diff --git a/package-lock.json b/package-lock.json index 73b7b38c..a34135eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { "name": "frontend", - "version": "0.32.0", + "version": "0.33.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "version": "0.32.0", + "version": "0.33.0", "license": "MIT", "dependencies": { "@dinero.js/currencies": "^2.0.0-alpha.11", @@ -4186,7 +4186,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.3.4", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", "dev": true, "license": "MIT", "dependencies": { @@ -7783,7 +7785,9 @@ "dev": true }, "node_modules/picocolors": { - "version": "1.0.0", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", "dev": true, "license": "ISC" }, @@ -7846,9 +7850,9 @@ } }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", + "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", "dev": true, "funding": [ { @@ -7864,9 +7868,10 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", + "picocolors": "^1.0.1", "source-map-js": "^1.2.0" }, "engines": { @@ -8861,14 +8866,16 @@ "license": "MIT" }, "node_modules/start-server-and-test": { - "version": "2.0.3", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.0.4.tgz", + "integrity": "sha512-CKNeBTcP0hVqIlNismHMudb9q3lLdAjcVPO13/7gfI66fcJpeIb/o4NzQd1JK/CD+lfWVqr10ZH9Y14+OwlJuw==", "dev": true, "license": "MIT", "dependencies": { "arg": "^5.0.2", "bluebird": "3.7.2", "check-more-types": "2.24.0", - "debug": "4.3.4", + "debug": "4.3.5", "execa": "5.1.1", "lazy-ass": "1.6.0", "ps-tree": "1.2.0", @@ -8885,6 +8892,8 @@ }, "node_modules/start-server-and-test/node_modules/execa": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", "dev": true, "license": "MIT", "dependencies": { @@ -8907,6 +8916,8 @@ }, "node_modules/start-server-and-test/node_modules/get-stream": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, "license": "MIT", "engines": { @@ -8918,6 +8929,8 @@ }, "node_modules/start-server-and-test/node_modules/human-signals": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true, "license": "Apache-2.0", "engines": { diff --git a/package.json b/package.json index e2e9cffb..79402bec 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "lint-staged": { "*.{ts,js,tsx,jsx}": "eslint --fix" }, - "version": "0.32.0", + "version": "0.33.0", "engines": { "node": "20.x" }, diff --git a/src/.DS_Store b/src/.DS_Store index 26e46162..3340cb99 100644 Binary files a/src/.DS_Store and b/src/.DS_Store differ diff --git a/src/api/useEvents.ts b/src/api/useEvents.ts index bedec152..78223111 100644 --- a/src/api/useEvents.ts +++ b/src/api/useEvents.ts @@ -4,6 +4,13 @@ import { Game } from '../entities/game' import makeValidatedGetRequest from './makeValidatedGetRequest' import { z } from 'zod' +export const eventsVisualisationPayloadSchema = z.object({ + name: z.string(), + date: z.number(), + count: z.number(), + change: z.number() +}) + export default function useEvents(activeGame: Game, startDate: string, endDate: string) { const fetcher = async ([url]: [string]) => { const qs = new URLSearchParams({ @@ -13,11 +20,7 @@ export default function useEvents(activeGame: Game, startDate: string, endDate: const res = await makeValidatedGetRequest(`${url}?${qs}`, z.object({ events: z.record( - z.object({ - name: z.string(), - date: z.number(), - count: z.number() - }) + z.array(eventsVisualisationPayloadSchema) ), eventNames: z.array(z.string()) })) diff --git a/src/api/usePlayerAuthActivities.ts b/src/api/usePlayerAuthActivities.ts new file mode 100644 index 00000000..ec4ab857 --- /dev/null +++ b/src/api/usePlayerAuthActivities.ts @@ -0,0 +1,27 @@ +import useSWR from 'swr' +import buildError from '../utils/buildError' +import { Game } from '../entities/game' +import makeValidatedGetRequest from './makeValidatedGetRequest' +import { z } from 'zod' +import { playerAuthActivitySchema } from '../entities/playerAuthActivity' + +export default function usePlayerAuthActivities(activeGame: Game, playerId: string) { + const fetcher = async ([url]: [string]) => { + const res = await makeValidatedGetRequest(url, z.object({ + activities: z.array(playerAuthActivitySchema) + })) + + return res + } + + const { data, error } = useSWR( + [`/games/${activeGame.id}/players/${playerId}/auth-activities`], + fetcher + ) + + return { + activities: data?.activities ?? [], + loading: !data && !error, + error: error && buildError(error) + } +} diff --git a/src/assets/talo-service.svg b/src/assets/talo-service.svg new file mode 100644 index 00000000..eea1800a --- /dev/null +++ b/src/assets/talo-service.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/components/ActivityRenderer.tsx b/src/components/ActivityRenderer.tsx new file mode 100644 index 00000000..26aaa83f --- /dev/null +++ b/src/components/ActivityRenderer.tsx @@ -0,0 +1,39 @@ +import SecondaryTitle from './SecondaryTitle' +import { format } from 'date-fns' + +type ActivityRendererProps = { + section: { + date: Date + items: { + createdAt: string + description: string + extra: Record + }[] + } +} + +export default function ActivityRenderer({ section }: ActivityRendererProps) { + return ( +
+ {format(section.date, 'dd MMM Y')} + + {section.items.map((item, itemIdx) => ( +
+

{format(new Date(item.createdAt), 'HH:mm')} {item.description}

+ + {item.extra && +
+ {Object.keys(item.extra).sort((a, b) => { + if (b === 'Player') return 1 + + return a.localeCompare(b) + }).map((key) => ( + {key} = {String(item.extra[key])} + ))} +
+ } +
+ ))} +
+ ) +} diff --git a/src/components/PlayerAliases.tsx b/src/components/PlayerAliases.tsx index 47ec41da..43f16fc2 100644 --- a/src/components/PlayerAliases.tsx +++ b/src/components/PlayerAliases.tsx @@ -2,6 +2,7 @@ import { IconBrandSteam, IconMail, IconQuestionMark, IconUser } from '@tabler/ic import Tippy from '@tippyjs/react' import useSortedItems from '../utils/useSortedItems' import { PlayerAlias } from '../entities/playerAlias' +import taloIcon from '../assets/talo-service.svg' type PlayerAliasesProps = { aliases: PlayerAlias[] @@ -18,6 +19,7 @@ export default function PlayerAliases({ case 'steam': return case 'username': return case 'email': return + case 'talo': return Talo default: return } } diff --git a/src/components/charts/ChartTooltip.tsx b/src/components/charts/ChartTooltip.tsx index b8eb3dbe..71a84cdb 100644 --- a/src/components/charts/ChartTooltip.tsx +++ b/src/components/charts/ChartTooltip.tsx @@ -2,12 +2,10 @@ import { format } from 'date-fns' import { uniqBy } from 'lodash-es' import clsx from 'clsx' import getEventColour from '../../utils/getEventColour' +import { z } from 'zod' +import { eventsVisualisationPayloadSchema } from '../../api/useEvents' -type Payload = { - count: number - name: string - change: number -} +type Payload = z.infer type ChartTooltipProps = { active?: boolean diff --git a/src/entities/apiKey.ts b/src/entities/apiKey.ts index 5d0986e9..819fc5b8 100644 --- a/src/entities/apiKey.ts +++ b/src/entities/apiKey.ts @@ -6,7 +6,7 @@ export const apiKeySchema = z.object({ gameId: z.number(), createdBy: z.string(), createdAt: z.string().datetime(), - lastUsedAt: z.string().datetime().optional() + lastUsedAt: z.string().datetime().nullable() }) export type APIKey = z.infer diff --git a/src/entities/gameActivity.ts b/src/entities/gameActivity.ts index 1a3e04ad..878c9f8e 100644 --- a/src/entities/gameActivity.ts +++ b/src/entities/gameActivity.ts @@ -1,14 +1,10 @@ import { z } from 'zod' -const extraSchema = z.object({ - display: z.record(z.unknown()).optional() -}).catchall(z.unknown()) - export const gameActivitySchema = z.object({ id: z.number(), - type: z.string(), + type: z.number(), description: z.string(), - extra: extraSchema, + extra: z.record(z.unknown()), createdAt: z.string().datetime() }) diff --git a/src/entities/playerAlias.ts b/src/entities/playerAlias.ts index f51abb70..760f2de6 100644 --- a/src/entities/playerAlias.ts +++ b/src/entities/playerAlias.ts @@ -6,7 +6,8 @@ export enum PlayerAliasService { EPIC = 'epic', USERNAME = 'username', EMAIL = 'email', - CUSTOM = 'custom' + CUSTOM = 'custom', + TALO = 'talo' } export const playerAliasSchema = z.object({ diff --git a/src/entities/playerAuthActivity.ts b/src/entities/playerAuthActivity.ts new file mode 100644 index 00000000..3511b910 --- /dev/null +++ b/src/entities/playerAuthActivity.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' + +export const playerAuthActivitySchema = z.object({ + id: z.number(), + type: z.number(), + description: z.string(), + extra: z.record(z.unknown()), + createdAt: z.string().datetime() +}) + +export type PlayerAuthActivity = z.infer diff --git a/src/pages/Activity.tsx b/src/pages/Activity.tsx index 7de66c1b..e53bd07b 100644 --- a/src/pages/Activity.tsx +++ b/src/pages/Activity.tsx @@ -1,39 +1,17 @@ -import { useMemo } from 'react' import useGameActivities from '../api/useGameActivities' import ErrorMessage from '../components/ErrorMessage' import activeGameState, { SelectedActiveGame } from '../state/activeGameState' import { useRecoilValue } from 'recoil' -import useSortedItems from '../utils/useSortedItems' -import { differenceInDays, subDays, startOfDay, isSameDay, format } from 'date-fns' import SecondaryNav from '../components/SecondaryNav' import { secondaryNavRoutes } from '../pages/Dashboard' import Page from '../components/Page' -import SecondaryTitle from '../components/SecondaryTitle' +import useDaySections from '../utils/useDaySections' +import ActivityRenderer from '../components/ActivityRenderer' -function Activity() { +export default function Activity() { const activeGame = useRecoilValue(activeGameState) as SelectedActiveGame const { activities, loading, error } = useGameActivities(activeGame) - - const sortedActivities = useSortedItems(activities, 'createdAt') - - const sections = useMemo(() => { - if (sortedActivities.length === 0) return [] - const latestDate = sortedActivities[0].createdAt - const oldestDate = sortedActivities[sortedActivities.length - 1].createdAt - - const numSections = differenceInDays(new Date(latestDate), new Date(oldestDate)) + 1 - - const sections = [] - for (let i = 0; i < numSections; i++) { - const date = startOfDay(subDays(new Date(latestDate), i)) - sections.push({ - date, - items: sortedActivities.filter((activity) => isSameDay(date, new Date(activity.createdAt))) - }) - } - - return sections.filter((section) => section.items.length > 0) - }, [sortedActivities]) + const sections = useDaySections(activities) return ( ( -
- {format(section.date, 'dd MMM Y')} - - {section.items.map((item, itemIdx) => ( -
-

{format(new Date(item.createdAt), 'HH:mm')} {item.description}

- - {item.extra && -
- {Object.keys(item.extra).sort((a, b) => { - if (b === 'Player') return 1 - - return a.localeCompare(b) - }).map((key) => ( - {key} = {item.extra[key] as string | number} - ))} -
- } -
- ))} -
+ ))} {error && }
) } - -export default Activity diff --git a/src/pages/Events.tsx b/src/pages/Events.tsx index de18250b..a98f8c0d 100644 --- a/src/pages/Events.tsx +++ b/src/pages/Events.tsx @@ -1,7 +1,5 @@ import EventsOverview from '../components/events/EventsOverview' -const Events = () => { +export default function Events() { return } - -export default Events diff --git a/src/pages/PlayerProfile.tsx b/src/pages/PlayerProfile.tsx index efd1b52f..88ba483a 100644 --- a/src/pages/PlayerProfile.tsx +++ b/src/pages/PlayerProfile.tsx @@ -15,6 +15,11 @@ import Identifier from '../components/Identifier' import { IconBolt, IconChartBar, IconDeviceFloppy, IconSettings, IconTrophy } from '@tabler/icons-react' import Button from '../components/Button' import Loading from '../components/Loading' +import usePlayerAuthActivities from '../api/usePlayerAuthActivities' +import { useRecoilValue } from 'recoil' +import activeGameState, { SelectedActiveGame } from '../state/activeGameState' +import useDaySections from '../utils/useDaySections' +import ActivityRenderer from '../components/ActivityRenderer' const links = [ { @@ -50,6 +55,11 @@ export default function PlayerProfile() { const sortedAliases = useSortedItems(player?.aliases ?? [], 'updatedAt') + const activeGame = useRecoilValue(activeGameState) as SelectedActiveGame + const { activities } = usePlayerAuthActivities(activeGame, player.id) + + const sections = useDaySections(activities) + const goToPlayerRoute = (route: string) => { navigate(route.replace(':id', player.id), { state: { player } @@ -110,6 +120,16 @@ export default function PlayerProfile() { )} + + {activities.length > 0 && + <> + Authentication activities + + {sections.map((section, sectionIdx) => ( + + ))} + + } ) } diff --git a/src/utils/useDaySections.ts b/src/utils/useDaySections.ts new file mode 100644 index 00000000..f78cfd93 --- /dev/null +++ b/src/utils/useDaySections.ts @@ -0,0 +1,28 @@ +import { useMemo } from 'react' +import useSortedItems from './useSortedItems' +import { differenceInDays, isSameDay, startOfDay, subDays } from 'date-fns' + +export default function useDaySections(items: T[]) { + const sortedItems = useSortedItems(items, 'createdAt') + + const sections = useMemo(() => { + if (sortedItems.length === 0) return [] + const latestDate = sortedItems[0].createdAt + const oldestDate = sortedItems[sortedItems.length - 1].createdAt + + const numSections = differenceInDays(new Date(latestDate), new Date(oldestDate)) + 1 + + const sections = [] + for (let i = 0; i < numSections; i++) { + const date = startOfDay(subDays(new Date(latestDate), i)) + sections.push({ + date, + items: sortedItems.filter((item) => isSameDay(date, new Date(item.createdAt))) + }) + } + + return sections.filter((section) => section.items.length > 0) + }, [sortedItems]) + + return sections +}