Skip to content

Commit

Permalink
wip(web/players): assorted changes
Browse files Browse the repository at this point in the history
  • Loading branch information
tabarra committed Mar 17, 2024
1 parent 5bdcaa9 commit 2e913a6
Show file tree
Hide file tree
Showing 6 changed files with 65 additions and 51 deletions.
4 changes: 3 additions & 1 deletion core/extras/helpers.ts
Expand Up @@ -199,7 +199,9 @@ export const parseLaxIdsArrayInput = (fullInput: string) => {
for (const input of inputs) {
if (input.includes(':')) {
if (consts.regexValidHwidToken.test(input)) {
validHwids.push(input)
validHwids.push(input);
}else if (Object.values(consts.validIdentifiers).some((regex) => regex.test(input))){
validIds.push(input);
} else {
const [type, value] = input.split(':', 1);
if (consts.validIdentifierParts[type as keyof typeof consts.validIdentifierParts]?.test(value)) {
Expand Down
5 changes: 3 additions & 2 deletions core/webroutes/player/search.ts
Expand Up @@ -39,8 +39,6 @@ export default async function PlayerModal(ctx: AuthedCtx) {
const dbo = ctx.txAdmin.playerDatabase.getDb();
let chain = dbo.chain.get('players');

console.dir(ctx.query); //DEBUG

/*
In order:
- [X] sort the players by the sortingKey/sortingDesc
Expand Down Expand Up @@ -109,6 +107,7 @@ export default async function PlayerModal(ctx: AuthedCtx) {
}

if (searchType === 'playerName') {
//Searching by player name
const { pureName } = cleanPlayerName(searchValue);
if (pureName === 'emptyname') {
return sendTypedResp({ error: 'This player name is unsearchable (pureName is empty).' });
Expand All @@ -122,6 +121,7 @@ export default async function PlayerModal(ctx: AuthedCtx) {
const filtered = fuse.search(pureName).map(x => x.item);
chain = createChain(filtered);
} else if (searchType === 'playerNotes') {
//Searching by player notes
const players = chain.value();
const fuse = new Fuse(players, {
keys: ['notes.text'],
Expand All @@ -130,6 +130,7 @@ export default async function PlayerModal(ctx: AuthedCtx) {
const filtered = fuse.search(searchValue).map(x => x.item);
chain = createChain(filtered);
} else if (searchType === 'playerIds') {
//Searching by player identifiers
const { validIds, validHwids, invalidIds } = parseLaxIdsArrayInput(searchValue);
if (invalidIds.length) {
return sendTypedResp({ error: `Invalid identifiers (${invalidIds.join(',')}). Prefix any identifier with their type, like 'fivem:123456' instead of just '123456'.` });
Expand Down
11 changes: 6 additions & 5 deletions docs/dev_notes.md
Expand Up @@ -18,14 +18,15 @@
- [x] pressing enter on the license input text in setup page refreshes the page
- [x] can I remove `/nui/resetSession`? I think we don't even use cookies anymore
- [ ] NEW PAGE: Players
- [ ] test everything
- [ ] show online/notes/admin
- [X] make sure it is not spamming search requests at the start (remove debug print on the route)
- [x] show online/notes/admin
- [x] test everything
- [x] ~~Write `estimateSize` function to calculate size dynamically?~~ made it no-wrap
- [x] code the hotkey
- [ ] temporarily, dropdown redirects:
- Legacy Ban -> old players page
- prune players/hwids (from master actions -> clean database)
- [ ] code the hotkey
- [ ] make sure it is not spamming search requests at the start (remove debug print on the route)
- [ ] Write `estimateSize` function to calculate size dynamically?
- [ ] track search duration in StatsManager just like we do for join checks
- [ ] NEW PAGE: History
- [ ] fix disallowed intents message

Expand Down
26 changes: 13 additions & 13 deletions panel/src/pages/Players/PlayersPage.tsx
Expand Up @@ -15,10 +15,7 @@ const PageCalloutRowMemo = memo(PageCalloutRow);

export default function PlayersPage() {
const [calloutData, setCalloutData] = useState<PlayersStatsResp|undefined>(undefined);
const [searchBoxReturn, setSearchBoxReturn] = useState<PlayersSearchBoxReturnStateType>({
search: null,
filters: [],
});
const [searchBoxReturn, setSearchBoxReturn] = useState<PlayersSearchBoxReturnStateType|undefined>(undefined);
const statsApi = useBackendApi<PlayersStatsResp>({
method: 'GET',
path: '/player/stats',
Expand Down Expand Up @@ -46,8 +43,9 @@ export default function PlayersPage() {
}, []);


const hasCalloutData = calloutData && !('error' in calloutData);
return (<div
className='flex flex-col min-w-96'
className='flex flex-col min-w-96 w-full'
style={{ height: 'calc(100vh - 3.5rem - 1px - 2rem)' }}
>
{/* <div
Expand All @@ -60,25 +58,25 @@ export default function PlayersPage() {
callouts={[
{
label: 'Total Players',
value: calloutData?.total ?? false,
value: hasCalloutData ? calloutData.total : false,
icon: <UsersIcon />,
prefix: ''
},
{
label: 'Players Today',
value: calloutData?.playedLast24h ?? false,
value: hasCalloutData ? calloutData.playedLast24h : false,
icon: <CalendarPlusIcon />,
prefix: ''
},
{
label: 'New Players Today',
value: calloutData?.joinedLast24h ?? false,
value: hasCalloutData ? calloutData.joinedLast24h : false,
icon: <UserRoundPlusIcon />,
prefix: '+'
},
{
label: 'New Players This Week',
value: calloutData?.joinedLast7d ?? false,
value: hasCalloutData ? calloutData.joinedLast7d : false,
icon: <UserRoundPlusIcon />,
prefix: '+'
}
Expand All @@ -88,9 +86,11 @@ export default function PlayersPage() {
doSearch={doSearch}
initialState={initialState}
/>
<PlayersTableMemo
search={searchBoxReturn.search}
filters={searchBoxReturn.filters}
/>
{searchBoxReturn ? (
<PlayersTableMemo
search={searchBoxReturn.search}
filters={searchBoxReturn.filters}
/>
) : null}
</div>);
}
11 changes: 10 additions & 1 deletion panel/src/pages/Players/PlayersSearchBox.tsx
Expand Up @@ -15,6 +15,7 @@ import {
} from "@/components/ui/dropdown-menu";
import InlineCode from '@/components/InlineCode';
import { PlayersTableFiltersType, PlayersTableSearchType } from "@shared/playerApiTypes";
import { useEventListener } from "usehooks-ts";


/**
Expand Down Expand Up @@ -92,7 +93,7 @@ export function PlayerSearchBox({ doSearch, initialState }: PlayerSearchBoxProps
//Call onSearch when params change
useEffect(() => {
updateSearch();
}, [inputRef, currSearchType, selectedFilters]);
}, [currSearchType, selectedFilters]);

//Input handlers
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
Expand Down Expand Up @@ -124,6 +125,14 @@ export function PlayerSearchBox({ doSearch, initialState }: PlayerSearchBoxProps
}
}

//Search hotkey
useEventListener('keydown', (e: KeyboardEvent) => {
if (e.code === 'KeyF' && (e.ctrlKey || e.metaKey)) {
inputRef.current?.focus();
e.preventDefault();
}
});

//It's render time! 🎉
const selectedSearchType = availableSearchTypes.find((type) => type.value === currSearchType);
if (!selectedSearchType) throw new Error(`Invalid search type: ${currSearchType}`);
Expand Down
59 changes: 30 additions & 29 deletions panel/src/pages/Players/PlayersTable.tsx
Expand Up @@ -2,9 +2,9 @@ import { useEffect, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ScrollArea } from '@/components/ui/scroll-area';
import TxAnchor from '@/components/TxAnchor';
import { cn, createRandomHslColor, msToShortDuration, tsToLocaleDateTime } from '@/lib/utils';
import { cn, msToShortDuration, tsToLocaleDateTime } from '@/lib/utils';
import { TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table";
import { Loader2Icon } from 'lucide-react';
import { Loader2Icon, ShieldCheckIcon, ActivitySquareIcon, FileTextIcon } from 'lucide-react';
import { useOpenPlayerModal } from "@/hooks/playerModal";
import { PlayersTableSearchResp, PlayersTableFiltersType, PlayersTableSearchType, PlayersTableSortingType, PlayersTablePlayerType } from '@shared/playerApiTypes';
import { useBackendApi } from '@/hooks/fetch';
Expand All @@ -28,13 +28,20 @@ function PlayerRow({ rowData, modalOpener }: PlayerRowProps) {
//border-r whitespace-nowrap text-ellipsis overflow-hidden
return (
<TableRow onClick={openModal} className='cursor-pointer'>
<TableCell
className={cn(
'px-4 py-2 border-r',
rowData.isOnline && 'text-success-inline'
)}
>{rowData.displayName}</TableCell>
<TableCell className='px-4 py-2 border-r'>ligma</TableCell>
<TableCell className={'px-4 py-2 flex justify-between border-r'}>
<span className='text-ellipsis overflow-hidden line-clamp-1 break-all'>{rowData.displayName}</span>
<div className='inline-flex items-center gap-1'>
<ActivitySquareIcon className={cn('h-5',
rowData.isOnline ? 'text-success-inline animate-pulse' : 'text-muted'
)} />
<ShieldCheckIcon className={cn('h-5',
rowData.isAdmin ? 'text-warning-inline' : 'text-muted'
)} />
<FileTextIcon className={cn('h-5',
rowData.notes ? 'text-secondary-foreground' : 'text-muted'
)} />
</div>
</TableCell>
<TableCell className='px-4 py-2 border-r'>{msToShortDuration(rowData.playTime * 60_000)}</TableCell>
<TableCell className='px-4 py-2 border-r'>{convertRowDateTime(rowData.tsJoined)}</TableCell>
<TableCell className='px-4 py-2'>{convertRowDateTime(rowData.tsLastConnection)}</TableCell>
Expand All @@ -55,17 +62,17 @@ type LastRowProps = {

function LastRow({ playersCount, hasReachedEnd, isFetching, loadError, retryFetch }: LastRowProps) {
let content: React.ReactNode;
if (hasReachedEnd) {
content = <span className='font-bold text-muted-foreground'>
{playersCount ? 'You have reached the end of the list.' : 'No players found.'}
</span>
} else if (isFetching) {
if (isFetching) {
content = <Loader2Icon className="mx-auto animate-spin" />
} else if (loadError) {
content = <>
<span className='text-destructive-inline'>Error: {loadError}.</span><br />
<button className='underline' onClick={() => retryFetch()}>Try again?</button>
</>
} else if (hasReachedEnd) {
content = <span className='font-bold text-muted-foreground'>
{playersCount ? 'You have reached the end of the list.' : 'No players found.'}
</span>
} else {
content = <span>
You've found the end of the rainbow, but there's no pot of gold here. <br />
Expand All @@ -75,7 +82,7 @@ function LastRow({ playersCount, hasReachedEnd, isFetching, loadError, retryFetc

return (
<TableRow>
<TableCell colSpan={5} className='px-4 py-2 text-center'>
<TableCell colSpan={4} className='px-4 py-2 text-center'>
{content}
</TableCell>
</TableRow>
Expand All @@ -91,9 +98,10 @@ type SortableTableHeaderProps = {
sortKey: 'playTime' | 'tsJoined' | 'tsLastConnection';
sortingState: PlayersTableSortingType;
setSorting: (newState: PlayersTableSortingType) => void;
className?: string;
}

function SortableTableHeader({ label, sortKey, sortingState, setSorting }: SortableTableHeaderProps) {
function SortableTableHeader({ label, sortKey, sortingState, setSorting, className }: SortableTableHeaderProps) {
const isSorted = sortingState.key === sortKey;
const isDesc = sortingState.desc;
const sortIcon = isSorted ? (isDesc ? '▼' : '▲') : <></>;
Expand All @@ -108,8 +116,9 @@ function SortableTableHeader({ label, sortKey, sortingState, setSorting }: Sorta
<th
onClick={onClick}
className={cn(
'py-2 px-4 text-left font-light tracking-wider cursor-pointer hover:font-medium hover:dark:bg-zinc-600',
'py-2 px-4 text-left font-light tracking-wider cursor-pointer hover:bg-zinc-300 hover:dark:bg-zinc-600',
isSorted && 'font-medium dark:bg-zinc-700',
className,
)}
>
{label}
Expand Down Expand Up @@ -231,12 +240,7 @@ export default function PlayersTable({ search, filters }: PlayersTableProps) {
}
}, [players, virtualItems, hasReachedEnd, isFetching]);

//fetch the first page automatically
useEffect(() => {
if (!players.length) fetchNextPage(true);
}, []);

//on sorting change, reset the list
//on state change, reset the list
useEffect(() => {
rowVirtualizer.scrollToIndex(0);
fetchNextPage(true);
Expand All @@ -253,14 +257,11 @@ export default function PlayersTable({ search, filters }: PlayersTableProps) {
style={{ color: createRandomHslColor() }}
>{JSON.stringify({ search, filters, sorting })}</div> */}
<ScrollArea className="h-full" ref={scrollRef}>
<table className='w-full caption-bottom text-sm table-fixed select-none'>
<table className='w-full caption-bottom text-sm select-none'>
<TableHeader>
<tr className='sticky top-0 z-10 bg-zinc-200 dark:bg-muted text-secondary-foreground text-base shadow-md transition-colors'>
<th className='w-[50%]x py-2 px-4 font-light tracking-wider text-left text-muted-foreground'>
Display Name
</th>
<th className='py-2 px-4 font-light tracking-wider text-left text-muted-foreground'>
Status
Display Name
</th>
<SortableTableHeader
label='Play Time'
Expand All @@ -282,7 +283,7 @@ export default function PlayersTable({ search, filters }: PlayersTableProps) {
/>
</tr>
</TableHeader>
<TableBody className={cn(isResetting && 'opacity-25')}>
<TableBody className={cn('whitespace-nowrap', isResetting && 'opacity-25')}>
{TopRowPad}
{virtualItems.map((virtualItem) => {
const isLastRow = virtualItem.index > players.length - 1;
Expand Down

0 comments on commit 2e913a6

Please sign in to comment.