badges
This commit is contained in:
47
src/features/badges/components/badge-showcase-skeleton.tsx
Normal file
47
src/features/badges/components/badge-showcase-skeleton.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Box, Card, Skeleton, Text } from "@mantine/core";
|
||||
|
||||
const BadgeShowcaseSkeleton = () => {
|
||||
return (
|
||||
<Box mb="lg">
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p={0}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
p="md"
|
||||
style={{
|
||||
background: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6))',
|
||||
borderBottom: '1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))',
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: '0.5px' }}>
|
||||
Badges
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
p="md"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))',
|
||||
gap: 'var(--mantine-spacing-sm)',
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
height={70}
|
||||
radius="md"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BadgeShowcaseSkeleton;
|
||||
189
src/features/badges/components/badge-showcase.tsx
Normal file
189
src/features/badges/components/badge-showcase.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { Box, Text, Tooltip, Card } from "@mantine/core";
|
||||
import { usePlayerBadges, useAllBadges } from "../queries";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { Badge, BadgeProgress } from "../types";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface BadgeShowcaseProps {
|
||||
playerId: string;
|
||||
}
|
||||
|
||||
interface BadgeDisplay {
|
||||
badge: Badge;
|
||||
progress?: BadgeProgress;
|
||||
earned: boolean;
|
||||
progressText: string;
|
||||
}
|
||||
|
||||
const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
|
||||
const { user } = useAuth();
|
||||
const { data: badgeProgress } = usePlayerBadges(playerId);
|
||||
const { data: allBadges } = useAllBadges();
|
||||
|
||||
const isCurrentUser = user?.id === playerId;
|
||||
|
||||
const badgesToDisplay = useMemo(() => {
|
||||
const displays: BadgeDisplay[] = [];
|
||||
|
||||
if (isCurrentUser) {
|
||||
for (const badge of allBadges) {
|
||||
const progress = badgeProgress.find(bp => bp.badge.id === badge.id);
|
||||
const earned = progress?.earned || false;
|
||||
|
||||
if (badge.type === 'manual' && !earned) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let progressText = "";
|
||||
if (progress) {
|
||||
const target = getTargetProgress(badge);
|
||||
progressText = `${progress.progress} / ${target}`;
|
||||
} else {
|
||||
const target = getTargetProgress(badge);
|
||||
progressText = `0 / ${target}`;
|
||||
}
|
||||
|
||||
displays.push({
|
||||
badge,
|
||||
progress,
|
||||
earned,
|
||||
progressText,
|
||||
});
|
||||
}
|
||||
|
||||
displays.sort((a, b) => {
|
||||
if (a.earned && !b.earned) return -1;
|
||||
if (!a.earned && b.earned) return 1;
|
||||
return a.badge.order - b.badge.order;
|
||||
});
|
||||
} else {
|
||||
const earnedProgress = badgeProgress.filter(bp => bp.earned);
|
||||
for (const progress of earnedProgress) {
|
||||
const badge: Badge = {
|
||||
...progress.badge,
|
||||
criteria: {},
|
||||
created: progress.created,
|
||||
updated: progress.updated,
|
||||
};
|
||||
|
||||
const target = getTargetProgress(badge);
|
||||
displays.push({
|
||||
badge,
|
||||
progress,
|
||||
earned: true,
|
||||
progressText: `${progress.progress} / ${target}`,
|
||||
});
|
||||
}
|
||||
|
||||
displays.sort((a, b) => a.badge.order - b.badge.order);
|
||||
}
|
||||
|
||||
return displays;
|
||||
}, [allBadges, badgeProgress, isCurrentUser]);
|
||||
|
||||
if (badgesToDisplay.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box mb="lg">
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p={0}
|
||||
>
|
||||
<Box
|
||||
p="md"
|
||||
style={{
|
||||
background: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6))',
|
||||
borderBottom: '1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))'
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: '0.5px' }}>
|
||||
Badges
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
p="md"
|
||||
mah={120}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))',
|
||||
gap: 'var(--mantine-spacing-sm)',
|
||||
overflow: 'scroll',
|
||||
}}
|
||||
>
|
||||
{badgesToDisplay.map((display) => (
|
||||
<Tooltip
|
||||
key={display.badge.id}
|
||||
label={
|
||||
<Box>
|
||||
<Text size="xs" fw={600} mb={4}>
|
||||
{display.badge.name}
|
||||
</Text>
|
||||
<Text size="xs" mb={4}>
|
||||
{display.badge.description}
|
||||
</Text>
|
||||
{isCurrentUser && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Progress: {display.progressText}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
multiline
|
||||
w={220}
|
||||
>
|
||||
<Card
|
||||
withBorder
|
||||
padding="sm"
|
||||
radius="md"
|
||||
shadow={display.earned ? "xs" : undefined}
|
||||
style={(theme) => ({
|
||||
opacity: display.earned ? 1 : 0.35,
|
||||
cursor: "pointer",
|
||||
transition: 'all 0.2s ease',
|
||||
minHeight: 70,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderStyle: display.earned ? 'solid' : 'dashed',
|
||||
':hover': {
|
||||
transform: display.earned ? 'translateY(-2px)' : 'none',
|
||||
boxShadow: display.earned ? theme.shadows.sm : undefined,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
ta="center"
|
||||
fw={display.earned ? 600 : 500}
|
||||
c={display.earned ? undefined : "dimmed"}
|
||||
style={{ lineHeight: 1.3 }}
|
||||
>
|
||||
{display.badge.name}
|
||||
</Text>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
function getTargetProgress(badge: Badge): number {
|
||||
const criteria = badge.criteria;
|
||||
return (
|
||||
criteria.matches_played ||
|
||||
criteria.tournament_wins ||
|
||||
criteria.tournaments_attended ||
|
||||
criteria.overtime_matches ||
|
||||
criteria.overtime_wins ||
|
||||
criteria.consecutive_wins ||
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
export default BadgeShowcase;
|
||||
24
src/features/badges/queries.ts
Normal file
24
src/features/badges/queries.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||
import { getPlayerBadges, getAllBadges } from "./server";
|
||||
|
||||
export const badgeKeys = {
|
||||
playerBadges: (playerId: string) => ['badges', 'player', playerId],
|
||||
allBadges: () => ['badges', 'all'],
|
||||
};
|
||||
|
||||
export const badgeQueries = {
|
||||
playerBadges: (playerId: string) => ({
|
||||
queryKey: badgeKeys.playerBadges(playerId),
|
||||
queryFn: async () => await getPlayerBadges({ data: playerId })
|
||||
}),
|
||||
allBadges: () => ({
|
||||
queryKey: badgeKeys.allBadges(),
|
||||
queryFn: async () => await getAllBadges()
|
||||
}),
|
||||
};
|
||||
|
||||
export const usePlayerBadges = (playerId: string) =>
|
||||
useServerSuspenseQuery(badgeQueries.playerBadges(playerId));
|
||||
|
||||
export const useAllBadges = () =>
|
||||
useServerSuspenseQuery(badgeQueries.allBadges());
|
||||
@@ -1,4 +1,24 @@
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import { superTokensAdminFunctionMiddleware } 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";
|
||||
|
||||
export const getPlayerBadges = createServerFn()
|
||||
.inputValidator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: playerId }) =>
|
||||
toServerResult(() => pbAdmin.getPlayerBadgeProgress(playerId))
|
||||
);
|
||||
|
||||
export const migrateBadgeProgress = createServerFn()
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async () =>
|
||||
toServerResult(() => pbAdmin.migrateBadgeProgress())
|
||||
);
|
||||
|
||||
export const getAllBadges = createServerFn()
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async () =>
|
||||
toServerResult(() => pbAdmin.listBadges())
|
||||
);
|
||||
|
||||
25
src/features/badges/types.ts
Normal file
25
src/features/badges/types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface BadgeInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
description: string;
|
||||
type: "manual" | "match" | "tournament";
|
||||
progressive: boolean;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface Badge extends BadgeInfo {
|
||||
criteria: Record<string, any>;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface BadgeProgress {
|
||||
id: string;
|
||||
badge: BadgeInfo;
|
||||
player: string;
|
||||
progress: number;
|
||||
earned: boolean;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
Reference in New Issue
Block a user