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 его нет, + // и скринридер там его просто не прочтёт. +
) }