working but sheet styling is ugly

This commit is contained in:
2025-10-16 12:32:26 -04:00
parent 49bbd1611c
commit fa98634402
12 changed files with 261 additions and 212 deletions

View File

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

View File

@@ -16,6 +16,7 @@ import { Route as AuthedRouteImport } from './routes/_authed'
import { Route as AuthedIndexRouteImport } from './routes/_authed/index'
import { Route as AuthedStatsRouteImport } from './routes/_authed/stats'
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 AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
@@ -76,6 +77,11 @@ const AuthedSettingsRoute = AuthedSettingsRouteImport.update({
path: '/settings',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedBadgesRoute = AuthedBadgesRouteImport.update({
id: '/badges',
path: '/badges',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedAdminRoute = AuthedAdminRouteImport.update({
id: '/admin',
path: '/admin',
@@ -215,6 +221,7 @@ export interface FileRoutesByFullPath {
'/logout': typeof LogoutRoute
'/refresh-session': typeof RefreshSessionRoute
'/admin': typeof AuthedAdminRouteWithChildren
'/badges': typeof AuthedBadgesRoute
'/settings': typeof AuthedSettingsRoute
'/stats': typeof AuthedStatsRoute
'/': typeof AuthedIndexRoute
@@ -247,6 +254,7 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/logout': typeof LogoutRoute
'/refresh-session': typeof RefreshSessionRoute
'/badges': typeof AuthedBadgesRoute
'/settings': typeof AuthedSettingsRoute
'/stats': typeof AuthedStatsRoute
'/': typeof AuthedIndexRoute
@@ -282,6 +290,7 @@ export interface FileRoutesById {
'/logout': typeof LogoutRoute
'/refresh-session': typeof RefreshSessionRoute
'/_authed/admin': typeof AuthedAdminRouteWithChildren
'/_authed/badges': typeof AuthedBadgesRoute
'/_authed/settings': typeof AuthedSettingsRoute
'/_authed/stats': typeof AuthedStatsRoute
'/_authed/': typeof AuthedIndexRoute
@@ -317,6 +326,7 @@ export interface FileRouteTypes {
| '/logout'
| '/refresh-session'
| '/admin'
| '/badges'
| '/settings'
| '/stats'
| '/'
@@ -349,6 +359,7 @@ export interface FileRouteTypes {
| '/login'
| '/logout'
| '/refresh-session'
| '/badges'
| '/settings'
| '/stats'
| '/'
@@ -383,6 +394,7 @@ export interface FileRouteTypes {
| '/logout'
| '/refresh-session'
| '/_authed/admin'
| '/_authed/badges'
| '/_authed/settings'
| '/_authed/stats'
| '/_authed/'
@@ -481,6 +493,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedSettingsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/badges': {
id: '/_authed/badges'
path: '/badges'
fullPath: '/badges'
preLoaderRoute: typeof AuthedBadgesRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/admin': {
id: '/_authed/admin'
path: '/admin'
@@ -687,6 +706,7 @@ const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren(
interface AuthedRouteChildren {
AuthedAdminRoute: typeof AuthedAdminRouteWithChildren
AuthedBadgesRoute: typeof AuthedBadgesRoute
AuthedSettingsRoute: typeof AuthedSettingsRoute
AuthedStatsRoute: typeof AuthedStatsRoute
AuthedIndexRoute: typeof AuthedIndexRoute
@@ -699,6 +719,7 @@ interface AuthedRouteChildren {
const AuthedRouteChildren: AuthedRouteChildren = {
AuthedAdminRoute: AuthedAdminRouteWithChildren,
AuthedBadgesRoute: AuthedBadgesRoute,
AuthedSettingsRoute: AuthedSettingsRoute,
AuthedStatsRoute: AuthedStatsRoute,
AuthedIndexRoute: AuthedIndexRoute,

View File

@@ -0,0 +1,33 @@
import BadgeStatsTable from '@/features/badges/components/badge-stats-table';
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',
},
refresh: [badgeQueries.allBadges().queryKey],
}),
});
function Badges() {
//TODO: CHANGE FALLBACK
return (
<Suspense fallback={<PlayerStatsTableSkeleton />}>
<div>
<BadgeStatsTable />
</div>
</Suspense>
);
}

View File

@@ -18,15 +18,15 @@ interface BadgeDisplay {
interface BadgeIconProps {
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 imagePath = `/static/img/${badge.key}.png`;
if (imageError) {
return earned ? (
return filled ? (
<MedalIcon
size={size}
weight="fill"
@@ -50,7 +50,7 @@ const BadgeIcon = ({ badge, earned, size = 48 }: BadgeIconProps & { size?: numbe
onError={() => setImageError(true)}
style={{
objectFit: 'contain',
opacity: earned ? 1 : 0.4,
opacity: filled ? 1 : 0.4,
}}
/>
);
@@ -218,7 +218,7 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
zIndex: 1,
}}
>
<BadgeIcon badge={display.badge} earned={display.earned} />
<BadgeIcon badge={display.badge} filled={display.earned} />
{showStack && (
<Box
@@ -256,7 +256,7 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
<Popover.Dropdown>
<Stack gap={4} align="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>
<Title order={5} ta="center">

View File

@@ -0,0 +1,129 @@
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';
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();
return (
<Box key={badge.id}>
<UnstyledButton onClick={badgeSheet.open} w='100%'>
<Grid p={'xs'}>
<Grid.Col span={2}>
<BadgeIcon badge={badge} filled={true} />
</Grid.Col>
<Grid.Col span={'auto'}>
<Text fw={700}>{badge.name}</Text>
<Text fw={500}>{badge.description}</Text>
</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 size='lg' fw={700}>
{(
((earnedBadges?.length ?? 0) / totalNumPlayers) *
100
).toFixed(0)}
%
</Text>
<Text size='xs'>of players</Text>
</Stack>
</Grid.Col>
</Grid>
</UnstyledButton>
<Sheet title={badge.name + ' Badge Holders'} {...badgeSheet.props}>
<Box mx='xl'>
{earnedBadges?.map((earnedBadge) => (
<Text>
{earnedBadge.player.first_name +
' ' +
earnedBadge.player.last_name}
</Text>
))}
</Box>
</Sheet>
{!isLastRow && <Divider />}
</Box>
);
};

View File

@@ -1,9 +1,10 @@
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { getPlayerBadges, getAllBadges } from "./server";
import { getPlayerBadges, getAllBadges, getAllEarnedBadges } from "./server";
export const badgeKeys = {
playerBadges: (playerId: string) => ['badges', 'player', playerId],
allBadges: () => ['badges', 'all'],
allEarnedBadges: () => ['badges', 'earned'],
};
export const badgeQueries = {
@@ -15,6 +16,10 @@ export const badgeQueries = {
queryKey: badgeKeys.allBadges(),
queryFn: async () => await getAllBadges()
}),
allEarnedBadges: () => ({
queryKey: badgeKeys.allEarnedBadges(),
queryFn: async () => await getAllEarnedBadges(),
}),
};
export const usePlayerBadges = (playerId: string) =>
@@ -22,3 +27,6 @@ export const usePlayerBadges = (playerId: string) =>
export const useAllBadges = () =>
useServerSuspenseQuery(badgeQueries.allBadges());
export const useAllEarnedBadges = () =>
useServerSuspenseQuery(badgeQueries.allEarnedBadges());

View File

@@ -1,5 +1,8 @@
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
import {
superTokensAdminFunctionMiddleware,
superTokensFunctionMiddleware,
} from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod";
@@ -19,14 +22,17 @@ export const migrateBadgeProgress = createServerFn()
export const getAllBadges = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () =>
toServerResult(() => pbAdmin.listBadges())
);
.handler(async () =>
toServerResult(() => pbAdmin.listBadges()));
export const getAllEarnedBadges = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () => toServerResult(() => pbAdmin.listEarnedBadges()));
export const awardManualBadge = createServerFn()
.inputValidator(z.object({
playerId: z.string(),
badgeId: z.string(),
playerId: z.string(),
badgeId: z.string(),
}))
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) =>

View File

@@ -1,3 +1,5 @@
import { PlayerInfo } from '../players/types';
export interface BadgeInfo {
id: string;
name: string;
@@ -23,3 +25,13 @@ export interface BadgeProgress {
created: string;
updated: string;
}
export interface EarnedBadge {
id: string;
badge: string;
player: PlayerInfo;
progress: number;
earned: boolean;
created: string;
updated: string;
}

View File

@@ -1,4 +1,4 @@
import { HouseIcon, RankingIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
import { HouseIcon, RankingIcon, SealIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
export const useLinks = (userId: string | undefined, roles: string[]) =>
@@ -25,6 +25,11 @@ export const useLinks = (userId: string | undefined, roles: string[]) =>
href: `/profile/${userId}`,
Icon: UserCircleIcon,
include: ['/settings']
},
{
label: 'Badges',
href: '/badges',
Icon: SealIcon
}
]

View File

@@ -16,22 +16,22 @@ export const playerQueries = {
export const useAvailablePlayers = (excludedPlayerIds: string[] = []) => {
const { data: allPlayers } = useServerSuspenseQuery(playerQueries.all());
const availablePlayers = useMemo(() => {
if (!allPlayers) return [];
return allPlayers.filter(player =>
!excludedPlayerIds.includes(player.id) &&
player.first_name &&
player.last_name
!excludedPlayerIds.includes(player.id) &&
player.first_name &&
player.last_name
);
}, [allPlayers, excludedPlayerIds]);
const playerOptions = useMemo(() =>
availablePlayers.map(player => ({
value: player.id,
value: player.id,
label: `${player.first_name} ${player.last_name}`.trim() || 'Unnamed Player'
})),
})),
[availablePlayers]
);
@@ -40,4 +40,6 @@ export const useAvailablePlayers = (excludedPlayerIds: string[] = []) => {
playerOptions,
allPlayers
};
};
};
export const useAllPlayers = () => useServerSuspenseQuery(playerQueries.all());

View File

@@ -1,6 +1,6 @@
import PocketBase from "pocketbase";
import { Badge, BadgeProgress } from "@/features/badges/types";
import { transformBadge, transformBadgeProgress } from "@/lib/pocketbase/util/transform-types";
import { Badge, BadgeProgress, EarnedBadge } from "@/features/badges/types";
import { transformBadge, transformBadgeProgress, transformEarnedBadge } from "@/lib/pocketbase/util/transform-types";
export interface PlayerStats {
player_id: string;
@@ -41,6 +41,14 @@ export function createBadgesService(pb: PocketBase) {
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: {
badge: string;
player: string;

View File

@@ -3,7 +3,7 @@ import { Match } from "@/features/matches/types";
import { Player, PlayerInfo } from "@/features/players/types";
import { Team, TeamInfo } from "@/features/teams/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";
// pocketbase does this weird thing with relations where it puts them under a seperate "expand" field
@@ -314,6 +314,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 {
return {
id: record.id,