activities
This commit is contained in:
@@ -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,
|
||||
|
||||
24
src/app/routes/_authed/admin/activities.tsx
Normal file
24
src/app/routes/_authed/admin/activities.tsx
Normal file
@@ -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 <ActivitiesTable />;
|
||||
}
|
||||
222
src/features/activities/components/activities-table.tsx
Normal file
222
src/features/activities/components/activities-table.tsx
Normal file
@@ -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 (
|
||||
<Box p="md">
|
||||
<Group justify="space-between" align="flex-start" w="100%">
|
||||
<Stack gap={4} flex={1}>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{activity.name}
|
||||
</Text>
|
||||
{activity.success ? (
|
||||
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
||||
) : (
|
||||
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
{playerName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{activity.duration}ms
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDate(activity.created)}
|
||||
</Text>
|
||||
</Group>
|
||||
{activity.error && (
|
||||
<Text size="xs" c="red" lineClamp={1}>
|
||||
{activity.error}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
const ActivitiesResults = ({ searchParams, page, setPage }: any) => {
|
||||
const { data: result } = useActivities(searchParams);
|
||||
return (
|
||||
<>
|
||||
<Stack gap={0}>
|
||||
{result.items.map((activity: Activity, index: number) => (
|
||||
<Box key={activity.id}>
|
||||
<ActivityListItem activity={activity} />
|
||||
{index < result.items.length - 1 && <Divider />}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{result.items.length === 0 && (
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
No activities found
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{result.totalPages > 1 && (
|
||||
<Group justify="center" py="md">
|
||||
<Pagination
|
||||
total={result.totalPages}
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
};
|
||||
|
||||
export const ActivitiesTable = () => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(100);
|
||||
const [search, setSearch] = useState("");
|
||||
const [successFilter, setSuccessFilter] = useState<string | null>(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 <CaretUpIcon size={14} />;
|
||||
if (sortBy === `-${field}`) return <CaretDownIcon size={14} />;
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="100%" px={0}>
|
||||
<Stack gap="xs">
|
||||
<Stack gap="xs" px="md">
|
||||
<TextInput
|
||||
placeholder="serverFn name"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value);
|
||||
setPage(1);
|
||||
}}
|
||||
leftSection={<MagnifyingGlassIcon size={16} />}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<Group>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
value={successFilter}
|
||||
onChange={(value) => {
|
||||
setSuccessFilter(value);
|
||||
setPage(1);
|
||||
}}
|
||||
data={[
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "success", label: "Success" },
|
||||
{ value: "failure", label: "Failure" },
|
||||
]}
|
||||
clearable
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Group px="md" justify="space-between" align="center">
|
||||
<Text size="10px" lh={0} c="dimmed">
|
||||
{result.totalItems} total activities
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
Sort:
|
||||
</Text>
|
||||
<UnstyledButton
|
||||
onClick={() => handleSort("created")}
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={sortBy.includes("created") ? 600 : 400}
|
||||
c={sortBy.includes("created") ? "dark" : "dimmed"}
|
||||
>
|
||||
Date
|
||||
</Text>
|
||||
{getSortIcon("created")}
|
||||
</UnstyledButton>
|
||||
<Text size="xs" c="dimmed">
|
||||
•
|
||||
</Text>
|
||||
<UnstyledButton
|
||||
onClick={() => handleSort("duration")}
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={sortBy.includes("duration") ? 600 : 400}
|
||||
c={sortBy.includes("duration") ? "dark" : "dimmed"}
|
||||
>
|
||||
Duration
|
||||
</Text>
|
||||
{getSortIcon("duration")}
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<ActivitiesResults searchParams={searchParams} page={page} setPage={setPage} />
|
||||
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
3
src/features/activities/index.ts
Normal file
3
src/features/activities/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types";
|
||||
export * from "./queries";
|
||||
export { ActivitiesTable } from "./components/activities-table";
|
||||
17
src/features/activities/queries.ts
Normal file
17
src/features/activities/queries.ts
Normal file
@@ -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));
|
||||
29
src/features/activities/server.ts
Normal file
29
src/features/activities/server.ts
Normal file
@@ -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<ActivityListResult>(async () => {
|
||||
const result = await pbAdmin.searchActivities(data);
|
||||
return {
|
||||
...result,
|
||||
items: result.items.map(transformActivity),
|
||||
};
|
||||
})
|
||||
);
|
||||
1
src/features/activities/types.ts
Normal file
1
src/features/activities/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type { Activity, ActivityListResult, ActivitySearchParams } from "@/lib/pocketbase/services/activities";
|
||||
@@ -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}
|
||||
/>
|
||||
<ListLink
|
||||
label="Activities"
|
||||
Icon={ListIcon}
|
||||
to="/admin/activities"
|
||||
/>
|
||||
<ListButton
|
||||
label="Open Pocketbase"
|
||||
Icon={DatabaseIcon}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import PocketBase from "pocketbase";
|
||||
import { PlayerInfo } from "@/features/players/types";
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
name: string;
|
||||
player?: string;
|
||||
player?: string | PlayerInfo;
|
||||
duration: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@@ -23,6 +24,23 @@ export interface ActivityInput {
|
||||
user_agent?: string;
|
||||
}
|
||||
|
||||
export interface ActivityListResult {
|
||||
items: Activity[];
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
}
|
||||
|
||||
export interface ActivitySearchParams {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
name?: string;
|
||||
player?: string;
|
||||
success?: boolean;
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
export function createActivitiesService(pb: PocketBase) {
|
||||
return {
|
||||
async createActivity(data: ActivityInput): Promise<Activity> {
|
||||
@@ -30,6 +48,47 @@ export function createActivitiesService(pb: PocketBase) {
|
||||
return result;
|
||||
},
|
||||
|
||||
async searchActivities(params: ActivitySearchParams = {}): Promise<ActivityListResult> {
|
||||
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<Activity>(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<Activity[]> {
|
||||
const result = await pb.collection("activities").getList<Activity>(1, limit, {
|
||||
sort: "-created",
|
||||
@@ -39,7 +98,7 @@ export function createActivitiesService(pb: PocketBase) {
|
||||
|
||||
async getActivitiesByUser(userId: string, limit: number = 50): Promise<Activity[]> {
|
||||
const result = await pb.collection("activities").getList<Activity>(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<Activity[]> {
|
||||
const result = await pb.collection("activities").getList<Activity>(1, limit, {
|
||||
filter: `function_name = "${functionName}"`,
|
||||
filter: `name = "${functionName}"`,
|
||||
sort: "-created",
|
||||
});
|
||||
return result.items;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user