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,4 +1,4 @@
import { cleanup, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import App from './App'
import { demoSeries } from './api/demo'
@@ -48,6 +48,10 @@ describe('Mixmaker frontend', () => {
render(<App />)
expect(await screen.findByRole('heading', { name: 'Series control' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /spectator view/i })).toHaveAttribute('href', '/watch/series-1')
fireEvent.click(screen.getByRole('tab', { name: /DPS/i }))
fireEvent.change(screen.getByRole('searchbox', { name: /search hero/i }), { target: { value: 'tRaC' } })
expect(screen.getByRole('radio', { name: /Tracer/i })).toBeInTheDocument()
expect(screen.queryByRole('radio', { name: /Ana/i })).not.toBeInTheDocument()
})
it('does not reuse completed-map hero bans during map pick', () => {

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>

View File

@@ -92,6 +92,7 @@ const translations: Record<string, string> = {
'Select your current Overwatch rank for each role.': 'Выберите текущий ранг Overwatch для каждой роли.',
'Tank': 'Танк',
'Damage': 'Урон',
'DPS': 'Урон',
'Support': 'Поддержка',
'Space, pressure, and frontline': 'Пространство, давление и передовая',
'Eliminations and map control': 'Устранения и контроль карты',
@@ -179,6 +180,9 @@ const translations: Record<string, string> = {
'Previous live match': 'Предыдущий активный матч',
'Next live match': 'Следующий активный матч',
'Hero bans': 'Баны героев',
'Search hero…': 'Найти героя…',
'Search hero': 'Поиск героя',
'No heroes found.': 'Герои не найдены.',
'Bo3 hero ban history': 'История банов героев Bo3',
'A team cannot repeat its own hero ban.': 'Команда не может повторять собственный бан героя.',
'No bans yet': 'Банов пока нет',

View File

@@ -262,6 +262,7 @@ textarea { min-height: 85px; resize: vertical; }
.turn-header { padding: 26px; display: flex; align-items: start; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--border); }.turn-header h2 { font-size: 27px; margin: 7px 0; }.turn-header p { color: var(--muted); font-size: 12px; }
.turn-team { text-align: right; display: flex; flex-direction: column; gap: 5px; }.turn-team span { color: var(--muted); font-size: 9px; text-transform: uppercase; }.turn-team strong { color: var(--team); font-size: 12px; }
.draft-options { padding: 22px; display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; }
.hero-filter-tools { display: grid; gap: 9px; padding: 18px 22px 0; }.hero-search { height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 11px; border: 1px solid var(--border); border-radius: 7px; background: #0d1014; }.hero-search:focus-within { border-color: var(--orange); }.hero-search svg { width: 16px; color: var(--muted); }.hero-search input { width: 100%; padding: 0; border: 0; outline: 0; background: transparent; }.hero-role-filter { display: flex; gap: 6px; overflow-x: auto; }.hero-role-filter button { min-width: 82px; padding: 8px 11px; display: flex; align-items: center; justify-content: space-between; gap: 9px; border: 1px solid var(--border); border-radius: 6px; background: #11151a; color: var(--muted); cursor: pointer; }.hero-role-filter button.active { border-color: var(--orange); background: var(--orange-soft); color: var(--text); }.hero-role-filter small { min-width: 18px; padding: 2px 4px; border-radius: 8px; background: rgba(255,255,255,.06); font-size: 8px; text-align: center; }
.draft-options button { min-height: 74px; padding: 12px; display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 8px; text-align: left; border: 1px solid var(--border); border-radius: 7px; background: #12161b; cursor: pointer; }
.draft-options button > svg { color: transparent; }.draft-options button.selected { border-color: var(--team); background: var(--team-bg); }.draft-options button.selected > svg { color: var(--team); }
.draft-options button:disabled { opacity: .45; cursor: not-allowed; }.draft-options button small { grid-column: 2/-1; color: #e49765; font-size: 8px; }
@@ -317,7 +318,7 @@ textarea { min-height: 85px; resize: vertical; }
.workflow-action { align-items: stretch; flex-direction: column; }.workflow-action .button { width: 100%; }.roster-editor-body { grid-template-columns: 1fr; }.roster-slot { grid-template-columns: 24px 1fr auto 30px; }
.scoreboard { min-height: 120px; grid-template-columns: 1fr 90px 1fr; }.score-team { padding: 15px 10px; }.score-team h2 { font-size: 13px; }.score-team small { display: none; }.series-score strong { font-size: 30px; }.series-score .badge { display: none; }
.team-name-controls { grid-template-columns: 1fr; }.team-name-editor { align-items: stretch; flex-direction: column; }
.turn-header { padding: 20px; }.turn-header h2 { font-size: 23px; }.draft-options { grid-template-columns: repeat(2,1fr); padding: 14px; }.turn-footer { align-items: stretch; flex-direction: column; }.turn-footer .button { width: 100%; }
.turn-header { padding: 20px; }.turn-header h2 { font-size: 23px; }.hero-filter-tools { padding: 14px 14px 0; }.draft-options { grid-template-columns: repeat(2,1fr); padding: 14px; }.turn-footer { align-items: stretch; flex-direction: column; }.turn-footer .button { width: 100%; }
.result-form { grid-template-columns: 1fr; }
.ban-display { grid-template-columns: repeat(2,1fr); }.spectator-focus { padding: 24px 14px; }.spectator-grid { grid-template-columns: 1fr; }
.bracket-scroll { padding: 25px; gap: 70px; }.bracket-match::after { width: 70px; right: -71px; }