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;