Merge branch 'caro/badges-stats'
This commit is contained in:
@@ -1,187 +0,0 @@
|
|||||||
/// <reference path="../pb_data/types.d.ts" />
|
|
||||||
migrate((app) => {
|
|
||||||
const collection = app.findCollectionByNameOrId("pbc_5062686152");
|
|
||||||
|
|
||||||
return app.delete(collection);
|
|
||||||
}, (app) => {
|
|
||||||
const collection = new Collection({
|
|
||||||
"createRule": null,
|
|
||||||
"deleteRule": null,
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text3208210256",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "id",
|
|
||||||
"pattern": "^[a-z0-9]+$",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": true,
|
|
||||||
"required": true,
|
|
||||||
"system": true,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cascadeDelete": false,
|
|
||||||
"collectionId": "pbc_3072146508",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "relation2582050271",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"minSelect": 0,
|
|
||||||
"name": "player_id",
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "relation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cascadeDelete": false,
|
|
||||||
"collectionId": "pbc_1340419796",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "relation4154639100",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"minSelect": 0,
|
|
||||||
"name": "badge_id",
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "relation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "_clone_GhrR",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "badge_name",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "_clone_DEaW",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "badge_description",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "_clone_MHmw",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"name": "badge_type",
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "select",
|
|
||||||
"values": [
|
|
||||||
"tournament_participation",
|
|
||||||
"tournament_placement",
|
|
||||||
"performance",
|
|
||||||
"overtime",
|
|
||||||
"match_milestone"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "_clone_11YE",
|
|
||||||
"max": 50,
|
|
||||||
"min": 0,
|
|
||||||
"name": "badge_icon",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "_clone_qAJu",
|
|
||||||
"max": 50,
|
|
||||||
"min": 0,
|
|
||||||
"name": "badge_color",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "_clone_giOf",
|
|
||||||
"name": "is_progressive",
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "bool"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "json3212413036",
|
|
||||||
"maxSize": 1,
|
|
||||||
"name": "current_progress",
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "json4171899439",
|
|
||||||
"maxSize": 1,
|
|
||||||
"name": "target_progress",
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "json3435813110",
|
|
||||||
"maxSize": 1,
|
|
||||||
"name": "is_earned",
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "_clone_Q7lC",
|
|
||||||
"max": "",
|
|
||||||
"min": "",
|
|
||||||
"name": "earned_at",
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "date"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"id": "pbc_5062686152",
|
|
||||||
"indexes": [],
|
|
||||||
"listRule": null,
|
|
||||||
"name": "player_badges_view",
|
|
||||||
"system": false,
|
|
||||||
"type": "view",
|
|
||||||
"updateRule": null,
|
|
||||||
"viewQuery": "\n SELECT\n (p.id || '_' || b.id) as id,\n p.id as player_id,\n b.id as badge_id,\n b.name as badge_name,\n b.description as badge_description,\n b.type as badge_type,\n b.icon as badge_icon,\n b.color as badge_color,\n b.is_progressive,\n COALESCE(pbp.current_progress, 0) as current_progress,\n COALESCE(pbp.target_progress, b.progress_target, 1) as target_progress,\n COALESCE(pbp.is_earned, false) as is_earned,\n pbp.earned_at\n FROM players p\n CROSS JOIN badges b\n LEFT JOIN player_badge_progress pbp ON pbp.player_id = p.id AND pbp.badge_id = b.id\n ",
|
|
||||||
"viewRule": null
|
|
||||||
});
|
|
||||||
|
|
||||||
return app.save(collection);
|
|
||||||
})
|
|
||||||
@@ -16,6 +16,7 @@ import { Route as AuthedRouteImport } from './routes/_authed'
|
|||||||
import { Route as AuthedIndexRouteImport } from './routes/_authed/index'
|
import { Route as AuthedIndexRouteImport } from './routes/_authed/index'
|
||||||
import { Route as AuthedStatsRouteImport } from './routes/_authed/stats'
|
import { Route as AuthedStatsRouteImport } from './routes/_authed/stats'
|
||||||
import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings'
|
import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings'
|
||||||
|
import { Route as AuthedBadgesRouteImport } from './routes/_authed/badges'
|
||||||
import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
|
import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
|
||||||
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
|
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
|
||||||
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
|
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
|
||||||
@@ -76,6 +77,11 @@ const AuthedSettingsRoute = AuthedSettingsRouteImport.update({
|
|||||||
path: '/settings',
|
path: '/settings',
|
||||||
getParentRoute: () => AuthedRoute,
|
getParentRoute: () => AuthedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthedBadgesRoute = AuthedBadgesRouteImport.update({
|
||||||
|
id: '/badges',
|
||||||
|
path: '/badges',
|
||||||
|
getParentRoute: () => AuthedRoute,
|
||||||
|
} as any)
|
||||||
const AuthedAdminRoute = AuthedAdminRouteImport.update({
|
const AuthedAdminRoute = AuthedAdminRouteImport.update({
|
||||||
id: '/admin',
|
id: '/admin',
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
@@ -215,6 +221,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/logout': typeof LogoutRoute
|
'/logout': typeof LogoutRoute
|
||||||
'/refresh-session': typeof RefreshSessionRoute
|
'/refresh-session': typeof RefreshSessionRoute
|
||||||
'/admin': typeof AuthedAdminRouteWithChildren
|
'/admin': typeof AuthedAdminRouteWithChildren
|
||||||
|
'/badges': typeof AuthedBadgesRoute
|
||||||
'/settings': typeof AuthedSettingsRoute
|
'/settings': typeof AuthedSettingsRoute
|
||||||
'/stats': typeof AuthedStatsRoute
|
'/stats': typeof AuthedStatsRoute
|
||||||
'/': typeof AuthedIndexRoute
|
'/': typeof AuthedIndexRoute
|
||||||
@@ -247,6 +254,7 @@ export interface FileRoutesByTo {
|
|||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/logout': typeof LogoutRoute
|
'/logout': typeof LogoutRoute
|
||||||
'/refresh-session': typeof RefreshSessionRoute
|
'/refresh-session': typeof RefreshSessionRoute
|
||||||
|
'/badges': typeof AuthedBadgesRoute
|
||||||
'/settings': typeof AuthedSettingsRoute
|
'/settings': typeof AuthedSettingsRoute
|
||||||
'/stats': typeof AuthedStatsRoute
|
'/stats': typeof AuthedStatsRoute
|
||||||
'/': typeof AuthedIndexRoute
|
'/': typeof AuthedIndexRoute
|
||||||
@@ -282,6 +290,7 @@ export interface FileRoutesById {
|
|||||||
'/logout': typeof LogoutRoute
|
'/logout': typeof LogoutRoute
|
||||||
'/refresh-session': typeof RefreshSessionRoute
|
'/refresh-session': typeof RefreshSessionRoute
|
||||||
'/_authed/admin': typeof AuthedAdminRouteWithChildren
|
'/_authed/admin': typeof AuthedAdminRouteWithChildren
|
||||||
|
'/_authed/badges': typeof AuthedBadgesRoute
|
||||||
'/_authed/settings': typeof AuthedSettingsRoute
|
'/_authed/settings': typeof AuthedSettingsRoute
|
||||||
'/_authed/stats': typeof AuthedStatsRoute
|
'/_authed/stats': typeof AuthedStatsRoute
|
||||||
'/_authed/': typeof AuthedIndexRoute
|
'/_authed/': typeof AuthedIndexRoute
|
||||||
@@ -317,6 +326,7 @@ export interface FileRouteTypes {
|
|||||||
| '/logout'
|
| '/logout'
|
||||||
| '/refresh-session'
|
| '/refresh-session'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
|
| '/badges'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/stats'
|
| '/stats'
|
||||||
| '/'
|
| '/'
|
||||||
@@ -349,6 +359,7 @@ export interface FileRouteTypes {
|
|||||||
| '/login'
|
| '/login'
|
||||||
| '/logout'
|
| '/logout'
|
||||||
| '/refresh-session'
|
| '/refresh-session'
|
||||||
|
| '/badges'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/stats'
|
| '/stats'
|
||||||
| '/'
|
| '/'
|
||||||
@@ -383,6 +394,7 @@ export interface FileRouteTypes {
|
|||||||
| '/logout'
|
| '/logout'
|
||||||
| '/refresh-session'
|
| '/refresh-session'
|
||||||
| '/_authed/admin'
|
| '/_authed/admin'
|
||||||
|
| '/_authed/badges'
|
||||||
| '/_authed/settings'
|
| '/_authed/settings'
|
||||||
| '/_authed/stats'
|
| '/_authed/stats'
|
||||||
| '/_authed/'
|
| '/_authed/'
|
||||||
@@ -481,6 +493,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedSettingsRouteImport
|
preLoaderRoute: typeof AuthedSettingsRouteImport
|
||||||
parentRoute: typeof AuthedRoute
|
parentRoute: typeof AuthedRoute
|
||||||
}
|
}
|
||||||
|
'/_authed/badges': {
|
||||||
|
id: '/_authed/badges'
|
||||||
|
path: '/badges'
|
||||||
|
fullPath: '/badges'
|
||||||
|
preLoaderRoute: typeof AuthedBadgesRouteImport
|
||||||
|
parentRoute: typeof AuthedRoute
|
||||||
|
}
|
||||||
'/_authed/admin': {
|
'/_authed/admin': {
|
||||||
id: '/_authed/admin'
|
id: '/_authed/admin'
|
||||||
path: '/admin'
|
path: '/admin'
|
||||||
@@ -687,6 +706,7 @@ const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren(
|
|||||||
|
|
||||||
interface AuthedRouteChildren {
|
interface AuthedRouteChildren {
|
||||||
AuthedAdminRoute: typeof AuthedAdminRouteWithChildren
|
AuthedAdminRoute: typeof AuthedAdminRouteWithChildren
|
||||||
|
AuthedBadgesRoute: typeof AuthedBadgesRoute
|
||||||
AuthedSettingsRoute: typeof AuthedSettingsRoute
|
AuthedSettingsRoute: typeof AuthedSettingsRoute
|
||||||
AuthedStatsRoute: typeof AuthedStatsRoute
|
AuthedStatsRoute: typeof AuthedStatsRoute
|
||||||
AuthedIndexRoute: typeof AuthedIndexRoute
|
AuthedIndexRoute: typeof AuthedIndexRoute
|
||||||
@@ -699,6 +719,7 @@ interface AuthedRouteChildren {
|
|||||||
|
|
||||||
const AuthedRouteChildren: AuthedRouteChildren = {
|
const AuthedRouteChildren: AuthedRouteChildren = {
|
||||||
AuthedAdminRoute: AuthedAdminRouteWithChildren,
|
AuthedAdminRoute: AuthedAdminRouteWithChildren,
|
||||||
|
AuthedBadgesRoute: AuthedBadgesRoute,
|
||||||
AuthedSettingsRoute: AuthedSettingsRoute,
|
AuthedSettingsRoute: AuthedSettingsRoute,
|
||||||
AuthedStatsRoute: AuthedStatsRoute,
|
AuthedStatsRoute: AuthedStatsRoute,
|
||||||
AuthedIndexRoute: AuthedIndexRoute,
|
AuthedIndexRoute: AuthedIndexRoute,
|
||||||
|
|||||||
34
src/app/routes/_authed/badges.tsx
Normal file
34
src/app/routes/_authed/badges.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import BadgeStatsTable from '@/features/badges/components/badge-stats-table';
|
||||||
|
import BadgeStatsTableSkeleton from '@/features/badges/components/badge-stats-table-skeleton';
|
||||||
|
import { badgeQueries, useAllBadges } from '@/features/badges/queries';
|
||||||
|
import PlayerStatsTableSkeleton from '@/features/players/components/player-stats-table-skeleton';
|
||||||
|
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authed/badges')({
|
||||||
|
component: Badges,
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
const queryClient = context.queryClient;
|
||||||
|
prefetchServerQuery(queryClient, badgeQueries.allBadges());
|
||||||
|
},
|
||||||
|
loader: () => ({
|
||||||
|
withPadding: false,
|
||||||
|
fullWidth: true,
|
||||||
|
header: {
|
||||||
|
title: 'All Badges',
|
||||||
|
withBackButton: true,
|
||||||
|
},
|
||||||
|
refresh: [badgeQueries.allBadges().queryKey],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function Badges() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<BadgeStatsTableSkeleton />}>
|
||||||
|
<div>
|
||||||
|
<BadgeStatsTable />
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,15 +18,15 @@ interface BadgeDisplay {
|
|||||||
|
|
||||||
interface BadgeIconProps {
|
interface BadgeIconProps {
|
||||||
badge: Badge;
|
badge: Badge;
|
||||||
earned: boolean;
|
filled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BadgeIcon = ({ badge, earned, size = 48 }: BadgeIconProps & { size?: number }) => {
|
export const BadgeIcon = ({ badge, filled, size = 48 }: BadgeIconProps & { size?: number }) => {
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
const imagePath = `/static/img/${badge.key}.png`;
|
const imagePath = `/static/img/${badge.key}.png`;
|
||||||
|
|
||||||
if (imageError) {
|
if (imageError) {
|
||||||
return earned ? (
|
return filled ? (
|
||||||
<MedalIcon
|
<MedalIcon
|
||||||
size={size}
|
size={size}
|
||||||
weight="fill"
|
weight="fill"
|
||||||
@@ -50,7 +50,7 @@ const BadgeIcon = ({ badge, earned, size = 48 }: BadgeIconProps & { size?: numbe
|
|||||||
onError={() => setImageError(true)}
|
onError={() => setImageError(true)}
|
||||||
style={{
|
style={{
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
opacity: earned ? 1 : 0.4,
|
opacity: filled ? 1 : 0.4,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -218,7 +218,7 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
|
|||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BadgeIcon badge={display.badge} earned={display.earned} />
|
<BadgeIcon badge={display.badge} filled={display.earned} />
|
||||||
|
|
||||||
{showStack && (
|
{showStack && (
|
||||||
<Box
|
<Box
|
||||||
@@ -256,7 +256,7 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
|
|||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
<Stack gap={4} align="center">
|
<Stack gap={4} align="center">
|
||||||
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<BadgeIcon badge={display.badge} earned={display.earned} size={80} />
|
<BadgeIcon badge={display.badge} filled={display.earned} size={80} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Title order={5} ta="center">
|
<Title order={5} ta="center">
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Container,
|
||||||
|
Box,
|
||||||
|
Divider,
|
||||||
|
Grid,
|
||||||
|
Skeleton,
|
||||||
|
} from '@mantine/core';
|
||||||
|
|
||||||
|
const BadgeStatsTableSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<Container size='100%' px={0}>
|
||||||
|
<Stack gap='xs'>
|
||||||
|
<Stack gap={0}>
|
||||||
|
{Array.from({ length: 15 }).map((_, index) => (
|
||||||
|
<BadgeStatRowSkeleton
|
||||||
|
key={`skeleton-${index}`}
|
||||||
|
isLastRow={index >= 14}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BadgeStatsTableSkeleton;
|
||||||
|
|
||||||
|
interface BadgeStatRowSkeletonProps {
|
||||||
|
isLastRow: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BadgeStatRowSkeleton: React.FC<BadgeStatRowSkeletonProps> = ({
|
||||||
|
isLastRow,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Grid p={'xs'} align='center'>
|
||||||
|
<Grid.Col span={2} style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Skeleton circle height={48} width={48} />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={'auto'}>
|
||||||
|
<Stack gap={8}>
|
||||||
|
<Skeleton height={16} width='60%' />
|
||||||
|
<Skeleton height={14} width='80%' />
|
||||||
|
<Skeleton height={12} width='50%' mt={4} />
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
width: '73px',
|
||||||
|
flexBasis: '73px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap={4} align='center'>
|
||||||
|
<Skeleton height={20} width={40} />
|
||||||
|
<Skeleton height={12} width={50} />
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
{!isLastRow && <Divider />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
177
src/features/badges/components/badge-stats-table.tsx
Normal file
177
src/features/badges/components/badge-stats-table.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Container,
|
||||||
|
Title,
|
||||||
|
Box,
|
||||||
|
Divider,
|
||||||
|
Text,
|
||||||
|
Grid,
|
||||||
|
UnstyledButton,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useAllBadges, useAllEarnedBadges } from '../queries';
|
||||||
|
import { BadgeIcon } from './badge-showcase';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Badge, EarnedBadge } from '../types';
|
||||||
|
import { useAllPlayers } from '@/features/teams/hooks/use-available-players';
|
||||||
|
import { useSheet } from '@/hooks/use-sheet';
|
||||||
|
import Sheet from '@/components/sheet/sheet';
|
||||||
|
import PlayerList from '@/features/players/components/player-list';
|
||||||
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
|
import { Player } from '@/features/players/types';
|
||||||
|
|
||||||
|
const BadgeStatsTable = () => {
|
||||||
|
const { data: allBadges } = useAllBadges();
|
||||||
|
const { data: allEarnedBadges } = useAllEarnedBadges();
|
||||||
|
const { data: allPlayers } = useAllPlayers();
|
||||||
|
const totalNumPlayers = allPlayers?.length || 0;
|
||||||
|
|
||||||
|
const groupedEarnedBadges = useMemo(() => {
|
||||||
|
const returnDict = new Map<string, EarnedBadge[]>();
|
||||||
|
allEarnedBadges?.forEach((earnedBadge) => {
|
||||||
|
if (!returnDict.has(earnedBadge.badge)) {
|
||||||
|
returnDict.set(earnedBadge.badge, []);
|
||||||
|
}
|
||||||
|
returnDict.get(earnedBadge.badge)!.push(earnedBadge);
|
||||||
|
});
|
||||||
|
return returnDict;
|
||||||
|
}, [allEarnedBadges]);
|
||||||
|
|
||||||
|
if (allBadges.length === 0) {
|
||||||
|
return (
|
||||||
|
<Container px={0} size='md'>
|
||||||
|
<Stack align='center' gap='md' py='xl'>
|
||||||
|
<Title order={3} c='dimmed'>
|
||||||
|
No Badges Available
|
||||||
|
</Title>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size='100%' px={0}>
|
||||||
|
<Stack gap='xs'>
|
||||||
|
<Stack gap={0}>
|
||||||
|
{allBadges.map((badge, index) => (
|
||||||
|
<BadgeStatRow
|
||||||
|
badge={badge}
|
||||||
|
totalNumPlayers={totalNumPlayers}
|
||||||
|
earnedBadges={groupedEarnedBadges.get(badge.id) || []}
|
||||||
|
isLastRow={index >= allBadges.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BadgeStatsTable;
|
||||||
|
|
||||||
|
interface BadgeStatRowProps {
|
||||||
|
badge: Badge;
|
||||||
|
totalNumPlayers: number;
|
||||||
|
earnedBadges: EarnedBadge[];
|
||||||
|
isLastRow: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BadgeStatRow: React.FC<BadgeStatRowProps> = ({
|
||||||
|
badge,
|
||||||
|
totalNumPlayers,
|
||||||
|
earnedBadges,
|
||||||
|
isLastRow,
|
||||||
|
}) => {
|
||||||
|
const badgeSheet = useSheet();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const playerNamesBlurb = useMemo(() => {
|
||||||
|
if (earnedBadges.length === 0) return 'No players yet';
|
||||||
|
|
||||||
|
const currentUserHasBadge = earnedBadges.some(
|
||||||
|
(eb) => eb.player.id === user?.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const otherPlayers = earnedBadges.filter(
|
||||||
|
(eb) => eb.player.id !== user?.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayPlayers = currentUserHasBadge
|
||||||
|
? otherPlayers.slice(0, 2)
|
||||||
|
: earnedBadges.slice(0, 3);
|
||||||
|
|
||||||
|
const names = displayPlayers.map((eb) => eb.player.first_name);
|
||||||
|
|
||||||
|
if (currentUserHasBadge) {
|
||||||
|
const remaining = earnedBadges.length - 1 - names.length;
|
||||||
|
if (names.length === 0 && remaining === 0) {
|
||||||
|
return 'You';
|
||||||
|
} else if (names.length === 0 && remaining > 0) {
|
||||||
|
return `You and ${remaining} other${remaining > 1 ? 's' : ''}`;
|
||||||
|
} else if (remaining > 0) {
|
||||||
|
return `You, ${names.join(', ')} and ${remaining} other${remaining > 1 ? 's' : ''}`;
|
||||||
|
} else {
|
||||||
|
return `You${names.length > 0 ? ` and ${names.join(', ')}` : ''}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const remaining = earnedBadges.length - names.length;
|
||||||
|
if (remaining > 0) {
|
||||||
|
return `${names.join(', ')} and ${remaining} other${remaining > 1 ? 's' : ''}`;
|
||||||
|
} else {
|
||||||
|
return names.join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [earnedBadges, user?.id]);
|
||||||
|
|
||||||
|
const playersForList: Player[] = useMemo(() => {
|
||||||
|
return earnedBadges.map((eb) => ({
|
||||||
|
id: eb.player.id,
|
||||||
|
first_name: eb.player.first_name,
|
||||||
|
last_name: eb.player.last_name,
|
||||||
|
} as Player));
|
||||||
|
}, [earnedBadges]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={badge.id}>
|
||||||
|
<UnstyledButton onClick={badgeSheet.open} w='100%'>
|
||||||
|
<Grid p={'xs'} align='center'>
|
||||||
|
<Grid.Col span={2} style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<BadgeIcon badge={badge} filled={true} />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={'auto'}>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text fw={700}>{badge.name}</Text>
|
||||||
|
<Text size="sm" fw={500}>{badge.description}</Text>
|
||||||
|
<Text size='xs' c='dimmed' mt={4}>
|
||||||
|
{playerNamesBlurb}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
width: '73px',
|
||||||
|
flexBasis: '73px', // ensures the width is respected
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap={0} align='center'>
|
||||||
|
<Text c="dimmed" size='lg' fw={700}>
|
||||||
|
{(
|
||||||
|
((earnedBadges?.length ?? 0) / totalNumPlayers) *
|
||||||
|
100
|
||||||
|
).toFixed(0)}
|
||||||
|
%
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed" size='xs'>of players</Text>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</UnstyledButton>
|
||||||
|
<Sheet title={badge.name + ' Badge Holders'} {...badgeSheet.props}>
|
||||||
|
<PlayerList players={playersForList} />
|
||||||
|
</Sheet>
|
||||||
|
{!isLastRow && <Divider />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
import { getPlayerBadges, getAllBadges } from "./server";
|
import { getPlayerBadges, getAllBadges, getAllEarnedBadges } from "./server";
|
||||||
|
|
||||||
export const badgeKeys = {
|
export const badgeKeys = {
|
||||||
playerBadges: (playerId: string) => ['badges', 'player', playerId],
|
playerBadges: (playerId: string) => ['badges', 'player', playerId],
|
||||||
allBadges: () => ['badges', 'all'],
|
allBadges: () => ['badges', 'all'],
|
||||||
|
allEarnedBadges: () => ['badges', 'earned'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const badgeQueries = {
|
export const badgeQueries = {
|
||||||
@@ -15,6 +16,10 @@ export const badgeQueries = {
|
|||||||
queryKey: badgeKeys.allBadges(),
|
queryKey: badgeKeys.allBadges(),
|
||||||
queryFn: async () => await getAllBadges()
|
queryFn: async () => await getAllBadges()
|
||||||
}),
|
}),
|
||||||
|
allEarnedBadges: () => ({
|
||||||
|
queryKey: badgeKeys.allEarnedBadges(),
|
||||||
|
queryFn: async () => await getAllEarnedBadges(),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePlayerBadges = (playerId: string) =>
|
export const usePlayerBadges = (playerId: string) =>
|
||||||
@@ -22,3 +27,6 @@ export const usePlayerBadges = (playerId: string) =>
|
|||||||
|
|
||||||
export const useAllBadges = () =>
|
export const useAllBadges = () =>
|
||||||
useServerSuspenseQuery(badgeQueries.allBadges());
|
useServerSuspenseQuery(badgeQueries.allBadges());
|
||||||
|
|
||||||
|
export const useAllEarnedBadges = () =>
|
||||||
|
useServerSuspenseQuery(badgeQueries.allEarnedBadges());
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ export const getAllBadges = createServerFn()
|
|||||||
toServerResult(() => pbAdmin.listBadges())
|
toServerResult(() => pbAdmin.listBadges())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getAllEarnedBadges = createServerFn()
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async () => toServerResult(() => pbAdmin.listEarnedBadges()));
|
||||||
|
|
||||||
export const awardManualBadge = createServerFn()
|
export const awardManualBadge = createServerFn()
|
||||||
.inputValidator(z.object({
|
.inputValidator(z.object({
|
||||||
playerId: z.string(),
|
playerId: z.string(),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { PlayerInfo } from '../players/types';
|
||||||
|
|
||||||
export interface BadgeInfo {
|
export interface BadgeInfo {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -23,3 +25,13 @@ export interface BadgeProgress {
|
|||||||
created: string;
|
created: string;
|
||||||
updated: string;
|
updated: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EarnedBadge {
|
||||||
|
id: string;
|
||||||
|
badge: string;
|
||||||
|
player: PlayerInfo;
|
||||||
|
progress: number;
|
||||||
|
earned: boolean;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Box, Stack, Text, Divider, Group, Button } from "@mantine/core";
|
import { Box, Stack, Text, Divider, Group, Button, Anchor } from "@mantine/core";
|
||||||
import { Suspense, useState, useDeferredValue } from "react";
|
import { Suspense, useState, useDeferredValue } from "react";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
|
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
|
||||||
@@ -51,7 +52,12 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
label: "Overview",
|
label: "Overview",
|
||||||
content: <>
|
content: <>
|
||||||
<Stack px="md">
|
<Stack px="md">
|
||||||
<Text size="md" fw={700}>Badges</Text>
|
<Group justify="space-between" align="center">
|
||||||
|
<Text size="md" fw={700}>Badges</Text>
|
||||||
|
<Anchor component={Link} to="/badges" size="sm" fw={500}>
|
||||||
|
View all badges
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
<Suspense fallback={<BadgeShowcaseSkeleton />}>
|
<Suspense fallback={<BadgeShowcaseSkeleton />}>
|
||||||
<BadgeShowcase playerId={id} />
|
<BadgeShowcase playerId={id} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -41,3 +41,5 @@ export const useAvailablePlayers = (excludedPlayerIds: string[] = []) => {
|
|||||||
allPlayers
|
allPlayers
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useAllPlayers = () => useServerSuspenseQuery(playerQueries.all());
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import PocketBase from "pocketbase";
|
import PocketBase from "pocketbase";
|
||||||
import { Badge, BadgeProgress } from "@/features/badges/types";
|
import { Badge, BadgeProgress, EarnedBadge } from "@/features/badges/types";
|
||||||
import { transformBadge, transformBadgeProgress } from "@/lib/pocketbase/util/transform-types";
|
import { transformBadge, transformBadgeProgress, transformEarnedBadge } from "@/lib/pocketbase/util/transform-types";
|
||||||
|
|
||||||
export interface PlayerStats {
|
export interface PlayerStats {
|
||||||
player_id: string;
|
player_id: string;
|
||||||
@@ -41,6 +41,14 @@ export function createBadgesService(pb: PocketBase) {
|
|||||||
return results.map(transformBadgeProgress);
|
return results.map(transformBadgeProgress);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listEarnedBadges(): Promise<EarnedBadge[]> {
|
||||||
|
const results = await pb.collection("badge_progress").getFullList({
|
||||||
|
filter: `earned = true`,
|
||||||
|
expand: 'player',
|
||||||
|
});
|
||||||
|
return results.map(transformEarnedBadge);
|
||||||
|
},
|
||||||
|
|
||||||
async createBadgeProgress(data: {
|
async createBadgeProgress(data: {
|
||||||
badge: string;
|
badge: string;
|
||||||
player: string;
|
player: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Match } from "@/features/matches/types";
|
|||||||
import { Player, PlayerInfo } from "@/features/players/types";
|
import { Player, PlayerInfo } from "@/features/players/types";
|
||||||
import { Team, TeamInfo } from "@/features/teams/types";
|
import { Team, TeamInfo } from "@/features/teams/types";
|
||||||
import { Tournament, TournamentInfo } from "@/features/tournaments/types";
|
import { Tournament, TournamentInfo } from "@/features/tournaments/types";
|
||||||
import { Badge, BadgeInfo, BadgeProgress } from "@/features/badges/types";
|
import { Badge, BadgeInfo, BadgeProgress, EarnedBadge } from "@/features/badges/types";
|
||||||
import { Activity } from "../services/activities";
|
import { Activity } from "../services/activities";
|
||||||
|
|
||||||
// pocketbase does this weird thing with relations where it puts them under a seperate "expand" field
|
// pocketbase does this weird thing with relations where it puts them under a seperate "expand" field
|
||||||
@@ -312,6 +312,18 @@ export function transformBadgeProgress(record: any): BadgeProgress {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function transformEarnedBadge(record: any): EarnedBadge {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
badge: record.badge,
|
||||||
|
player: record.expand?.player ? transformPlayerInfo(record.expand.player) : record.player,
|
||||||
|
progress: record.progress,
|
||||||
|
earned: record.earned,
|
||||||
|
created: record.created,
|
||||||
|
updated: record.updated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function transformActivity(record: any): Activity {
|
export function transformActivity(record: any): Activity {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export * from './use-optimistic-mutation';
|
export * from './use-optimistic-mutation';
|
||||||
export * from './use-server-mutation';
|
export * from './use-server-mutation';
|
||||||
export * from './use-server-query';
|
export * from './use-server-query';
|
||||||
export * from './user-server-suspense-query';
|
export * from './use-server-suspense-query';
|
||||||
|
|||||||
Reference in New Issue
Block a user