diff --git a/README.md b/README.md index 36682286..4cd9f3b7 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,10 @@ Talo is available to use via our [Godot plugin](https://github.com/TaloDev/godot - đŸ•šī¸ [Leaderboards](https://trytalo.com/leaderboards): Highly customisable leaderboards that can sync with Steamworks. - 💾 [Game saves](https://trytalo.com/saves): A simple and flexible way to load/save game state; also works offline. - 📊 [Game stats](https://trytalo.com/stats): Track global or per-player stats across your game; also syncs with Steamworks. +- đŸ’Ŧ [Game channels](https://trytalo.com/channels): Send real-time messages between players subscribed to specific topics. - âš™ī¸ [Live config](https://trytalo.com/live-config): Update game settings from the web with zero downtime. - 🔧 [Steamworks integration](https://trytalo.com/steamworks-integration): Hook into Steamworks for authentication and ownership checks. -- đŸ’Ŧ [Game feedback](https://trytalo.com/feedback): Collect and manage feedback from your players. +- đŸ—Ŗī¸ [Game feedback](https://trytalo.com/feedback): Collect and manage feedback from your players. ## Documentation diff --git a/package-lock.json b/package-lock.json index 11209098..df86cc12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { "name": "frontend", - "version": "0.41.0", + "version": "0.42.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "version": "0.41.0", + "version": "0.42.0", "license": "MIT", "dependencies": { "@dinero.js/currencies": "^2.0.0-alpha.11", @@ -4445,9 +4445,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "dev": true, "license": "MIT", "dependencies": { @@ -9225,16 +9225,16 @@ "license": "MIT" }, "node_modules/start-server-and-test": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.0.8.tgz", - "integrity": "sha512-v2fV6NV2F7tL1ocwfI4Wpait+IKjRbT5l3ZZ+ZikXdMLmxYsS8ynGAsCQAUVXkVyGyS+UibsRnvgHkMvJIvCsw==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.0.9.tgz", + "integrity": "sha512-DDceIvc4wdpr+z3Aqkot2QMho8TcUBh5qH0wEHDpEexBTzlheOcmh53d3dExABY4J5C7qS2UbSXqRWLtxpbWIQ==", "dev": true, "license": "MIT", "dependencies": { "arg": "^5.0.2", "bluebird": "3.7.2", "check-more-types": "2.24.0", - "debug": "4.3.7", + "debug": "4.4.0", "execa": "5.1.1", "lazy-ass": "1.6.0", "ps-tree": "1.2.0", @@ -9648,9 +9648,9 @@ "license": "MIT" }, "node_modules/tailwindcss": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.15.tgz", - "integrity": "sha512-r4MeXnfBmSOuKUWmXe6h2CcyfzJCEk4F0pptO5jlnYSIViUkVmsawj80N5h2lO3gwcmSb4n3PuN+e+GC1Guylw==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "dev": true, "license": "MIT", "dependencies": { @@ -9663,7 +9663,7 @@ "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.6", - "lilconfig": "^2.1.0", + "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", @@ -9698,6 +9698,19 @@ "node": ">=10.13.0" } }, + "node_modules/tailwindcss/node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", diff --git a/package.json b/package.json index 45992c87..a7457f22 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "lint-staged": { "*.{ts,js,tsx,jsx}": "eslint --fix" }, - "version": "0.41.0", + "version": "0.42.0", "engines": { "node": "20.x" }, diff --git a/src/api/updateStatValue.ts b/src/api/updateStatValue.ts new file mode 100644 index 00000000..1b892bba --- /dev/null +++ b/src/api/updateStatValue.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' +import api from './api' +import makeValidatedRequest from './makeValidatedRequest' +import { playerGameStatSchema } from '../entities/playerGameStat' + +const updateStatValue = makeValidatedRequest( + (gameId: number, playerId: string, playerStatId: number, newValue: number) => { + return api.patch(`/games/${gameId}/players/${playerId}/stats/${playerStatId}`, { newValue }) + }, + z.object({ + playerStat: playerGameStatSchema + }) +) + +export default updateStatValue diff --git a/src/api/usePlayerStats.ts b/src/api/usePlayerStats.ts index 3fabf539..f2346e45 100644 --- a/src/api/usePlayerStats.ts +++ b/src/api/usePlayerStats.ts @@ -14,7 +14,7 @@ const usePlayerStats = (activeGame: Game, playerId: string) => { return res } - const { data, error } = useSWR( + const { data, error, mutate } = useSWR( [`/games/${activeGame.id}/players/${playerId}/stats`], fetcher ) @@ -23,7 +23,8 @@ const usePlayerStats = (activeGame: Game, playerId: string) => { stats: data?.stats ?? [], loading: !data && !error, error: error && buildError(error), - errorStatusCode: error && error.response?.status + errorStatusCode: error && error.response?.status, + mutate } } diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 47b0cb36..5c96eabb 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -27,6 +27,7 @@ export default function Footer() {
  • Player management
  • Event tracking
  • Leaderboards
  • +
  • Game channels
  • Game saves
  • Game stats
  • Game feedback
  • diff --git a/src/modals/UpdateStatValue.tsx b/src/modals/UpdateStatValue.tsx new file mode 100644 index 00000000..44546524 --- /dev/null +++ b/src/modals/UpdateStatValue.tsx @@ -0,0 +1,95 @@ +import { MouseEvent, useState } from 'react' +import Modal from '../components/Modal' +import TextInput from '../components/TextInput' +import Button from '../components/Button' +import buildError from '../utils/buildError' +import ErrorMessage, { TaloError } from '../components/ErrorMessage' +import { PlayerGameStat } from '../entities/playerGameStat' +import { KeyedMutator } from 'swr/_internal' +import updateStatValue from '../api/updateStatValue' +import { Player } from '../entities/player' +import activeGameState from '../state/activeGameState' +import { useRecoilValue } from 'recoil' +import { SelectedActiveGame } from '../state/activeGameState' +import { upperFirst } from 'lodash-es' + +type UpdateStatValueProps = { + modalState: [boolean, (open: boolean) => void] + mutate: KeyedMutator<{ stats: PlayerGameStat[] }> + editingStat: PlayerGameStat + player: Player +} + +export default function UpdateStatValue({ modalState, mutate, editingStat, player }: UpdateStatValueProps) { + const [, setOpen] = modalState + const [value, setValue] = useState(editingStat.value.toString()) + const [isLoading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const activeGame = useRecoilValue(activeGameState) as SelectedActiveGame + + const onCreateClick = async (e: MouseEvent) => { + e.preventDefault() + setLoading(true) + setError(null) + + try { + const { playerStat } = await updateStatValue(activeGame.id, player.id, editingStat.id, Number(value)) + mutate((data) => { + return { + ...data, + stats: [...data!.stats.filter((stat) => stat.id !== editingStat!.id), playerStat] + } + }, true) + setOpen(false) + } catch (err) { + setError(buildError(err)) + setLoading(false) + } + } + + return ( + +
    +
    +
    +

    Current value

    +

    {editingStat.value}

    +
    + + + + {error && } +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    + ) +} diff --git a/src/pages/PlayerStats.tsx b/src/pages/PlayerStats.tsx index 9a0e6366..73924b06 100644 --- a/src/pages/PlayerStats.tsx +++ b/src/pages/PlayerStats.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import ErrorMessage from '../components/ErrorMessage' import TableCell from '../components/tables/TableCell' @@ -14,20 +14,31 @@ import usePlayer from '../utils/usePlayer' import Table from '../components/tables/Table' import { useRecoilValue } from 'recoil' import activeGameState, { SelectedActiveGame } from '../state/activeGameState' +import Button from '../components/Button' +import { IconPencil } from '@tabler/icons-react' +import { PlayerGameStat } from '../entities/playerGameStat' +import UpdateStatValue from '../modals/UpdateStatValue' +import { PermissionBasedAction } from '../utils/canPerformAction' +import canPerformAction from '../utils/canPerformAction' +import userState from '../state/userState' +import { AuthedUser } from '../state/userState' export default function PlayerStats() { + const user = useRecoilValue(userState) as AuthedUser const activeGame = useRecoilValue(activeGameState) as SelectedActiveGame const { id: playerId } = useParams() const [player] = usePlayer() - const { stats, loading: statsLoading, error, errorStatusCode } = usePlayerStats(activeGame, playerId!) + const { stats, loading: statsLoading, error, errorStatusCode, mutate } = usePlayerStats(activeGame, playerId!) const sortedStats = useSortedItems(stats, 'updatedAt') const navigate = useNavigate() const loading = !player || statsLoading + const [editingStat, setEditingStat] = useState(null) + useEffect(() => { if (errorStatusCode === 404) { navigate(routes.players, { replace: true }) @@ -52,7 +63,20 @@ export default function PlayerStats() { {(playerStat) => ( <> {playerStat.stat.name} - {playerStat.value} + + <> + {playerStat.value} + {canPerformAction(user, PermissionBasedAction.UPDATE_PLAYER_STAT) && +