-
Notifications
You must be signed in to change notification settings - Fork 531
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
wip(web/players): separated and optimized components
- Loading branch information
Showing
9 changed files
with
763 additions
and
473 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
Oops, something went wrong.