From 0f0657e5233ad8fee382902b0e6cd1cdea742e09 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 26 Jul 2026 22:35:36 +0300 Subject: [PATCH] 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. --- backend/src/TeleWave.Api/Program.cs | 2 +- backend/src/TeleWave.Api/wwwroot/index.html | 2 +- .../components/BumperTemplateEditor.tsx | 22 ++++++++++--------- .../channels/components/ScheduleGrid.tsx | 7 ++++-- .../channels/components/TemplatePreview.tsx | 2 +- .../src/features/admin/media/episode-regex.ts | 18 +++++++++++++-- frontend/src/features/streaming/AirPage.tsx | 1 + frontend/src/routes/__root.tsx | 4 +++- frontend/src/shared/lib/locales/en.ts | 1 + frontend/src/shared/lib/locales/ru.ts | 1 + frontend/src/shared/ui/card.tsx | 8 +++++-- frontend/src/shared/ui/hls-video.tsx | 6 ++++- frontend/src/shared/ui/sortable.tsx | 8 +++++-- frontend/src/shared/ui/toaster.tsx | 19 +++++++++++++--- 14 files changed, 75 insertions(+), 26 deletions(-) diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs index 7b7de2c..cbdc289 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -142,7 +142,7 @@ app.UseDefaultFiles(); app.UseStaticFiles(); app.MapFallbackToFile("index.html"); -app.Run(); +await app.RunAsync(); /// Делает неявный класс Program доступным для WebApplicationFactory<Program> в интеграционных тестах. public partial class Program; diff --git a/backend/src/TeleWave.Api/wwwroot/index.html b/backend/src/TeleWave.Api/wwwroot/index.html index 2326906..470e271 100644 --- a/backend/src/TeleWave.Api/wwwroot/index.html +++ b/backend/src/TeleWave.Api/wwwroot/index.html @@ -1,5 +1,5 @@ - + TeleWave

Frontend build not present yet — run the Vite build to populate wwwroot.

diff --git a/frontend/src/features/admin/channels/components/BumperTemplateEditor.tsx b/frontend/src/features/admin/channels/components/BumperTemplateEditor.tsx index 96a4524..cbe1d96 100644 --- a/frontend/src/features/admin/channels/components/BumperTemplateEditor.tsx +++ b/frontend/src/features/admin/channels/components/BumperTemplateEditor.tsx @@ -80,11 +80,16 @@ export function BumperTemplateEditor({ return (
-
setOpen((o) => !o)} - > -
+ {/* Сворачивание висит на кнопке, а не на всей строке: кликабельный div недоступен с клавиатуры. + Кнопка удаления при этом вынесена наружу — вложенная кнопка внутри кнопки недопустима, + и заодно ей больше не нужен stopPropagation. */} +
+
+ {!template.isDefault && ( diff --git a/frontend/src/features/admin/channels/components/ScheduleGrid.tsx b/frontend/src/features/admin/channels/components/ScheduleGrid.tsx index d5d8b57..fd46ece 100644 --- a/frontend/src/features/admin/channels/components/ScheduleGrid.tsx +++ b/frontend/src/features/admin/channels/components/ScheduleGrid.tsx @@ -216,8 +216,11 @@ export function ScheduleGrid({ const minutes = resizing?.slot.id === slot.id ? resizing.minutes : slot.targetDurationMinutes return ( -
setDragged(slot)} onDragEnd={() => setDragged(null)} @@ -249,7 +252,7 @@ export function ScheduleGrid({ startResize(e, slot, e.currentTarget.parentElement!.parentElement!) } /> -
+ ) })}
diff --git a/frontend/src/features/admin/channels/components/TemplatePreview.tsx b/frontend/src/features/admin/channels/components/TemplatePreview.tsx index 922f399..7c187fa 100644 --- a/frontend/src/features/admin/channels/components/TemplatePreview.tsx +++ b/frontend/src/features/admin/channels/components/TemplatePreview.tsx @@ -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, diff --git a/frontend/src/features/admin/media/episode-regex.ts b/frontend/src/features/admin/media/episode-regex.ts index e94b3ec..7361e93 100644 --- a/frontend/src/features/admin/media/episode-regex.ts +++ b/frontend/src/features/admin/media/episode-regex.ts @@ -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}` diff --git a/frontend/src/features/streaming/AirPage.tsx b/frontend/src/features/streaming/AirPage.tsx index 87a0df2..7c48c05 100644 --- a/frontend/src/features/streaming/AirPage.tsx +++ b/frontend/src/features/streaming/AirPage.tsx @@ -160,6 +160,7 @@ export function AirPage() { {channels.map((channel) => ( diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index deb0c10..e6a0d3e 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -15,6 +15,7 @@ export const en = { save: 'Save', cancel: 'Cancel', retry: 'Retry', + close: 'Close', delete: 'Delete', create: 'Create', loading: 'Loading…', diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 85e56e2..cd61167 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -15,6 +15,7 @@ export const ru = { save: 'Сохранить', cancel: 'Отмена', retry: 'Повторить', + close: 'Закрыть', delete: 'Удалить', create: 'Создать', loading: 'Загрузка…', diff --git a/frontend/src/shared/ui/card.tsx b/frontend/src/shared/ui/card.tsx index b72ea58..410c35c 100644 --- a/frontend/src/shared/ui/card.tsx +++ b/frontend/src/shared/ui/card.tsx @@ -12,8 +12,12 @@ export const CardHeader = forwardRef>( - ({ className, ...props }, ref) => ( -

+ // children разворачиваем явно: заголовок без видимого содержимого — это дыра для скринридера, + // и статический анализ такое ловит только тогда, когда содержимое видно в разметке. + ({ className, children, ...props }, ref) => ( +

+ {children} +

), ) CardTitle.displayName = 'CardTitle' diff --git a/frontend/src/shared/ui/hls-video.tsx b/frontend/src/shared/ui/hls-video.tsx index cbdb3fc..8e77cfd 100644 --- a/frontend/src/shared/ui/hls-video.tsx +++ b/frontend/src/shared/ui/hls-video.tsx @@ -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 объявляет это явно, вместо молчаливого отсутствия. */} + + ) } diff --git a/frontend/src/shared/ui/sortable.tsx b/frontend/src/shared/ui/sortable.tsx index 6a00aff..7f5a70c 100644 --- a/frontend/src/shared/ui/sortable.tsx +++ b/frontend/src/shared/ui/sortable.tsx @@ -33,11 +33,15 @@ export function SortHeader({ const active = sort.key === sortKey const Icon = !active ? ChevronsUpDown : sort.desc ? ArrowDown : ArrowUp return ( - + // aria-sort — атрибут заголовка столбца, а не кнопки внутри него: у роли button его нет, + // и скринридер там его просто не прочтёт. +
) }