activities
This commit is contained in:
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}
|
||||
|
||||
Reference in New Issue
Block a user