diff --git a/package-lock.json b/package-lock.json index d39de98ed..4dfefb80f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@mdi/react": "^1.6.1", "@mui/lab": "^5.0.0-alpha.166", "@mui/material": "^5.15.11", + "@mui/x-tree-view": "^6.17.0", "@reduxjs/toolkit": "^2.2.1", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^14.2.1", @@ -5398,6 +5399,35 @@ } } }, + "node_modules/@mui/x-tree-view": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@mui/x-tree-view/-/x-tree-view-6.17.0.tgz", + "integrity": "sha512-09dc2D+Rjg2z8KOaxbUXyPi0aw7fm2jurEtV8Xw48xJ00joLWd5QJm1/v4CarEvaiyhTQzHImNqdgeJW8ZQB6g==", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@mui/base": "^5.0.0-beta.20", + "@mui/utils": "^5.14.14", + "@types/react-transition-group": "^4.4.8", + "clsx": "^2.0.0", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.8.6", + "@mui/system": "^5.8.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + } + }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", diff --git a/package.json b/package.json index 57407d5bd..c9d1f6dcf 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@mdi/react": "^1.6.1", "@mui/lab": "^5.0.0-alpha.166", "@mui/material": "^5.15.11", + "@mui/x-tree-view": "^6.17.0", "@reduxjs/toolkit": "^2.2.1", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^14.2.1", diff --git a/src/App.js b/src/App.js index 01c900a81..3e4140562 100644 --- a/src/App.js +++ b/src/App.js @@ -76,6 +76,8 @@ const ItemTracker = React.lazy(() => import('./pages/item-tracker/index.js')); const Hideout = React.lazy(() => import('./pages/hideout/index.js')); const WipeLength = React.lazy(() => import('./pages/wipe-length/index.js')); const Achievements = React.lazy(() => import('./pages/achievements/index.js')); +const Players = React.lazy(() => import('./pages/players/index.js')); +const Player = React.lazy(() => import('./pages/player/index.js')); const About = React.lazy(() => import('./pages/about/index.js')); const APIDocs = React.lazy(() => import('./pages/api-docs/index.js')); @@ -871,6 +873,26 @@ function App() { remoteControlSessionElement, ]} /> + } key="suspense-players-wrapper"> + + , + remoteControlSessionElement, + ]} + /> + } key="suspense-player-wrapper"> + + , + remoteControlSessionElement, + ]} + /> { {t('Achievements')} +
  • + + {t('Players')} + +
  • { + setAccountId(params.accountId); + }, [params, setAccountId]); + + const [playerData, setPlayerData] = useState({ + aid: 0, + info: { + nickname: t('Loading'), + side: t('Loading'), + experience: 0, + memberCategory: 0, + bannedState: false, + bannedUntil: 0, + registrationDate: 0, + }, + customization: {}, + skills: {}, + equipment: {}, + achievements: {}, + favoriteItems: [], + pmcStats: {}, + scavStats: {}, + }); + const [profileError, setProfileError] = useState(false); + console.log(playerData); + const { data: items } = useItemsData(); + const { data: metaData } = useMetaData(); + const { data: achievements } = useAchievementsData(); + + useEffect(() => { + async function fetchProfile() { + if (!accountId) { + return; + } + if (isNaN(accountId)) { + try { + const response = await fetch('https://player.tarkov.dev/name/'+accountId); + if (response.status !== 200) { + return; + } + const searchResponse = await response.json(); + if (searchResponse.err) { + setProfileError(`Error searching for profile ${accountId}: ${searchResponse.errmsg}`); + return; + } + for (const result of searchResponse) { + if (result.name.toLowerCase() === accountId.toLowerCase()) { + setAccountId(result.aid); + break; + } + } + return; + } catch (error) { + console.log('Error retrieving player profile', error); + } + } + try { + const response = await fetch('https://player.tarkov.dev/account/'+accountId); + if (response.status !== 200) { + return; + } + const profileResponse = await response.json(); + if (profileResponse.err) { + setProfileError(profileResponse.errmsg); + return; + } + setPlayerData(profileResponse); + } catch (error) { + console.log('Error retrieving player profile', error); + } + } + fetchProfile(); + }, [accountId, setPlayerData, setProfileError]); + + const playerLevel = useMemo(() => { + if (playerData.info.experience === 0) { + return 0; + } + let expTotal = 0; + for (let i = 0; i < metaData.playerLevels.length; i++) { + const levelData = metaData.playerLevels[i]; + expTotal += levelData.exp; + if (expTotal === playerData.info.experience) { + return levelData.level; + } + if (expTotal > playerData.info.experience) { + return metaData.playerLevels[i - 1].level; + } + + } + return metaData.playerLevels[metaData.playerLevels.length-1].level; + }, [playerData, metaData]); + + const pageTitle = useMemo(() => { + if (!playerData.aid) { + return t('Loading...'); + } + return t('{{playerName}} - level {{playerLevel}} {{playerSide}}', { + playerName: playerData.info.nickname, + playerLevel, + playerSide: playerData.info.side, + }); + }, [playerData, playerLevel, t]); + + const achievementColumns = useMemo( + () => [ + { + Header: () => ( +
    {t('Name')}
    ), + id: 'name', + accessor: 'name', + }, + { + Header: () => ( +
    {t('Description')}
    ), + id: 'description', + accessor: 'description', + }, + { + Header: t('Player %'), + id: 'playersCompletedPercent', + accessor: 'playersCompletedPercent', + Cell: (props) => { + return ( +
    + {props.value}% +
    + ); + }, + }, + { + Header: t('Completed'), + id: 'completionDate', + accessor: 'completionDate', + Cell: (props) => { + return ( +
    + {new Date(props.value * 1000).toLocaleString()} +
    + ); + }, + }, + ], + [t], + ); + + const achievementsData = useMemo(() => { + return achievements?.map(a => { + if (!playerData.achievements[a.id]) { + return false; + } + return { + ...a, + completionDate: playerData.achievements[a.id], + } + }).filter(Boolean) || []; + }, [achievements, playerData]); + + const raidsColumns = useMemo( + () => [ + { + Header: ( +
    + {t('Side')} +
    + ), + id: 'side', + accessor: 'side', + Cell: (props) => { + return t(props.value); + }, + }, + { + Header: ( +
    + {t('Raids')} +
    + ), + id: 'raids', + accessor: 'raids', + }, + { + Header: ( +
    + {t('Survived')} +
    + ), + id: 'survived', + accessor: 'survived', + }, + { + Header: ( +
    + {t('Runthrough')} +
    + ), + id: 'runthrough', + accessor: 'runthrough', + Cell: (props) => { + return props.value; + }, + }, + { + Header: ( +
    + {t('MIA')} +
    + ), + id: 'mia', + accessor: 'mia', + Cell: (props) => { + return props.value; + }, + }, + { + Header: ( +
    + {t('KIA')} +
    + ), + id: 'kia', + accessor: 'kia', + Cell: (props) => { + return props.value; + }, + }, + { + Header: ( +
    + {t('Kills')} +
    + ), + id: 'kills', + accessor: 'kills', + Cell: (props) => { + return props.value; + }, + }, + { + Header: ( +
    + {t('K:D', {nsSeparator: '|'})} +
    + ), + id: 'kdr', + accessor: 'kills', + Cell: (props) => { + return (props.value / props.row.original.kia).toFixed(2); + }, + }, + ], + [t], + ); + + const raidsData = useMemo(() => { + if (!playerData.pmcStats?.eft) { + return []; + } + const statSides = {'pmcStats': 'PMC', 'scavStats': 'Scav'}; + const statTypes = [ + { + name: 'raids', + key: ['Sessions'], + }, + { + name: 'survived', + key: ['ExitStatus', 'Survived'], + }, + { + name: 'runthrough', + key: ['ExitStatus', 'Runner'], + }, + { + name: 'mia', + key: ['ExitStatus', 'Left'], + }, + { + name: 'kia', + key: ['ExitStatus', 'Killed'], + }, + { + name: 'kills', + key: ['Kills'], + }, + ]; + const getStats = (side) => { + return { + side, + ...statTypes.reduce((all, s) => { + all[s.name] = 0; + return all; + }, {}) + }; + }; + const totalStats = getStats('Total'); + const statsData = [totalStats]; + for (const sideKey in statSides) { + const sideLabel = statSides[sideKey]; + const stats = playerData[sideKey].eft.overAllCounters.Items; + const currentData = getStats(sideLabel); + for (const st of statTypes) { + const foundStat = stats.find(s => !st.key.some(keyPart => !s.Key.includes(keyPart))); + //console.log(sideKey, st, foundStat); + currentData[st.name] = foundStat?.Value || 0; + totalStats[st.name] += currentData[st.name]; + } + statsData.push(currentData); + } + //console.log(statsData); + return statsData; + }, [playerData]); + + const skillsColumns = useMemo( + () => [ + { + Header: ( +
    + {t('Skill')} +
    + ), + id: 'skill', + accessor: 'skill', + Cell: (props) => { + return t(props.value); + }, + }, + { + Header: ( +
    + {t('Progress')} +
    + ), + id: 'progress', + accessor: 'progress', + }, + { + Header: ( +
    + {t('Last Access')} +
    + ), + id: 'lastAccess', + accessor: 'lastAccess', + Cell: (props) => { + return new Date(props.value * 1000).toLocaleString(); + }, + }, + ], + [t], + ); + + const skillsData = useMemo(() => { + return playerData.skills?.Common?.map(s => { + if (!s.Progress || s.LastAccess <= 0) { + return false; + } + return { + skill: s.Id, + progress: s.Progress, + lastAccess: s.LastAccess, + } + }).filter(Boolean) || []; + }, [playerData]); + + const totalSecondsInGame = useMemo(() => { + return playerData.pmcStats?.eft?.totalInGameTime || 0; + }, [playerData]); + + const getLoadoutContents = useCallback((parentItem) => { + return playerData?.equipment?.Items.reduce((contents, loadoutItem) => { + if (loadoutItem.parentId !== parentItem._id) { + return contents; + } + let item = items.find(i => i.id === loadoutItem._tpl); + if (!item) { + return contents; + } + if (item.properties?.defaultPreset) { + const preset = items.find(i => i.id === item.properties.defaultPreset.id); + item = { + ...item, + width: preset.width, + height: preset.height, + baseImageLink: preset.baseImageLink, + }; + } + let countLabel; + + let label = ''; + if (loadoutItem.upd?.StackObjectsCount > 1) { + countLabel = loadoutItem.upd?.StackObjectsCount; + } + if (loadoutItem.upd?.Dogtag) { + const tag = loadoutItem.upd.Dogtag; + const weapon = items.find(i => i.id === tag.WeaponName?.split(' ')[0]); + countLabel = tag.Level; + label = ( + + {tag.Nickname} + {` ${t(tag.Status)} `} + {tag.KillerName} + {weapon !== undefined && [ + {` ${t('using')} `}, + {weapon.name} + ]} + {` ${new Date(tag.Time).toLocaleString()}`} + + ); + } + if (loadoutItem.upd?.Key) { + const key = items.find(i => i.id === loadoutItem._tpl); + if (key) { + countLabel = `${key.properties.uses-loadoutItem.upd.Key.NumberOfUsages}/${key.properties.uses}`; + } + } + if (loadoutItem.upd?.Repairable) { + countLabel = `${loadoutItem.upd.Repairable.Durability}/${loadoutItem.upd.Repairable.MaxDurability}` + } + if (loadoutItem.upd?.MedKit) { + const item = items.find(i => i.id === loadoutItem._tpl); + if (item?.properties?.uses || item?.properties?.hitpoints) { + countLabel = `${loadoutItem.upd.MedKit.HpResource}/${item.properties?.uses || item.properties?.hitpoints}`; + } + } + + const itemImage = ( + + ); + contents.push(( + + {getLoadoutContents(loadoutItem)} + + )); + return contents; + }, []); + }, [items, playerData, t]); + + return [ + , +
    +
    +

    + + {pageTitle} +

    +
    +
    + {profileError && ( +

    {profileError}

    + )} + {playerData.info.registrationDate && ( +

    {`${t('Started current wipe')}: ${new Date(playerData.info.registrationDate * 1000).toLocaleString()}`}

    + )} + {playerData.info.bannedState && ( +

    {t('Banned')}

    + )} + {totalSecondsInGame > 0 && ( +

    {`${t('Total account time in game')}: ${(() => { + const { days, hours, minutes, seconds } = getDHMS(totalSecondsInGame); + + return t('{{days}} days, {{hours}} h, {{minutes}} m, {{seconds}} s', { + days, + hours, + minutes, + seconds + }); + })()}`}

    + )} +

    {t('Raid Stats')}

    + {Object.keys(playerData.pmcStats).length > 0 ? + + :

    {t('None')}

    } +

    {t('Achievements')}

    + {Object.keys(playerData.achievements).length > 0 ? + + :

    {t('None')}

    } +

    {t('Loadout')}

    + {playerData?.equipment?.Id !== undefined && ( + } + defaultCollapseIcon={} + defaultParentIcon={***} + > + {getLoadoutContents(playerData.equipment.Items.find(i => i._id === playerData.equipment.Id))} + + )} +

    {t('Skills')}

    + {playerData.skills?.Common?.length > 0 && ( + + )} +

    {t('Mastering')}

    + {playerData.skills?.Mastering?.length > 0 && ( +
      + {playerData.skills.Mastering.map(skillData => { + if (skillData.Progress <= 1) { + return false; + } + return ( +
    • {`${t(skillData.Id)}: ${skillData.Progress}`}
    • + ); + }).filter(Boolean)} +
    + )} +
    +
    , + ]; +} + +export default Player; diff --git a/src/pages/players/index.css b/src/pages/players/index.css new file mode 100644 index 000000000..83b95a4e4 --- /dev/null +++ b/src/pages/players/index.css @@ -0,0 +1,12 @@ +.search-button { + padding: .2rem; + border-radius: 4px; +} + +.search-controls { + display: flex; +} + +.search-controls input { + margin-right: 10px; +} diff --git a/src/pages/players/index.js b/src/pages/players/index.js new file mode 100644 index 000000000..317094ac4 --- /dev/null +++ b/src/pages/players/index.js @@ -0,0 +1,96 @@ +import { useState, useCallback, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { Trans, useTranslation } from 'react-i18next'; + +import { Icon } from '@mdi/react'; +import { mdiAccountSearch } from '@mdi/js'; + +import SEO from '../../components/SEO.jsx'; +import { InputFilter } from '../../components/filter/index.js'; + +import './index.css'; + +function Players() { + const { t } = useTranslation(); + + const defaultQuery = new URLSearchParams(window.location.search).get( + 'search', + ); + const [nameFilter, setNameFilter] = useState(defaultQuery || ''); + const [nameResults, setNameResults] = useState([]); + + const searchForName = useCallback(async () => { + if (nameFilter.length < 3 && nameFilter > 14) { + return; + } + try { + const response = await fetch('https://player.tarkov.dev/name/'+nameFilter); + if (response.status !== 200) { + return; + } + setNameResults(await response.json()); + } catch (error) { + console.log('Error searching player profile', error); + } + }, [nameFilter, setNameResults]); + + const searchResults = useMemo(() => { + if (nameResults.length < 1) { + return ''; + } + return ( +
    +
      + {nameResults.map(result => { + return
    • + + {result.name} + +
    • + })} +
    +
    + ); + }, [nameResults]); + + if (defaultQuery) { + searchForName(); + } + + return [ + , +
    +
    +

    + + {t('Players')} +

    +
    +
    + +

    + Search for Escape From Tarkov players and view their profiles. +

    +
    +
    +
    + { + setNameFilter(event.target.value); + }} + /> + +
    + {searchResults} +
    , + ]; +} + +export default Players;