diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index a005760..bd4aa4c 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -33,6 +33,7 @@ import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_aut import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId' import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId' import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview' +import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges' import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index' import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket' import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index' @@ -161,6 +162,11 @@ const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({ path: '/preview', getParentRoute: () => AuthedAdminRoute, } as any) +const AuthedAdminBadgesRoute = AuthedAdminBadgesRouteImport.update({ + id: '/badges', + path: '/badges', + getParentRoute: () => AuthedAdminRoute, +} as any) const AuthedAdminTournamentsIndexRoute = AuthedAdminTournamentsIndexRouteImport.update({ id: '/tournaments/', @@ -206,6 +212,7 @@ export interface FileRoutesByFullPath { '/settings': typeof AuthedSettingsRoute '/stats': typeof AuthedStatsRoute '/': typeof AuthedIndexRoute + '/admin/badges': typeof AuthedAdminBadgesRoute '/admin/preview': typeof AuthedAdminPreviewRoute '/profile/$playerId': typeof AuthedProfilePlayerIdRoute '/teams/$teamId': typeof AuthedTeamsTeamIdRoute @@ -236,6 +243,7 @@ export interface FileRoutesByTo { '/settings': typeof AuthedSettingsRoute '/stats': typeof AuthedStatsRoute '/': typeof AuthedIndexRoute + '/admin/badges': typeof AuthedAdminBadgesRoute '/admin/preview': typeof AuthedAdminPreviewRoute '/profile/$playerId': typeof AuthedProfilePlayerIdRoute '/teams/$teamId': typeof AuthedTeamsTeamIdRoute @@ -269,6 +277,7 @@ export interface FileRoutesById { '/_authed/settings': typeof AuthedSettingsRoute '/_authed/stats': typeof AuthedStatsRoute '/_authed/': typeof AuthedIndexRoute + '/_authed/admin/badges': typeof AuthedAdminBadgesRoute '/_authed/admin/preview': typeof AuthedAdminPreviewRoute '/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute '/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute @@ -302,6 +311,7 @@ export interface FileRouteTypes { | '/settings' | '/stats' | '/' + | '/admin/badges' | '/admin/preview' | '/profile/$playerId' | '/teams/$teamId' @@ -332,6 +342,7 @@ export interface FileRouteTypes { | '/settings' | '/stats' | '/' + | '/admin/badges' | '/admin/preview' | '/profile/$playerId' | '/teams/$teamId' @@ -364,6 +375,7 @@ export interface FileRouteTypes { | '/_authed/settings' | '/_authed/stats' | '/_authed/' + | '/_authed/admin/badges' | '/_authed/admin/preview' | '/_authed/profile/$playerId' | '/_authed/teams/$teamId' @@ -576,6 +588,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminPreviewRouteImport parentRoute: typeof AuthedAdminRoute } + '/_authed/admin/badges': { + id: '/_authed/admin/badges' + path: '/badges' + fullPath: '/admin/badges' + preLoaderRoute: typeof AuthedAdminBadgesRouteImport + parentRoute: typeof AuthedAdminRoute + } '/_authed/admin/tournaments/': { id: '/_authed/admin/tournaments/' path: '/tournaments' @@ -622,6 +641,7 @@ declare module '@tanstack/react-router' { } interface AuthedAdminRouteChildren { + AuthedAdminBadgesRoute: typeof AuthedAdminBadgesRoute AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute @@ -631,6 +651,7 @@ interface AuthedAdminRouteChildren { } const AuthedAdminRouteChildren: AuthedAdminRouteChildren = { + AuthedAdminBadgesRoute: AuthedAdminBadgesRoute, AuthedAdminPreviewRoute: AuthedAdminPreviewRoute, AuthedAdminIndexRoute: AuthedAdminIndexRoute, AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute, diff --git a/src/app/routes/_authed/admin/badges.tsx b/src/app/routes/_authed/admin/badges.tsx new file mode 100644 index 0000000..ae61302 --- /dev/null +++ b/src/app/routes/_authed/admin/badges.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from "@tanstack/react-router"; +import AwardBadges from "@/features/admin/components/award-badges"; + +export const Route = createFileRoute("/_authed/admin/badges")({ + component: RouteComponent, +}); + +function RouteComponent() { + return ; +} diff --git a/src/features/admin/components/admin-page.tsx b/src/features/admin/components/admin-page.tsx index d8ad538..61a9f43 100644 --- a/src/features/admin/components/admin-page.tsx +++ b/src/features/admin/components/admin-page.tsx @@ -5,6 +5,7 @@ import { TreeStructureIcon, TrophyIcon, MedalIcon, + CrownIcon, } from "@phosphor-icons/react"; import ListButton from "@/components/list-button"; import { migrateBadgeProgress } from "@/features/badges/server"; @@ -28,6 +29,11 @@ const AdminPage = () => { Icon={TrophyIcon} to="/admin/tournaments" /> + { + const { data: players } = usePlayers(); + const { data: allBadges } = useAllBadges(); + + const [selectedPlayerId, setSelectedPlayerId] = useState(null); + const [selectedBadgeId, setSelectedBadgeId] = useState(null); + const [isAwarding, setIsAwarding] = useState(false); + + const manualBadges = allBadges.filter((badge) => badge.type === "manual"); + + const handleAwardBadge = async () => { + if (!selectedPlayerId || !selectedBadgeId) return; + + setIsAwarding(true); + try { + await awardManualBadge({ + data: { + playerId: selectedPlayerId, + badgeId: selectedBadgeId, + }, + }); + + toast.success("Badge awarded successfully"); + + setSelectedPlayerId(null); + setSelectedBadgeId(null); + } catch (error) { + toast.error("Failed to award badge"); + } finally { + setIsAwarding(false); + } + }; + + const playerOptions = players.map((player) => ({ + value: player.id, + label: `${player.first_name} ${player.last_name}`, + })); + + const badgeOptions = manualBadges.map((badge) => ({ + value: badge.id, + label: badge.name, + })); + + return ( + + + + + + Award Manual Badge + + + Select a player and a manual badge to award + + + + + + + + + + + + ); +}; + +export default AwardBadges; diff --git a/src/features/badges/components/badge-showcase.tsx b/src/features/badges/components/badge-showcase.tsx index 3a9bde9..4f8e662 100644 --- a/src/features/badges/components/badge-showcase.tsx +++ b/src/features/badges/components/badge-showcase.tsx @@ -86,7 +86,7 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => { } return ( - + toServerResult(() => pbAdmin.listBadges()) ); + +export const awardManualBadge = createServerFn() + .inputValidator(z.object({ + playerId: z.string(), + badgeId: z.string(), + })) + .middleware([superTokensAdminFunctionMiddleware]) + .handler(async ({ data }) => + toServerResult(() => pbAdmin.awardManualBadge(data.playerId, data.badgeId)) + ); diff --git a/src/lib/pocketbase/services/badges.ts b/src/lib/pocketbase/services/badges.ts index 9edd3b7..3ae8546 100644 --- a/src/lib/pocketbase/services/badges.ts +++ b/src/lib/pocketbase/services/badges.ts @@ -351,6 +351,32 @@ export function createBadgesService(pb: PocketBase) { ); }, + async awardManualBadge(playerId: string, badgeId: string): Promise { + // Get or create badge progress record + const existingProgress = await pb.collection("badge_progress").getFirstListItem( + `player = "${playerId}" && badge = "${badgeId}"`, + { expand: 'badge' } + ).catch(() => null); + + if (existingProgress) { + // Update existing progress to mark as earned + const updated = await pb.collection("badge_progress").update(existingProgress.id, { + progress: 1, + earned: true, + }, { expand: 'badge' }); + return transformBadgeProgress(updated); + } + + // Create new progress record + const created = await pb.collection("badge_progress").create({ + badge: badgeId, + player: playerId, + progress: 1, + earned: true, + }, { expand: 'badge' }); + return transformBadgeProgress(created); + }, + async migrateBadgeProgress(): Promise<{ success: boolean; playersProcessed: number;