Skip to content

Commit

Permalink
wip(web/players): separated and optimized components
Browse files Browse the repository at this point in the history
  • Loading branch information
tabarra committed Mar 16, 2024
1 parent 8d1f44f commit 3fe32a1
Show file tree
Hide file tree
Showing 9 changed files with 763 additions and 473 deletions.
9 changes: 9 additions & 0 deletions docs/dev_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ seems like it just refreshes the page
- fix disallowed intents message

- rtl issue
- [ ] `2xl:mx-8` for all pages? (change on MainShell)
- [ ] fix all imgur links
- [ ] build: generate fxmanifest files list dynamically
- [ ] easter egg with some old music? https://www.youtube.com/watch?v=nNoaXej0Jeg
Expand Down Expand Up @@ -59,6 +60,14 @@ Players:
- prune players (from master actions -> clean database)
- bulk remove HWIDs

Don't forget:
- [ ] before search, parse the ids to `xxx` -> `type:xxx`, except if array
- [ ] code button to wipe the filter
- [ ] code the hotkey
- [ ] search box state in url
- [ ] Write `estimateSize` function to calculate size dynamically?


History:
- list of warns/bans in a table
- search by id OR identifiers or reason
Expand Down
10 changes: 9 additions & 1 deletion panel/src/layout/MainRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Iframe from "@/pages/Iframe";
import NotFound from "@/pages/NotFound";
import TestingPage from "@/pages/TestingPage/TestingPage";
import LiveConsole from "@/pages/LiveConsole/LiveConsole";
import PlayersPage from "@/pages/Players/PlayersPage";


type RouteType = {
Expand All @@ -20,10 +21,16 @@ type RouteType = {
const allRoutes: RouteType[] = [
//Global Routes
{
path: '/players',
//FIXME: deprecate
path: '/players/old',
title: 'Players',
children: <Iframe legacyUrl="players" />
},
{
path: '/players',
title: 'Players',
children: <PlayersPage />
},
// {
// path: '/history',
// title: 'History',
Expand Down Expand Up @@ -87,6 +94,7 @@ const allRoutes: RouteType[] = [
children: <LiveConsole />
},
{
//FIXME: deprecate
path: '/server/console/old',
title: 'Old Live Console',
children: <Iframe legacyUrl="console" />
Expand Down
85 changes: 85 additions & 0 deletions panel/src/pages/Players/PlayersPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { createRandomHslColor } from '@/lib/utils';
import { UsersIcon, UserRoundPlusIcon, CalendarPlusIcon } from 'lucide-react';
import PageCalloutRow, { PageCalloutProps } from '@/components/PageCalloutRow';
import { PlayerSearchBox, PlayersSearchBoxReturnStateType } from './PlayersSearchBox';
import PlayersTable from './PlayersTable';
import { PlayersTableFiltersType, PlayersTableSearchType } from '@shared/playerApiTypes';




const callouts: PageCalloutProps[] = [
{
label: 'Total Players',
value: 123456,
icon: <UsersIcon />,
prefix: ''
},
{
label: 'Players Today',
value: 1234,
icon: <CalendarPlusIcon />,
prefix: ''
},
{
label: 'New Players Today',
value: 1234,
icon: <UserRoundPlusIcon />,
prefix: '+'
},
{
label: 'New Players This Week',
value: 12345,
icon: <UserRoundPlusIcon />,
prefix: '+'
}
]


const PlayerSearchBoxMemo = memo(PlayerSearchBox);
const PlayersTableMemo = memo(PlayersTable);
const PageCalloutRowMemo = memo(PageCalloutRow);


export default function PlayersPage() {
const [searchBoxReturn, setSearchBoxReturn] = useState<PlayersSearchBoxReturnStateType>({
search: null,
filters: [],
});

//PlayerSearchBox handlers
const doSearch = useCallback((search: PlayersTableSearchType, filters: PlayersTableFiltersType) => {
setSearchBoxReturn({ search, filters });
}, []);
const initialState = useMemo(() => {
return {
search: null,
filters: [],
} satisfies PlayersSearchBoxReturnStateType;
}, []);


return (<div
className='flex flex-col min-w-96'
style={{ height: 'calc(100vh - 3.5rem - 1px - 2rem)' }}
>
{/* <div
//DEBUG component state
className='w-full bg-black p-2'
style={{ color: createRandomHslColor() }}
>{JSON.stringify(searchBoxReturn)}</div> */}

<PageCalloutRowMemo
callouts={callouts}
/>
<PlayerSearchBoxMemo
doSearch={doSearch}
initialState={initialState}
/>
<PlayersTableMemo
search={searchBoxReturn.search}
filters={searchBoxReturn.filters}
/>
</div>);
}
246 changes: 246 additions & 0 deletions panel/src/pages/Players/PlayersSearchBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import { throttle } from "throttle-debounce";
import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { createRandomHslColor } from '@/lib/utils';
import { ChevronsUpDownIcon, FilterXIcon, XIcon, ChevronDownIcon } from 'lucide-react';
import { Input } from '@/components/ui/input';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import InlineCode from '@/components/InlineCode';
import { PlayersTableFiltersType, PlayersTableSearchType } from "@shared/playerApiTypes";


/**
* Helpers
*/
const availableSearchTypes = [
{
value: 'playerName',
label: 'Name',
placeholder: 'Enter a player name',
description: 'Search players by their last display name.'
},
{
value: 'playerNotes',
label: 'Notes',
placeholder: 'Enter part of the note to search for',
description: 'Search players by their profile notes contents.'
},
{
value: 'playerIds',
label: 'Identifiers',
placeholder: 'License, Discord, Steam, etc.',
description: 'Search players by their IDs separated by a comma.'
},
] as const;

const availableFilters = [
{ label: 'Is Admin', value: 'isAdmin' },
{ label: 'Is Online', value: 'isOnline' },
{ label: 'Is Banned', value: 'isBanned' },
{ label: 'Has Previous Ban', value: 'hasPreviousBan' },
{ label: 'Has Whitelisted ID', value: 'isWhitelisted' },
{ label: 'Has Profile Notes', value: 'hasNote' },
] as const;

//FIXME: this doesn't require exporting, but HMR doesn't work without it
// eslint-disable-next-line @typescript-eslint/no-explicit-any, react-refresh/only-export-components
export const throttleFunc = throttle(1250, (func: any) => {
func();
}, { noLeading: true });


export type PlayersSearchBoxReturnStateType = {
search: PlayersTableSearchType;
filters: PlayersTableFiltersType;
}

/**
* Component
*/
type PlayerSearchBoxProps = {
doSearch: (search: PlayersTableSearchType, filters: PlayersTableFiltersType) => void;
initialState: PlayersSearchBoxReturnStateType;
};

export function PlayerSearchBox({ doSearch, initialState }: PlayerSearchBoxProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [isSearchTypeDropdownOpen, setSearchTypeDropdownOpen] = useState(false);
const [isFilterDropdownOpen, setFilterDropdownOpen] = useState(false);
const [currSearchType, setCurrSearchType] = useState<string>(initialState.search?.type || 'playerName');
const [selectedFilters, setSelectedFilters] = useState<string[]>(initialState.filters);
const [hasSearchText, setHasSearchText] = useState(!!initialState.search?.value);

const updateSearch = () => {
if (!inputRef.current) return;
const searchValue = inputRef.current.value.trim();
if(searchValue.length){
doSearch({value: searchValue, type: currSearchType}, selectedFilters);
} else {
doSearch(null, selectedFilters);
}
}

//Call onSearch when params change
useEffect(() => {
updateSearch();
}, [inputRef, currSearchType, selectedFilters]);

//Input handlers
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
throttleFunc.cancel({ upcomingOnly: true });
updateSearch();
} else if (e.key === 'Escape') {
inputRef.current!.value = '';
throttleFunc(updateSearch);
setHasSearchText(false);
} else {
throttleFunc(updateSearch);
setHasSearchText(true);
}
};

const clearSearchBtn = () => {
inputRef.current!.value = '';
throttleFunc.cancel({ upcomingOnly: true });
updateSearch();
setHasSearchText(false);
};

const filterSelectChange = (filter: string, checked: boolean) => {
if (checked) {
setSelectedFilters((prev) => [...prev, filter]);
} else {
setSelectedFilters((prev) => prev.filter((f) => f !== filter));
}
}

const selectedSearchType = availableSearchTypes.find((type) => type.value === currSearchType);
if (!selectedSearchType) throw new Error(`Invalid search type: ${currSearchType}`);
const filterBtnMessage = selectedFilters.length ? `${selectedFilters.length} Filters` : 'No filters';
return (
<div className="p-4 mb-2 md:mb-4 md:rounded-xl border border-border bg-card text-card-foreground shadow-sm">
<div className="flex flex-wrap-reverse gap-2">
<div className='relative min-w-44 grow'>
<Input
type="text"
autoFocus
autoCapitalize='off'
autoCorrect='off'
ref={inputRef}
placeholder={selectedSearchType.placeholder}
onKeyDown={handleKeyDown}
/>
{hasSearchText ? (
<button
className="absolute right-2 inset-y-0 text-zinc-500 dark:text-zinc-400 ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-lg"
onClick={clearSearchBtn}
>
<XIcon />
</button>
) : (
<div className="absolute right-2 inset-y-0 flex items-center text-zinc-500 dark:text-zinc-400 select-none pointer-events-none">
<InlineCode className="text-xs tracking-wide">ctrl+f</InlineCode>
</div>
)}
</div>

<div className="grow flex justify-between">
<div className='space-x-2 flex-nowrap'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isSearchTypeDropdownOpen}
onClick={() => setSearchTypeDropdownOpen(!isSearchTypeDropdownOpen)}
className="grow xs:w-36 justify-between border-input bg-black/5 dark:bg-black/30 hover:dark:bg-primary"
>
{selectedSearchType.label}
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className='w-36'>
<DropdownMenuLabel>Search Type</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup value={currSearchType} onValueChange={setCurrSearchType}>
{availableSearchTypes.map((searchType) => (
<DropdownMenuRadioItem
key={searchType.value}
value={searchType.value}
className='cursor-pointer'
>
{searchType.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>

<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isFilterDropdownOpen}
onClick={() => setFilterDropdownOpen(!isFilterDropdownOpen)}
className="grow xs:w-44 justify-between border-input bg-black/5 dark:bg-black/30 hover:dark:bg-primary"
>
{filterBtnMessage}
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className='w-44'>
<DropdownMenuLabel>Search Filters</DropdownMenuLabel>
<DropdownMenuSeparator />
{availableFilters.map((filter) => (
<DropdownMenuCheckboxItem
key={filter.value}
checked={selectedFilters.includes(filter.value)}
className="cursor-pointer"
onCheckedChange={(checked) => {
filterSelectChange(filter.value, checked);
}}

>
{filter.label}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onClick={() => setSelectedFilters([])}
>
<FilterXIcon className="mr-2 h-4 w-4" />
Clear Filters
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>

<div>
<Button
variant="outline"
className="flex-grow"
>
More
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</div>
</div>
</div>
<div className="text-xs text-muted-foreground mt-1 px-1">
{selectedSearchType.description}
</div>
</div>
)
}

0 comments on commit 3fe32a1

Please sign in to comment.