Compare commits

2 Commits

Author SHA1 Message Date
yohlo
b458872ac1 images in badge popover 2025-10-06 02:23:25 -05:00
yohlo
afd0b692fa Badge images! 2025-10-06 02:13:51 -05:00
28 changed files with 148 additions and 67 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -1,4 +1,4 @@
import { Box, Container, Flex, Loader, useComputedColorScheme } from "@mantine/core"; import { Box, Container, Flex, Loader, Title, useComputedColorScheme } from "@mantine/core";
import { PropsWithChildren, Suspense, useEffect, useRef } from "react"; import { PropsWithChildren, Suspense, useEffect, useRef } from "react";
import { Drawer as VaulDrawer } from "vaul"; import { Drawer as VaulDrawer } from "vaul";
import styles from "./styles.module.css"; import styles from "./styles.module.css";
@@ -120,7 +120,7 @@ const Drawer: React.FC<DrawerProps> = ({
style={{ borderRadius: "9999px" }} style={{ borderRadius: "9999px" }}
/> />
<Container mx="auto" maw="28rem" px={0}> <Container mx="auto" maw="28rem" px={0}>
<VaulDrawer.Title>{title}</VaulDrawer.Title> <VaulDrawer.Title><Title order={2}>{title}</Title></VaulDrawer.Title>
<Suspense fallback={ <Suspense fallback={
<Flex justify='center' align='center' w='100%' h={400}> <Flex justify='center' align='center' w='100%' h={400}>
<Loader size='lg' /> <Loader size='lg' />

View File

@@ -1,8 +1,8 @@
import { Box, Text, Popover, Progress, Title } from "@mantine/core"; import { Box, Text, Progress, Image, Popover, Title, Stack } from "@mantine/core";
import { usePlayerBadges, useAllBadges } from "../queries"; import { usePlayerBadges, useAllBadges } from "../queries";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
import { Badge, BadgeProgress } from "../types"; import { Badge, BadgeProgress } from "../types";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { MedalIcon, LockKeyIcon } from "@phosphor-icons/react"; import { MedalIcon, LockKeyIcon } from "@phosphor-icons/react";
interface BadgeShowcaseProps { interface BadgeShowcaseProps {
@@ -16,6 +16,48 @@ interface BadgeDisplay {
progressText: string; progressText: string;
} }
interface BadgeIconProps {
badge: Badge;
earned: boolean;
}
const BadgeIcon = ({ badge, earned, size = 48 }: BadgeIconProps & { size?: number }) => {
const [imageError, setImageError] = useState(false);
const imagePath = `/static/img/${badge.key}.png`;
if (imageError) {
// Fallback to icon if image fails to load
return earned ? (
<MedalIcon
size={size}
weight="fill"
color="var(--mantine-primary-color-6)"
/>
) : (
<LockKeyIcon
size={size - 4}
weight="regular"
color="var(--mantine-color-dimmed)"
/>
);
}
return (
<Image
src={imagePath}
alt={badge.name}
width={size}
height={size}
onError={() => setImageError(true)}
style={{
objectFit: 'contain',
opacity: earned ? 1 : 0.4,
}}
/>
);
};
const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => { const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
const { user } = useAuth(); const { user } = useAuth();
const { data: badgeProgress } = usePlayerBadges(playerId); const { data: badgeProgress } = usePlayerBadges(playerId);
@@ -35,6 +77,15 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
continue; continue;
} }
const isVeteranBadge = /^veteran_\d+_badge$/.test(badge.key);
if (isVeteranBadge && !earned) {
continue;
}
if (badge.key === 'new_player_badge') {
continue;
}
let progressText = ""; let progressText = "";
if (progress) { if (progress) {
const target = getTargetProgress(badge); const target = getTargetProgress(badge);
@@ -114,7 +165,7 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
const showStack = stackCount > 1; const showStack = stackCount > 1;
return ( return (
<Popover key={display.badge.id} width={220} position="top" withArrow shadow="md"> <Popover key={display.badge.id} width={280} position="top" withArrow shadow="md" withinPortal>
<Popover.Target> <Popover.Target>
<Box <Box
style={{ style={{
@@ -129,15 +180,14 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
<Box <Box
key={i} key={i}
style={{ style={{
aspectRatio: '1', width: '85px',
height: '85px',
borderRadius: '12px', borderRadius: '12px',
background: 'transparent', background: 'transparent',
border: '2px solid var(--mantine-primary-color-5)', border: '2px solid var(--mantine-primary-color-5)',
position: 'absolute', position: 'absolute',
top: `${(i + 1) * 3}px`, top: `${(i + 1) * 3}px`,
left: `${(i + 1) * 3}px`, left: `${(i + 1) * 3}px`,
right: `-${(i + 1) * 3}px`,
bottom: `-${(i + 1) * 3}px`,
opacity: 0.4 - (i * 0.15), opacity: 0.4 - (i * 0.15),
zIndex: -(i + 1), zIndex: -(i + 1),
}} }}
@@ -148,7 +198,8 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
<Box <Box
style={{ style={{
aspectRatio: '1', width: '100px',
height: '100px',
borderRadius: '12px', borderRadius: '12px',
background: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-7))', background: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-7))',
border: display.earned border: display.earned
@@ -159,7 +210,6 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
justifyContent: 'center', justifyContent: 'center',
flexDirection: 'column', flexDirection: 'column',
gap: '4px', gap: '4px',
padding: 'var(--mantine-spacing-xs)',
position: 'relative', position: 'relative',
boxShadow: display.earned boxShadow: display.earned
? '0 0 0 1px color-mix(in srgb, var(--mantine-primary-color-6) 20%, transparent)' ? '0 0 0 1px color-mix(in srgb, var(--mantine-primary-color-6) 20%, transparent)'
@@ -168,19 +218,7 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
zIndex: 1, zIndex: 1,
}} }}
> >
{display.earned ? ( <BadgeIcon badge={display.badge} earned={display.earned} />
<MedalIcon
size={32}
weight="fill"
color="var(--mantine-primary-color-6)"
/>
) : (
<LockKeyIcon
size={28}
weight="regular"
color="var(--mantine-color-dimmed)"
/>
)}
{showStack && ( {showStack && (
<Box <Box
@@ -202,47 +240,53 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
</Box> </Box>
)} )}
<Title <Text
order={6} size="xs"
fw={display.earned ? 600 : 500} fw={display.earned ? 600 : 500}
ta="center" ta="center"
c={display.earned ? undefined : 'dimmed'} c={display.earned ? undefined : 'dimmed'}
style={{ lineHeight: 1.1 }} style={{ lineHeight: 1.1 }}
> >
{display.badge.name} {display.badge.name}
</Title> </Text>
</Box> </Box>
</Box> </Box>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
<Box> <Stack gap={4} align="center">
<Title order={5}> <Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{display.badge.name} <BadgeIcon badge={display.badge} earned={display.earned} size={80} />
</Title>
<Text size="xs" c="dimmed" mb={isCurrentUser ? "sm" : undefined}>
{display.badge.description}
</Text>
{isCurrentUser && (
<Box>
<Box mb="xs" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text size="xs" fw={500} c="dimmed">
Progress
</Text>
<Text size="xs" fw={600} c="dimmed">
{display.progressText}
</Text>
</Box>
<Progress
value={(display.progress?.progress || 0) / getTargetProgress(display.badge) * 100}
size="sm"
radius="sm"
color={display.earned ? "green" : undefined}
/>
</Box> </Box>
)}
</Box> <Title order={5} ta="center">
</Popover.Dropdown> {display.badge.name}
</Popover> </Title>
<Text size="sm" c="dimmed" ta="center">
{display.badge.description}
</Text>
{isCurrentUser && (
<Box>
<Box mb="xs" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text size="sm" fw={500} c="dimmed">
Progress&nbsp;
</Text>
<Text size="sm" fw={600} c="dimmed">
{display.progressText}
</Text>
</Box>
<Progress
value={(display.progress?.progress || 0) / getTargetProgress(display.badge) * 100}
size="sm"
radius="sm"
color={display.earned ? "green" : undefined}
/>
</Box>
)}
</Stack>
</Popover.Dropdown>
</Popover>
); );
})} })}
</Box> </Box>

View File

@@ -170,6 +170,38 @@ export function createBadgesService(pb: PocketBase) {
return tournamentsAttended; return tournamentsAttended;
} }
if (criteria.won_tournament !== undefined) {
const tournamentId = criteria.won_tournament;
try {
const tournamentMatches = await pb.collection("matches").getFullList({
filter: `tournament = "${tournamentId}" && status = "ended"`,
expand: 'home,away,home.players,away.players',
});
const winnersMatches = tournamentMatches.filter(m => !m.is_losers_bracket);
const finalsMatch = winnersMatches.reduce((highest: any, current: any) =>
(!highest || current.lid > highest.lid) ? current : highest, null);
if (finalsMatch && finalsMatch.status === 'ended') {
const finalsWinnerId = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.home : finalsMatch.away;
const winningTeam = finalsMatch.expand?.[finalsWinnerId === finalsMatch.home ? 'home' : 'away'];
const winningPlayers = winningTeam?.expand?.players || winningTeam?.players || [];
const playerWon = winningPlayers.some((p: any) =>
(typeof p === 'string' ? p : p.id) === playerId
);
return playerWon ? 1 : 0;
}
return 0;
} catch (error) {
return 0;
}
}
if (criteria.tournament_wins !== undefined) { if (criteria.tournament_wins !== undefined) {
if (tournamentIds.size === 0) return 0; if (tournamentIds.size === 0) return 0;
@@ -342,26 +374,28 @@ export function createBadgesService(pb: PocketBase) {
const criteria = badge.criteria; const criteria = badge.criteria;
return ( // Use explicit checks to handle 0 values correctly
criteria.matches_played || if (criteria.matches_played !== undefined) return criteria.matches_played;
criteria.tournament_wins || if (criteria.tournament_wins !== undefined) return criteria.tournament_wins;
criteria.tournaments_attended || if (criteria.tournaments_attended !== undefined) return criteria.tournaments_attended;
criteria.overtime_matches || if (criteria.overtime_matches !== undefined) return criteria.overtime_matches;
criteria.overtime_wins || if (criteria.overtime_wins !== undefined) return criteria.overtime_wins;
criteria.consecutive_wins || if (criteria.consecutive_wins !== undefined) return criteria.consecutive_wins;
1 if (criteria.won_tournament !== undefined) return 1;
); if (criteria.placement !== undefined) return 1;
if (criteria.margin_of_victory !== undefined) return 1;
if (criteria.tournament_record !== undefined) return 1;
return 1;
}, },
async awardManualBadge(playerId: string, badgeId: string): Promise<BadgeProgress> { async awardManualBadge(playerId: string, badgeId: string): Promise<BadgeProgress> {
// Get or create badge progress record
const existingProgress = await pb.collection("badge_progress").getFirstListItem( const existingProgress = await pb.collection("badge_progress").getFirstListItem(
`player = "${playerId}" && badge = "${badgeId}"`, `player = "${playerId}" && badge = "${badgeId}"`,
{ expand: 'badge' } { expand: 'badge' }
).catch(() => null); ).catch(() => null);
if (existingProgress) { if (existingProgress) {
// Update existing progress to mark as earned
const updated = await pb.collection("badge_progress").update(existingProgress.id, { const updated = await pb.collection("badge_progress").update(existingProgress.id, {
progress: 1, progress: 1,
earned: true, earned: true,
@@ -369,7 +403,6 @@ export function createBadgesService(pb: PocketBase) {
return transformBadgeProgress(updated); return transformBadgeProgress(updated);
} }
// Create new progress record
const created = await pb.collection("badge_progress").create({ const created = await pb.collection("badge_progress").create({
badge: badgeId, badge: badgeId,
player: playerId, player: playerId,
@@ -401,7 +434,11 @@ export function createBadgesService(pb: PocketBase) {
try { try {
const progress = await this.calculateBadgeProgress(playerId, badge); const progress = await this.calculateBadgeProgress(playerId, badge);
const target = this.getTargetProgress(badge); const target = this.getTargetProgress(badge);
const earned = progress >= target;
const isPlacementBadge = badge.criteria.placement !== undefined;
const earned = badge.progressive || isPlacementBadge
? progress >= target
: progress === target;
if (progress > 0 || earned) { if (progress > 0 || earned) {
await this.createBadgeProgress({ await this.createBadgeProgress({