Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
39 changes: 26 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
"lint-staged": {
"*.{ts,js,tsx,jsx}": "eslint --fix"
},
"version": "0.41.0",
"version": "0.42.0",
"engines": {
"node": "20.x"
},
Expand Down
15 changes: 15 additions & 0 deletions src/api/updateStatValue.ts
Original file line number Diff line number Diff line change
@@ -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
5 changes: 3 additions & 2 deletions src/api/usePlayerStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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
}
}

Expand Down
1 change: 1 addition & 0 deletions src/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default function Footer() {
<li><Link to='https://trytalo.com/players?utm_source=dashboard&utm_medium=footer'>Player management</Link></li>
<li><Link to='https://trytalo.com/events?utm_source=dashboard&utm_medium=footer'>Event tracking</Link></li>
<li><Link to='https://trytalo.com/leaderboards?utm_source=dashboard&utm_medium=footer'>Leaderboards</Link></li>
<li><Link to='https://trytalo.com/channels?utm_source=dashboard&utm_medium=footer'>Game channels</Link></li>
<li><Link to='https://trytalo.com/saves?utm_source=dashboard&utm_medium=footer'>Game saves</Link></li>
<li><Link to='https://trytalo.com/stats?utm_source=dashboard&utm_medium=footer'>Game stats</Link></li>
<li><Link to='https://trytalo.com/feedback?utm_source=dashboard&utm_medium=footer'>Game feedback</Link></li>
Expand Down
95 changes: 95 additions & 0 deletions src/modals/UpdateStatValue.tsx
Original file line number Diff line number Diff line change
@@ -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<TaloError | null>(null)

const activeGame = useRecoilValue(activeGameState) as SelectedActiveGame

const onCreateClick = async (e: MouseEvent<HTMLElement>) => {
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 (
<Modal
id='update-player-stat'
title={upperFirst(editingStat.stat.name)}
modalState={modalState}
>
<form>
<div className='p-4 space-y-4'>
<div>
<p className='font-semibold'>Current value</p>
<p>{editingStat.value}</p>
</div>

<TextInput
id='value'
variant='light'
type='number'
label='New value'
placeholder='Stat value'
onChange={setValue}
value={value}
inputClassName='border border-gray-200 focus:border-opacity-0'
/>

{error && <ErrorMessage error={error} />}
</div>

<div className='flex flex-col md:flex-row-reverse md:justify-between space-y-4 md:space-y-0 p-4 border-t border-gray-200'>
<div className='w-full md:w-32'>
<Button
disabled={!value}
isLoading={isLoading}
onClick={onCreateClick}
>
Update
</Button>
</div>
<div className='w-full md:w-32'>
<Button type='button' variant='grey' onClick={() => setOpen(false)}>Cancel</Button>
</div>
</div>
</form>
</Modal>
)
}
39 changes: 36 additions & 3 deletions src/pages/PlayerStats.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<PlayerGameStat | null>(null)

useEffect(() => {
if (errorStatusCode === 404) {
navigate(routes.players, { replace: true })
Expand All @@ -52,7 +63,20 @@ export default function PlayerStats() {
{(playerStat) => (
<>
<TableCell className='min-w-60'>{playerStat.stat.name}</TableCell>
<TableCell className='min-w-40'>{playerStat.value}</TableCell>
<TableCell className='min-w-40 flex items-center space-x-2'>
<>
<span>{playerStat.value}</span>
{canPerformAction(user, PermissionBasedAction.UPDATE_PLAYER_STAT) &&
<Button
variant='icon'
className='p-1 rounded-full bg-indigo-900'
onClick={() => setEditingStat(playerStat)}
icon={<IconPencil size={16} />}
extra={{ 'aria-label': 'Edit game name' }}
/>
}
</>
</TableCell>
<DateCell>{format(new Date(playerStat.createdAt), 'dd MMM Y, HH:mm')}</DateCell>
<DateCell>{format(new Date(playerStat.updatedAt), 'dd MMM Y, HH:mm')}</DateCell>
</>
Expand All @@ -61,6 +85,15 @@ export default function PlayerStats() {
</Table>
}

{editingStat &&
<UpdateStatValue
modalState={[true, () => setEditingStat(null)]}
mutate={mutate}
editingStat={editingStat}
player={player}
/>
}

{error && <ErrorMessage error={error} />}
</Page>
)
Expand Down
4 changes: 3 additions & 1 deletion src/utils/canPerformAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ export enum PermissionBasedAction {
DELETE_STAT,
DELETE_GROUP,
DELETE_FEEDBACK_CATEGORY,
VIEW_PLAYER_AUTH_ACTIVITIES
VIEW_PLAYER_AUTH_ACTIVITIES,
UPDATE_PLAYER_STAT
}

export default function canPerformAction(user: User, action: PermissionBasedAction) {
Expand All @@ -17,6 +18,7 @@ export default function canPerformAction(user: User, action: PermissionBasedActi
case PermissionBasedAction.DELETE_STAT:
case PermissionBasedAction.DELETE_FEEDBACK_CATEGORY:
case PermissionBasedAction.VIEW_PLAYER_AUTH_ACTIVITIES:
case PermissionBasedAction.UPDATE_PLAYER_STAT:
return user.type === UserType.ADMIN
case PermissionBasedAction.DELETE_GROUP:
return [UserType.DEV, UserType.ADMIN].includes(user.type)
Expand Down
Loading