Refactor Program.cs to use async Run method for improved performance. Update index.html to specify language attribute for accessibility. Enhance BumperTemplateEditor and ScheduleGrid components by replacing divs with buttons for better keyboard accessibility. Improve TemplatePreview sorting logic and optimize episode regex handling. Add close button to ToastItem for better user interaction. Update localization files to include 'close' translations. Refactor CardTitle to ensure screen reader compatibility by explicitly rendering children.
This commit is contained in:
@@ -80,11 +80,16 @@ export function BumperTemplateEditor({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-md border border-border bg-muted/30 p-4">
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-between gap-2"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Сворачивание висит на кнопке, а не на всей строке: кликабельный div недоступен с клавиатуры.
|
||||
Кнопка удаления при этом вынесена наружу — вложенная кнопка внутри кнопки недопустима,
|
||||
и заодно ей больше не нужен stopPropagation. */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="flex flex-1 cursor-pointer flex-wrap items-center gap-2 text-left"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
@@ -95,16 +100,13 @@ export function BumperTemplateEditor({
|
||||
? `≈${Math.round(template.audioDurationSeconds)} ${t('admin.channels.bumperSeconds')}`
|
||||
: t('admin.channels.bumperDefaultDuration')}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{!template.isDefault && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={remove.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
remove.mutate()
|
||||
}}
|
||||
onClick={() => remove.mutate()}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
|
||||
@@ -216,8 +216,11 @@ export function ScheduleGrid({
|
||||
const minutes =
|
||||
resizing?.slot.id === slot.id ? resizing.minutes : slot.targetDurationMinutes
|
||||
return (
|
||||
<div
|
||||
// Кнопка, а не div: слот выбирается кликом, и с клавиатуры это должно работать
|
||||
// тоже. Перетаскивание на кнопке сохраняется — draggable к роли не привязан.
|
||||
<button
|
||||
key={`${slot.id}-${weekday}`}
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={() => setDragged(slot)}
|
||||
onDragEnd={() => setDragged(null)}
|
||||
@@ -249,7 +252,7 @@ export function ScheduleGrid({
|
||||
startResize(e, slot, e.currentTarget.parentElement!.parentElement!)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -263,7 +263,7 @@ function RepeatHeatmap({ preview }: { preview: SchedulePreviewDto }) {
|
||||
counts.set(item.title, row)
|
||||
}
|
||||
|
||||
const sortedDays = [...dayKeys].sort()
|
||||
const sortedDays = [...dayKeys].sort((a, b) => a.localeCompare(b))
|
||||
const sortedRows = [...counts.entries()]
|
||||
.map(([title, byDay]) => ({
|
||||
title,
|
||||
|
||||
@@ -18,6 +18,20 @@ export function findNumbers(fileName: string): { index: number; start: number; t
|
||||
|
||||
const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&')
|
||||
|
||||
const isLetter = (char: string) => /\p{L}/u.test(char)
|
||||
const isLetterOrDigit = (char: string) => /[\p{L}\p{N}]/u.test(char)
|
||||
|
||||
/**
|
||||
* Хвост строки из символов, удовлетворяющих условию. Обходим с конца вручную, а не шаблоном вида
|
||||
* `X*$`: такой шаблон движок примеряет с каждой позиции строки и получает квадратичное время
|
||||
* (см. предупреждение анализатора о backtracking), тогда как здесь один линейный проход.
|
||||
*/
|
||||
function trailingRun(value: string, matches: (char: string) => boolean): string {
|
||||
let start = value.length
|
||||
while (start > 0 && matches(value[start - 1])) start--
|
||||
return value.slice(start)
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит regex по указанному пользователем числу в имени файла. Якорем берётся слово перед числом
|
||||
* («Серия 01» → `Серия\s*(\d{1,3})`): позиция числа в разных файлах гуляет, а слово рядом — нет.
|
||||
@@ -36,11 +50,11 @@ export function buildEpisodeRegex(fileName: string, occurrenceIndex: number): st
|
||||
|
||||
// Разделители между якорем и числом описываем классом, а не буквально: в соседних файлах
|
||||
// там встречается то пробел, то точка, то подчёркивание.
|
||||
const gap = /[^\p{L}\p{N}]*$/u.exec(before)?.[0] ?? ''
|
||||
const gap = trailingRun(before, (char) => !isLetterOrDigit(char))
|
||||
const anchorSource = before.slice(0, before.length - gap.length)
|
||||
// Якорь — только буквы: захвати он цифры, «S01E07» дало бы правило `S01E(\d)`, прибитое
|
||||
// к первому сезону, и на «S02E05» оно бы уже не сработало.
|
||||
const anchor = /\p{L}+$/u.exec(anchorSource)?.[0]
|
||||
const anchor = trailingRun(anchorSource, isLetter)
|
||||
|
||||
if (anchor) return `${escapeRegex(anchor)}${gap ? '[\\s._-]*' : ''}${digits}`
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ export function AirPage() {
|
||||
{channels.map((channel) => (
|
||||
<button
|
||||
key={channel.id}
|
||||
type="button"
|
||||
onClick={() => setSelected(channel.slug)}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-2 rounded-sm border border-border px-3 py-2 text-left text-sm hover:bg-muted md:shrink',
|
||||
|
||||
@@ -88,6 +88,7 @@ function RootLayout() {
|
||||
{user.userName}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="hidden rounded-sm border border-border px-3 py-1 text-xs uppercase tracking-wide hover:bg-muted md:block"
|
||||
onClick={() => void handleLogout()}
|
||||
>
|
||||
@@ -104,6 +105,7 @@ function RootLayout() {
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-sm border border-border p-1.5 md:hidden"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
aria-label="Menu"
|
||||
@@ -133,7 +135,7 @@ function RootLayout() {
|
||||
>
|
||||
{user.userName}
|
||||
</Link>
|
||||
<button className="py-1 text-left" onClick={() => void handleLogout()}>
|
||||
<button type="button" className="py-1 text-left" onClick={() => void handleLogout()}>
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -15,6 +15,7 @@ export const en = {
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
retry: 'Retry',
|
||||
close: 'Close',
|
||||
delete: 'Delete',
|
||||
create: 'Create',
|
||||
loading: 'Loading…',
|
||||
|
||||
@@ -15,6 +15,7 @@ export const ru = {
|
||||
save: 'Сохранить',
|
||||
cancel: 'Отмена',
|
||||
retry: 'Повторить',
|
||||
close: 'Закрыть',
|
||||
delete: 'Удалить',
|
||||
create: 'Создать',
|
||||
loading: 'Загрузка…',
|
||||
|
||||
@@ -12,8 +12,12 @@ export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivEleme
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
export const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn('crt-glow text-xl font-semibold tracking-tight', className)} {...props} />
|
||||
// children разворачиваем явно: заголовок без видимого содержимого — это дыра для скринридера,
|
||||
// и статический анализ такое ловит только тогда, когда содержимое видно в разметке.
|
||||
({ className, children, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn('crt-glow text-xl font-semibold tracking-tight', className)} {...props}>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
@@ -37,6 +37,10 @@ export function HlsVideo({ src, className }: { src: string; className?: string }
|
||||
controls
|
||||
playsInline
|
||||
className={cn('aspect-video w-full rounded-md border border-border bg-black', className)}
|
||||
/>
|
||||
>
|
||||
{/* Субтитров пайплайн не производит: ffmpeg режет видео и звук, дорожек с текстом нет.
|
||||
Пустой track объявляет это явно, вместо молчаливого отсутствия. */}
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,11 +33,15 @@ export function SortHeader({
|
||||
const active = sort.key === sortKey
|
||||
const Icon = !active ? ChevronsUpDown : sort.desc ? ArrowDown : ArrowUp
|
||||
return (
|
||||
<th className={cn('px-4 py-2 font-medium', className)}>
|
||||
// aria-sort — атрибут заголовка столбца, а не кнопки внутри него: у роли button его нет,
|
||||
// и скринридер там его просто не прочтёт.
|
||||
<th
|
||||
className={cn('px-4 py-2 font-medium', className)}
|
||||
aria-sort={active ? (sort.desc ? 'descending' : 'ascending') : 'none'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(sortKey)}
|
||||
aria-sort={active ? (sort.desc ? 'descending' : 'ascending') : 'none'}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 hover:text-foreground',
|
||||
active && 'text-foreground',
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { useToastContext } from './toast-store'
|
||||
|
||||
@@ -25,22 +27,33 @@ function ToastItem({
|
||||
variant: 'default' | 'success' | 'error'
|
||||
onDismiss: (id: number) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => onDismiss(id), 4000)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [id, onDismiss])
|
||||
|
||||
// Живой регион остаётся обычным контейнером, а закрытие висит на настоящей кнопке: обработчик
|
||||
// клика на самом сообщении недоступен с клавиатуры, и скринридер о нём никак не сообщает.
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'crt-panel pointer-events-auto rounded-md px-4 py-3 text-sm shadow-lg',
|
||||
'crt-panel pointer-events-auto flex items-start gap-3 rounded-md px-4 py-3 text-sm shadow-lg',
|
||||
variant === 'success' && 'border-primary/60',
|
||||
variant === 'error' && 'border-red-700/60 text-red-400',
|
||||
)}
|
||||
onClick={() => onDismiss(id)}
|
||||
role="status"
|
||||
>
|
||||
{message}
|
||||
<span className="flex-1">{message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDismiss(id)}
|
||||
aria-label={t('common.close')}
|
||||
className="shrink-0 opacity-60 hover:opacity-100"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user