diff --git a/i18n/en_US.json b/i18n/en_US.json index 29cfe92..cd0d124 100644 --- a/i18n/en_US.json +++ b/i18n/en_US.json @@ -50,6 +50,7 @@ "PREF_FRIEND_API_USAGE_ALL": "All Players", "PREF_FRIEND_API_USAGE_CHEATERS_ONLY": "Cheaters", "PREF_FRIEND_API_USAGE_NONE": "Nobody", + "PREF_SORT_DISCONNECTED_LAST": "Force disconnected players to appear last", "TOOLTIP_ACTUAL_NAME": "Actual Name:", "TOOLTIP_PREVIOUS_NAME": "Previous Name(s):", "TOOLTIP_BANS_DAYS": "day(s) since last ban", diff --git a/src/Pages/Preferences/Preferences.tsx b/src/Pages/Preferences/Preferences.tsx index d762046..ed9b224 100644 --- a/src/Pages/Preferences/Preferences.tsx +++ b/src/Pages/Preferences/Preferences.tsx @@ -118,6 +118,15 @@ const Preferences = () => { onChange={(e) => handleSettingChange('openInApp', e)} /> + +
+ {t('PREF_SORT_DISCONNECTED_LAST')} +
+ handleSettingChange('sortDisconnectedLast', e)} + /> +
{t('PREF_COLORS_PRECEDENCE')} diff --git a/src/api/api.d.ts b/src/api/api.d.ts index ba42736..4d026d0 100644 --- a/src/api/api.d.ts +++ b/src/api/api.d.ts @@ -2,6 +2,7 @@ interface Settings { external: { language?: string; openInApp?: boolean; + sortDisconnectedLast?: boolean; colors?: { You: string; Player: string; diff --git a/src/api/preferences/index.ts b/src/api/preferences/index.ts index db0995a..b43a291 100644 --- a/src/api/preferences/index.ts +++ b/src/api/preferences/index.ts @@ -65,6 +65,7 @@ interface PreferenceResponse { interface Settings { language: string; openInApp: boolean; + sortDisconnectedLast: boolean; colors: { You: string; Player: string; @@ -82,6 +83,7 @@ export const defaultSettings: PreferenceResponse = { external: { language: 'English', openInApp: false, + sortDisconnectedLast: true, colors: { You: '#00aaaa', Player: 'none', diff --git a/src/components/General/Checkbox/Checkbox.css b/src/components/General/Checkbox/Checkbox.css index 66c6f8d..6df7673 100644 --- a/src/components/General/Checkbox/Checkbox.css +++ b/src/components/General/Checkbox/Checkbox.css @@ -1,13 +1,9 @@ .checkbox-wrapper { + display: flex; align-items: center; cursor: pointer; } -.checkbox-icon { - position: relative; - top: 6px; -} - .checkbox-icon.checked { color: #0dc9d0; } diff --git a/src/components/TF2/Player/playerutils.tsx b/src/components/TF2/Player/playerutils.tsx index b2ad9da..3f705b7 100644 --- a/src/components/TF2/Player/playerutils.tsx +++ b/src/components/TF2/Player/playerutils.tsx @@ -11,29 +11,7 @@ import { Users2, } from 'lucide-react'; import { Tooltip } from '@components/General'; - -const localVerdict = [ - { - label: 'PLAYER', - value: 'Player', - }, - { - label: 'BOT', - value: 'Bot', - }, - { - label: 'CHEATER', - value: 'Cheater', - }, - { - label: 'SUSPICIOUS', - value: 'Suspicious', - }, - { - label: 'TRUSTED', - value: 'Trusted', - }, -]; +import { LOCAL_VERDICT_OPTIONS } from '../../../constants/playerConstants'; function calculateKD(kills: number = 0, deaths: number = 0): string { // No Kills, No KD @@ -47,7 +25,9 @@ function calculateKD(kills: number = 0, deaths: number = 0): string { function localizeVerdict(verdict: string | undefined) { if (!verdict || verdict.toLowerCase() === 'none') return t('PLAYER'); - const option = localVerdict.find((option) => option.value === verdict); + const option = LOCAL_VERDICT_OPTIONS.find( + (option) => option.value === verdict, + ); if (!option) console.error('Invalid verdict: ', verdict); @@ -55,7 +35,7 @@ function localizeVerdict(verdict: string | undefined) { } function makeLocalizedVerdictOptions() { - return localVerdict.map((option) => ({ + return LOCAL_VERDICT_OPTIONS.map((option) => ({ label: t(option.label.toUpperCase()), value: option.value, })); diff --git a/src/components/TF2/ScoreboardTable/ScoreboardTable.tsx b/src/components/TF2/ScoreboardTable/ScoreboardTable.tsx index 16c3fed..9b9e20a 100644 --- a/src/components/TF2/ScoreboardTable/ScoreboardTable.tsx +++ b/src/components/TF2/ScoreboardTable/ScoreboardTable.tsx @@ -1,10 +1,18 @@ import React from 'react'; -import './ScoreboardTable.css'; import { getAllSettings } from '@api/preferences'; import { Player } from '@components/TF2'; -import { t } from '@i18n'; import { ContextMenuProvider } from '@context'; +import { + DEFAULT_SORT_ORDER, + SORTABLE_SCOREBOARD_HEADERS, + Sorting, +} from '../../../constants/tableConstants'; +import { SortableTableHeader } from './SortableTableHeader'; +import { sortByFilter } from './soreboardUtils'; + +import './ScoreboardTable.css'; + interface ScoreboardTableProps { RED: PlayerInfo[]; BLU: PlayerInfo[]; @@ -29,6 +37,8 @@ const ScoreboardTable = ({ BLU, RED }: ScoreboardTableProps) => { }, openInApp: false, }); + const [currentSort, updateCurrentSorting] = + React.useState(DEFAULT_SORT_ORDER); React.useEffect(() => { const fetchTeamColors = async () => { @@ -71,6 +81,18 @@ const ScoreboardTable = ({ BLU, RED }: ScoreboardTableProps) => { (p) => p.convicted || ['Cheater', 'Bot'].includes(p.localVerdict ?? ''), ); + const getSortedPlayers = React.useCallback( + () => + sortByFilter({ + currentSort, + team, + playerSettings, + }), + [currentSort, team], + ); + + const sortedPlayers = getSortedPlayers(); + return ( // Keep the classname for the popoutinfo alignment
@@ -80,15 +102,21 @@ const ScoreboardTable = ({ BLU, RED }: ScoreboardTableProps) => { {teamColor} ({team?.length - amountDisconnected})
-
{t('TEAM_NAV_RATING')}
-
{t('TEAM_NAV_USER')}
+ {SORTABLE_SCOREBOARD_HEADERS.map((header) => ( + { + updateCurrentSorting(newSort); + }} + /> + ))} {/*
{t('TEAM_NAV_STATUS')}
*/} -
{t('TEAM_NAV_TIME')}
- {team?.map((player) => ( + {sortedPlayers?.map((player) => ( // Provide the Context Menu Provider to the Element void; + currentSort: Sorting; +} + +export const SortableTableHeader = ({ + header, + changeSort, + currentSort, +}: SortableTableHeaderProps) => { + const SortIcon = ({ size }: { size: number }) => { + if (currentSort.sortValue === header.sortValue) { + switch (currentSort.sortType) { + case SORT_TYPES.SORT_ASC: { + return ; + } + case SORT_TYPES.SORT_DESC: { + return ; + } + } + } + + return ; + }; + + return ( +
+ +
+ ); +}; diff --git a/src/components/TF2/ScoreboardTable/soreboardUtils.tsx b/src/components/TF2/ScoreboardTable/soreboardUtils.tsx new file mode 100644 index 0000000..2ff03b1 --- /dev/null +++ b/src/components/TF2/ScoreboardTable/soreboardUtils.tsx @@ -0,0 +1,103 @@ +import { VERDICT_TYPES } from '../../../constants/playerConstants'; +import { + RATING_SORT_ORDER, + SORT_OPTIONS, + SORT_TYPES, + Sorting, +} from '../../../constants/tableConstants'; + +interface SortByFilterProps { + currentSort: Sorting; + team: PlayerInfo[]; + playerSettings: Settings['external']; +} + +export const sortByFilter = ({ + currentSort, + team, + playerSettings, +}: SortByFilterProps): PlayerInfo[] => { + if (currentSort.sortType === SORT_TYPES.UNSORTED) { + return team; + } + + switch (currentSort.sortValue) { + case SORT_OPTIONS.SORT_BY_USER: { + return team.sort((curr, next) => { + if (next.isSelf) return 1; + if (curr.isSelf) return 0; + + if (playerSettings.sortDisconnectedLast) { + if ( + next.gameInfo.state === 'Disconnected' || + curr.gameInfo.state === 'Disconnected' + ) + return 1; + } + + return currentSort.sortType === SORT_TYPES.SORT_ASC + ? curr.name.localeCompare(next.name) + : next.name.localeCompare(curr.name); + }); + } + + case SORT_OPTIONS.SORT_BY_TIME: { + return team.sort((curr, next) => { + if (next.isSelf) return 1; + if (curr.isSelf) return 0; + + if (playerSettings.sortDisconnectedLast) { + if ( + next.gameInfo.state === 'Disconnected' || + curr.gameInfo.state === 'Disconnected' + ) + return 1; + } + + if (currentSort.sortType === SORT_TYPES.SORT_ASC) { + if (next.gameInfo.state === 'Disconnected') return 1; + if (curr.gameInfo.state === 'Disconnected') return 0; + + return curr.gameInfo.time - next.gameInfo.time; + } + + if (curr.gameInfo.state === 'Disconnected') return 1; + if (next.gameInfo.state === 'Disconnected') return 0; + + return next.gameInfo.time - curr.gameInfo.time; + }); + } + + case SORT_OPTIONS.SORT_BY_RATING: { + return team.sort((curr, next) => { + if (next.isSelf) return 1; + if (curr.isSelf) return 0; + + if (playerSettings.sortDisconnectedLast) { + if ( + next.gameInfo.state === 'Disconnected' || + curr.gameInfo.state === 'Disconnected' + ) + return 1; + } + + const currRatingScore = + RATING_SORT_ORDER[ + (curr.localVerdict as VERDICT_TYPES | undefined) ?? 'None' + ]; + const nextRatingScore = + RATING_SORT_ORDER[ + (next.localVerdict as VERDICT_TYPES | undefined) ?? 'None' + ]; + + return currentSort.sortType === SORT_TYPES.SORT_ASC + ? currRatingScore - nextRatingScore + : nextRatingScore - currRatingScore; + }); + } + + default: { + return team; + } + } +}; diff --git a/src/constants/playerConstants.tsx b/src/constants/playerConstants.tsx new file mode 100644 index 0000000..2404ce3 --- /dev/null +++ b/src/constants/playerConstants.tsx @@ -0,0 +1,35 @@ +export type VERDICT_TYPES = + | 'Player' + | 'Bot' + | 'Cheater' + | 'Suspicious' + | 'Trusted' + | 'None'; + +interface VERDICT_OPTION { + label: string; + value: VERDICT_TYPES | string; +} + +export const LOCAL_VERDICT_OPTIONS: VERDICT_OPTION[] = [ + { + label: 'PLAYER', + value: 'Player', + }, + { + label: 'BOT', + value: 'Bot', + }, + { + label: 'CHEATER', + value: 'Cheater', + }, + { + label: 'SUSPICIOUS', + value: 'Suspicious', + }, + { + label: 'TRUSTED', + value: 'Trusted', + }, +]; diff --git a/src/constants/tableConstants.tsx b/src/constants/tableConstants.tsx new file mode 100644 index 0000000..4ce5f5e --- /dev/null +++ b/src/constants/tableConstants.tsx @@ -0,0 +1,50 @@ +import { VERDICT_TYPES } from './playerConstants'; + +export enum SORT_TYPES { + UNSORTED, + SORT_ASC, + SORT_DESC, +} + +export enum SORT_OPTIONS { + SORT_BY_USER, + SORT_BY_TIME, + SORT_BY_RATING, +} + +export interface Sorting { + sortValue: SORT_OPTIONS; + sortType: SORT_TYPES; +} + +export interface SortableHeader { + sortValue: SORT_OPTIONS; + nameKey: string; + hideWhenSmall?: boolean; +} + +export const DEFAULT_SORT_ORDER: Sorting = { + sortValue: SORT_OPTIONS.SORT_BY_TIME, + sortType: SORT_TYPES.SORT_DESC, +}; + +export const SORTABLE_SCOREBOARD_HEADERS: SortableHeader[] = [ + { sortValue: SORT_OPTIONS.SORT_BY_RATING, nameKey: 'TEAM_NAV_RATING' }, + { sortValue: SORT_OPTIONS.SORT_BY_USER, nameKey: 'TEAM_NAV_USER' }, + { + sortValue: SORT_OPTIONS.SORT_BY_TIME, + nameKey: 'TEAM_NAV_TIME', + hideWhenSmall: true, + }, +]; + +export const RATING_SORT_ORDER: { + [Property in VERDICT_TYPES]: number; +} = { + Trusted: 0, + Player: 1, + None: 1, // This should appear as "Player" either way + Suspicious: 2, + Cheater: 3, + Bot: 4, +}; diff --git a/tailwind.config.js b/tailwind.config.js index 70c6974..370f183 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -14,9 +14,9 @@ module.exports = { outline: '#c5c6d1', }, gridTemplateColumns: { - player: '122px minmax(100px, 1fr) max-content minmax(0px, 60px)', + player: '122px minmax(100px, 1fr) max-content minmax(0px, 75px)', playersm: '120px minmax(100px, 1fr) 18px', - scoreboardnav: '114px minmax(95px, 1fr) 55px', + scoreboardnav: '114px minmax(95px, 1fr) 70px', scoreboardnavsm: '180px 130px', scoreboardgrid: '1fr 3px 1fr', scoreboardgridsm: '1fr',