Enhance hero selection functionality in LiveSeriesPage by adding search and role filter options. Update App.test.tsx to include tests for new search and tab interaction features. Improve translations for hero search-related strings and adjust CSS for new UI elements. This update aims to improve user experience during hero bans in live matches.
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit is contained in:
2026-07-19 03:26:13 +03:00
parent 5d3e66d2f1
commit b7a78b4384
4 changed files with 19 additions and 4 deletions

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState, type DragEvent, type FormEvent, type ReactNode } from 'react'
import {
CalendarDays, Check, ChevronLeft, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut,
Menu, Radio, RefreshCw, Shield, Sparkles, Swords, Trophy, UserRound,
Menu, Radio, RefreshCw, Search, Shield, Sparkles, Swords, Trophy, UserRound,
UsersRound, X, Zap,
} from 'lucide-react'
import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@@ -667,6 +667,8 @@ function LiveSeriesPage() {
const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
const [selected, setSelected] = useState<string>()
const [outcome, setOutcome] = useState<MapOutcome>('TeamAWin')
const [heroRoleFilter, setHeroRoleFilter] = useState<'all' | PlayerRole>('all')
const [heroSearch, setHeroSearch] = useState('')
const participant = Boolean(series.data && session.data && [series.data.teamAlpha, series.data.teamBeta].some((team) => team.members.some(({ player }) => player.id === session.data?.player.id)))
useEffect(() => {
if (series.data && session.data && !participant && !isStaff(session.data.account.role)) {
@@ -712,6 +714,10 @@ function LiveSeriesPage() {
const activeTeam = activeSide === 'alpha' ? data.teamAlpha : data.teamBeta
const canAct = isStaff(session.data.account.role) || activeTeam.captainId === session.data.player.id
const canRecord = isStaff(session.data.account.role) || data.teamAlpha.captainId === session.data.player.id || data.teamBeta.captainId === session.data.player.id
const normalizedHeroSearch = heroSearch.trim().toLocaleLowerCase()
const visibleOptions = data.currentStep.kind === 'hero_ban'
? data.options.filter((option) => (heroRoleFilter === 'all' || option.role === heroRoleFilter) && option.name.toLocaleLowerCase().includes(normalizedHeroSearch))
: data.options
return <div className="page live-page">
<PageHeader eyebrow={`${data.roundLabel} · ${data.status}`} title="Series control" description={canAct || canRecord ? 'Actions are validated and recorded by the server.' : 'You are playing in this match. Captain actions are read-only for team members.'} actions={<Link className="button secondary" to="/watch/$seriesId" params={{ seriesId: data.id }}><Radio />Spectator view</Link>} />
<Scoreboard series={data} />
@@ -720,7 +726,7 @@ function LiveSeriesPage() {
<div className="turn-header"><div><span className="eyebrow">Current step</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p></div>{data.currentStep.activeTeam && <div className="turn-team"><span>Acting team</span><strong>{activeTeam.name}</strong></div>}</div>
{data.currentStep.kind === 'result' ? <form className="result-form" onSubmit={(event) => { event.preventDefault(); if (canRecord) result.mutate() }}><label>Outcome<select disabled={!canRecord} value={outcome} onChange={(event) => setOutcome(event.target.value as MapOutcome)}><option value="TeamAWin">{data.teamAlpha.name} wins</option><option value="TeamBWin">{data.teamBeta.name} wins</option><option value="Draw">Draw</option></select></label><button className="button team-action" disabled={!canRecord || result.isPending}>{canRecord ? result.isPending ? 'Recording…' : 'Record result' : 'Captain action'}</button>{result.isError && <p className="error-note">{result.error.message}</p>}</form>
: data.currentStep.kind === 'complete' ? <div className="turn-footer"><p><Check />Series completed and bracket updated.</p></div>
: <><div className="draft-options" role="radiogroup" aria-label="Available draft choices">{data.options.map((option) => <button role="radio" aria-checked={selected === option.id} key={option.id} disabled={option.disabled || !canAct} className={selected === option.id ? 'selected' : ''} onClick={() => setSelected(option.id)}><span className={`role-chip ${option.role}`}>{option.role?.slice(0, 1).toUpperCase()}</span><strong>{option.name}</strong>{option.disabled ? <small>{option.disabledReason}</small> : <Check />}</button>)}</div><div className="turn-footer"><p><Shield />{canAct ? 'Draft commands use server-side validation.' : `Waiting for ${activeTeam.name} captain.`}</p><button className="button team-action" disabled={!canAct || (data.currentStep.kind !== 'coin_toss' && !selected) || action.isPending} onClick={() => action.mutate()}>{canAct ? action.isPending ? 'Confirming…' : data.currentStep.kind === 'coin_toss' ? 'Toss coin' : 'Confirm action' : 'Captain action'} <ChevronRight /></button></div>{action.isError && <p className="error-note">{action.error.message}</p>}</>}
: <>{data.currentStep.kind === 'hero_ban' && <div className="hero-filter-tools"><label className="hero-search"><Search /><input type="search" value={heroSearch} placeholder="Search hero…" aria-label="Search hero" onChange={(event) => { setHeroSearch(event.target.value); setSelected(undefined) }} /></label><div className="hero-role-filter" role="tablist" aria-label="Filter heroes by role">{([['all', 'All'], ['tank', 'Tank'], ['damage', 'DPS'], ['support', 'Support']] as const).map(([role, label]) => <button type="button" role="tab" aria-selected={heroRoleFilter === role} className={heroRoleFilter === role ? 'active' : ''} key={role} onClick={() => { setHeroRoleFilter(role); setSelected(undefined) }}>{label}<small>{role === 'all' ? data.options.length : data.options.filter((option) => option.role === role).length}</small></button>)}</div></div>}<div className="draft-options" role="radiogroup" aria-label="Available draft choices">{visibleOptions.length === 0 ? <p className="empty-note">No heroes found.</p> : visibleOptions.map((option) => <button role="radio" aria-checked={selected === option.id} key={option.id} disabled={option.disabled || !canAct} className={selected === option.id ? 'selected' : ''} onClick={() => setSelected(option.id)}><span className={`role-chip ${option.role}`}>{option.role?.slice(0, 1).toUpperCase()}</span><strong>{option.name}</strong>{option.disabled ? <small>{option.disabledReason}</small> : <Check />}</button>)}</div><div className="turn-footer"><p><Shield />{canAct ? 'Draft commands use server-side validation.' : `Waiting for ${activeTeam.name} captain.`}</p><button className="button team-action" disabled={!canAct || (data.currentStep.kind !== 'coin_toss' && !selected) || action.isPending} onClick={() => action.mutate()}>{canAct ? action.isPending ? 'Confirming…' : data.currentStep.kind === 'coin_toss' ? 'Toss coin' : 'Confirm action' : 'Captain action'} <ChevronRight /></button></div>{action.isError && <p className="error-note">{action.error.message}</p>}</>}
</section>
<aside className="match-sidebar"><HeroBanDisplay series={data} /><SeriesBanHistory series={data} /><MapTimeline series={data} /><AuditLog series={data} /></aside>
</div>