diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts
index bd4aa4c..b8bfcc6 100644
--- a/src/app/routeTree.gen.ts
+++ b/src/app/routeTree.gen.ts
@@ -34,6 +34,7 @@ import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$t
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 AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
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'
@@ -167,6 +168,11 @@ const AuthedAdminBadgesRoute = AuthedAdminBadgesRouteImport.update({
path: '/badges',
getParentRoute: () => AuthedAdminRoute,
} as any)
+const AuthedAdminActivitiesRoute = AuthedAdminActivitiesRouteImport.update({
+ id: '/activities',
+ path: '/activities',
+ getParentRoute: () => AuthedAdminRoute,
+} as any)
const AuthedAdminTournamentsIndexRoute =
AuthedAdminTournamentsIndexRouteImport.update({
id: '/tournaments/',
@@ -212,6 +218,7 @@ export interface FileRoutesByFullPath {
'/settings': typeof AuthedSettingsRoute
'/stats': typeof AuthedStatsRoute
'/': typeof AuthedIndexRoute
+ '/admin/activities': typeof AuthedAdminActivitiesRoute
'/admin/badges': typeof AuthedAdminBadgesRoute
'/admin/preview': typeof AuthedAdminPreviewRoute
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
@@ -243,6 +250,7 @@ export interface FileRoutesByTo {
'/settings': typeof AuthedSettingsRoute
'/stats': typeof AuthedStatsRoute
'/': typeof AuthedIndexRoute
+ '/admin/activities': typeof AuthedAdminActivitiesRoute
'/admin/badges': typeof AuthedAdminBadgesRoute
'/admin/preview': typeof AuthedAdminPreviewRoute
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
@@ -277,6 +285,7 @@ export interface FileRoutesById {
'/_authed/settings': typeof AuthedSettingsRoute
'/_authed/stats': typeof AuthedStatsRoute
'/_authed/': typeof AuthedIndexRoute
+ '/_authed/admin/activities': typeof AuthedAdminActivitiesRoute
'/_authed/admin/badges': typeof AuthedAdminBadgesRoute
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
@@ -311,6 +320,7 @@ export interface FileRouteTypes {
| '/settings'
| '/stats'
| '/'
+ | '/admin/activities'
| '/admin/badges'
| '/admin/preview'
| '/profile/$playerId'
@@ -342,6 +352,7 @@ export interface FileRouteTypes {
| '/settings'
| '/stats'
| '/'
+ | '/admin/activities'
| '/admin/badges'
| '/admin/preview'
| '/profile/$playerId'
@@ -375,6 +386,7 @@ export interface FileRouteTypes {
| '/_authed/settings'
| '/_authed/stats'
| '/_authed/'
+ | '/_authed/admin/activities'
| '/_authed/admin/badges'
| '/_authed/admin/preview'
| '/_authed/profile/$playerId'
@@ -595,6 +607,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminBadgesRouteImport
parentRoute: typeof AuthedAdminRoute
}
+ '/_authed/admin/activities': {
+ id: '/_authed/admin/activities'
+ path: '/activities'
+ fullPath: '/admin/activities'
+ preLoaderRoute: typeof AuthedAdminActivitiesRouteImport
+ parentRoute: typeof AuthedAdminRoute
+ }
'/_authed/admin/tournaments/': {
id: '/_authed/admin/tournaments/'
path: '/tournaments'
@@ -641,6 +660,7 @@ declare module '@tanstack/react-router' {
}
interface AuthedAdminRouteChildren {
+ AuthedAdminActivitiesRoute: typeof AuthedAdminActivitiesRoute
AuthedAdminBadgesRoute: typeof AuthedAdminBadgesRoute
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
@@ -651,6 +671,7 @@ interface AuthedAdminRouteChildren {
}
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
+ AuthedAdminActivitiesRoute: AuthedAdminActivitiesRoute,
AuthedAdminBadgesRoute: AuthedAdminBadgesRoute,
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
diff --git a/src/app/routes/_authed/admin/activities.tsx b/src/app/routes/_authed/admin/activities.tsx
new file mode 100644
index 0000000..e9141d1
--- /dev/null
+++ b/src/app/routes/_authed/admin/activities.tsx
@@ -0,0 +1,24 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
+import { ActivitiesTable, activityQueries } from "@/features/activities";
+
+export const Route = createFileRoute("/_authed/admin/activities")({
+ component: Stats,
+ beforeLoad: ({ context }) => {
+ const queryClient = context.queryClient;
+ prefetchServerQuery(queryClient, activityQueries.search());
+ },
+ loader: () => ({
+ withPadding: false,
+ fullWidth: true,
+ header: {
+ title: "Activities",
+ withBackButton: true,
+ },
+ refresh: [activityQueries.search().queryKey],
+ }),
+});
+
+function Stats() {
+ return ;
+}
diff --git a/src/features/activities/components/activities-table.tsx b/src/features/activities/components/activities-table.tsx
new file mode 100644
index 0000000..8cb8529
--- /dev/null
+++ b/src/features/activities/components/activities-table.tsx
@@ -0,0 +1,222 @@
+import { useState, useMemo, memo } from "react";
+import {
+ Text,
+ TextInput,
+ Stack,
+ Group,
+ Box,
+ Container,
+ Divider,
+ UnstyledButton,
+ Badge,
+ Select,
+ Pagination,
+} from "@mantine/core";
+import {
+ MagnifyingGlassIcon,
+ CaretUpIcon,
+ CaretDownIcon,
+ CheckIcon,
+ XIcon,
+} from "@phosphor-icons/react";
+import { Activity, ActivitySearchParams } from "../types";
+import { useActivities } from "../queries";
+
+interface ActivityListItemProps {
+ activity: Activity;
+}
+
+const ActivityListItem = memo(({ activity }: ActivityListItemProps) => {
+ const playerName = typeof activity.player === "object" && activity.player
+ ? `${activity.player.first_name} ${activity.player.last_name}`
+ : "System";
+
+ const formatDate = (dateStr: string) => {
+ const date = new Date(dateStr);
+ return date.toLocaleString();
+ };
+
+ return (
+
+
+
+
+
+ {activity.name}
+
+ {activity.success ? (
+
+ ) : (
+
+ )}
+
+
+
+ {playerName}
+
+
+ {activity.duration}ms
+
+
+ {formatDate(activity.created)}
+
+
+ {activity.error && (
+
+ {activity.error}
+
+ )}
+
+
+
+ );
+});
+
+const ActivitiesResults = ({ searchParams, page, setPage }: any) => {
+ const { data: result } = useActivities(searchParams);
+ return (
+ <>
+
+ {result.items.map((activity: Activity, index: number) => (
+
+
+ {index < result.items.length - 1 && }
+
+ ))}
+
+
+ {result.items.length === 0 && (
+
+ No activities found
+
+ )}
+
+ {result.totalPages > 1 && (
+
+
+
+ )}
+ >
+ )
+};
+
+export const ActivitiesTable = () => {
+ const [page, setPage] = useState(1);
+ const [perPage, setPerPage] = useState(100);
+ const [search, setSearch] = useState("");
+ const [successFilter, setSuccessFilter] = useState(null);
+ const [sortBy, setSortBy] = useState("-created");
+
+ const searchParams: ActivitySearchParams = useMemo(
+ () => ({
+ page,
+ perPage,
+ name: search || undefined,
+ success: successFilter === "success" ? true : successFilter === "failure" ? false : undefined,
+ sortBy,
+ }),
+ [page, perPage, search, successFilter, sortBy]
+ );
+
+ const { data: result } = useActivities(searchParams);
+
+ const handleSort = (field: string) => {
+ setSortBy((prev) => {
+ if (prev === field) return `-${field}`;
+ if (prev === `-${field}`) return field;
+ return `-${field}`;
+ });
+ };
+
+ const getSortIcon = (field: string) => {
+ if (sortBy === field) return ;
+ if (sortBy === `-${field}`) return ;
+ return null;
+ };
+
+ return (
+
+
+
+ {
+ setSearch(e.currentTarget.value);
+ setPage(1);
+ }}
+ leftSection={}
+ size="md"
+ />
+
+
+
+
+
+
+
+ {result.totalItems} total activities
+
+
+
+ Sort:
+
+ handleSort("created")}
+ style={{ display: "flex", alignItems: "center", gap: 4 }}
+ >
+
+ Date
+
+ {getSortIcon("created")}
+
+
+ •
+
+ handleSort("duration")}
+ style={{ display: "flex", alignItems: "center", gap: 4 }}
+ >
+
+ Duration
+
+ {getSortIcon("duration")}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/features/activities/index.ts b/src/features/activities/index.ts
new file mode 100644
index 0000000..cdad543
--- /dev/null
+++ b/src/features/activities/index.ts
@@ -0,0 +1,3 @@
+export * from "./types";
+export * from "./queries";
+export { ActivitiesTable } from "./components/activities-table";
diff --git a/src/features/activities/queries.ts b/src/features/activities/queries.ts
new file mode 100644
index 0000000..b78dd66
--- /dev/null
+++ b/src/features/activities/queries.ts
@@ -0,0 +1,17 @@
+import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
+import { searchActivities } from "./server";
+import { ActivitySearchParams } from "./types";
+
+export const activityKeys = {
+ search: (params: ActivitySearchParams) => ['activities', 'search', params] as const,
+};
+
+export const activityQueries = {
+ search: (params: ActivitySearchParams = {}) => ({
+ queryKey: activityKeys.search(params),
+ queryFn: () => searchActivities({ data: params }),
+ }),
+};
+
+export const useActivities = (params: ActivitySearchParams = {}) =>
+ useServerSuspenseQuery(activityQueries.search(params));
diff --git a/src/features/activities/server.ts b/src/features/activities/server.ts
new file mode 100644
index 0000000..1a50338
--- /dev/null
+++ b/src/features/activities/server.ts
@@ -0,0 +1,29 @@
+import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
+import { createServerFn } from "@tanstack/react-start";
+import { pbAdmin } from "@/lib/pocketbase/client";
+import { z } from "zod";
+import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
+import { transformActivity } from "@/lib/pocketbase/util/transform-types";
+import { Activity, ActivityListResult, ActivitySearchParams } from "./types";
+
+const activitySearchParamsSchema = z.object({
+ page: z.number().optional(),
+ perPage: z.number().optional(),
+ name: z.string().optional(),
+ player: z.string().optional(),
+ success: z.boolean().optional(),
+ sortBy: z.string().optional(),
+});
+
+export const searchActivities = createServerFn()
+ .inputValidator(activitySearchParamsSchema)
+ .middleware([superTokensAdminFunctionMiddleware])
+ .handler(async ({ data }) =>
+ toServerResult(async () => {
+ const result = await pbAdmin.searchActivities(data);
+ return {
+ ...result,
+ items: result.items.map(transformActivity),
+ };
+ })
+ );
diff --git a/src/features/activities/types.ts b/src/features/activities/types.ts
new file mode 100644
index 0000000..1ef15a3
--- /dev/null
+++ b/src/features/activities/types.ts
@@ -0,0 +1 @@
+export type { Activity, ActivityListResult, ActivitySearchParams } from "@/lib/pocketbase/services/activities";
diff --git a/src/features/admin/components/admin-page.tsx b/src/features/admin/components/admin-page.tsx
index 61a9f43..83e4b1a 100644
--- a/src/features/admin/components/admin-page.tsx
+++ b/src/features/admin/components/admin-page.tsx
@@ -6,6 +6,7 @@ import {
TrophyIcon,
MedalIcon,
CrownIcon,
+ ListIcon,
} from "@phosphor-icons/react";
import ListButton from "@/components/list-button";
import { migrateBadgeProgress } from "@/features/badges/server";
@@ -40,6 +41,11 @@ const AdminPage = () => {
onClick={handleMigrateBadges}
loading={isMigrating}
/>
+
{
@@ -30,6 +48,47 @@ export function createActivitiesService(pb: PocketBase) {
return result;
},
+ async searchActivities(params: ActivitySearchParams = {}): Promise {
+ const {
+ page = 1,
+ perPage = 100,
+ name,
+ player,
+ success,
+ sortBy = "-created"
+ } = params;
+
+ const filters: string[] = [];
+
+ if (name) {
+ filters.push(`name ~ "${name}"`);
+ }
+
+ if (player) {
+ filters.push(`player = "${player}"`);
+ }
+
+ if (success !== undefined) {
+ filters.push(`success = ${success}`);
+ }
+
+ const filterString = filters.length > 0 ? filters.join(" && ") : "";
+
+ const result = await pb.collection("activities").getList(page, perPage, {
+ filter: filterString,
+ sort: sortBy,
+ expand: "player",
+ });
+
+ return {
+ items: result.items,
+ page: result.page,
+ perPage: result.perPage,
+ totalPages: result.totalPages,
+ totalItems: result.totalItems,
+ };
+ },
+
async getRecentActivities(limit: number = 100): Promise {
const result = await pb.collection("activities").getList(1, limit, {
sort: "-created",
@@ -39,7 +98,7 @@ export function createActivitiesService(pb: PocketBase) {
async getActivitiesByUser(userId: string, limit: number = 50): Promise {
const result = await pb.collection("activities").getList(1, limit, {
- filter: `user_id = "${userId}"`,
+ filter: `player = "${userId}"`,
sort: "-created",
});
return result.items;
@@ -47,7 +106,7 @@ export function createActivitiesService(pb: PocketBase) {
async getActivitiesByFunction(functionName: string, limit: number = 50): Promise {
const result = await pb.collection("activities").getList(1, limit, {
- filter: `function_name = "${functionName}"`,
+ filter: `name = "${functionName}"`,
sort: "-created",
});
return result.items;
diff --git a/src/lib/pocketbase/util/transform-types.ts b/src/lib/pocketbase/util/transform-types.ts
index 93ac5e9..4dc615c 100644
--- a/src/lib/pocketbase/util/transform-types.ts
+++ b/src/lib/pocketbase/util/transform-types.ts
@@ -4,6 +4,7 @@ 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 { Activity } from "../services/activities";
// pocketbase does this weird thing with relations where it puts them under a seperate "expand" field
// this file transforms raw pocketbase results to our types
@@ -312,3 +313,18 @@ export function transformBadgeProgress(record: any): BadgeProgress {
updated: record.updated,
};
}
+
+export function transformActivity(record: any): Activity {
+ return {
+ id: record.id,
+ name: record.name,
+ player: record.expand?.player ? transformPlayerInfo(record.expand.player) : record.player,
+ duration: record.duration,
+ success: record.success,
+ error: record.error,
+ arguments: record.arguments,
+ user_agent: record.user_agent,
+ created: record.created,
+ updated: record.updated,
+ };
+}