diff --git a/frontend/src/features/admin/interstitials/BlockBuilder.tsx b/frontend/src/features/admin/interstitials/BlockBuilder.tsx index 7724de0..a27adcc 100644 --- a/frontend/src/features/admin/interstitials/BlockBuilder.tsx +++ b/frontend/src/features/admin/interstitials/BlockBuilder.tsx @@ -47,7 +47,7 @@ export function BlockBuilder({ setOver(false) const item = readDragItem(event) // Блок из блоков собрать нельзя: коллекция хранит шоу, а не вложенные коллекции. - if (!item || item.kind !== 'clip') return + if (item?.kind !== 'clip') return setItems((current) => [...current, item]) } diff --git a/frontend/src/features/admin/media/MediaPanel.tsx b/frontend/src/features/admin/media/MediaPanel.tsx index 8da1dc6..8cd0e6f 100644 --- a/frontend/src/features/admin/media/MediaPanel.tsx +++ b/frontend/src/features/admin/media/MediaPanel.tsx @@ -241,7 +241,7 @@ export function MediaPanel() { onDelete={() => deleteMutation.mutate(asset.id)} /> ))} - {data && data.items.length === 0 && !isLoading && ( + {data?.items.length === 0 && !isLoading && ( {t('admin.media.empty')} diff --git a/frontend/src/features/admin/media/episode-parse.ts b/frontend/src/features/admin/media/episode-parse.ts index 713dbb7..6f0cd82 100644 --- a/frontend/src/features/admin/media/episode-parse.ts +++ b/frontend/src/features/admin/media/episode-parse.ts @@ -9,14 +9,14 @@ export type ParsedEpisode = { season: number | null; episode: number | null } /** Встроенные шаблоны: SxxEyy, NxNN, ведущий номер серии. */ function parseBuiltin(name: string): ParsedEpisode { - const se = name.match(/[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})/) + const se = /[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})/.exec(name) if (se) return { season: Number(se[1]), episode: Number(se[2]) } - const nx = name.match(/(?:^|[^\d])(\d{1,2})x(\d{1,3})(?:[^\d]|$)/i) + const nx = /(?:^|[^\d])(\d{1,2})x(\d{1,3})(?:[^\d]|$)/i.exec(name) if (nx) return { season: Number(nx[1]), episode: Number(nx[2]) } // Ведущий номер серии: «01. Название», «02 - Название», «03_Название», «4) Название». - const lead = name.match(/^\s*(\d{1,3})[\s._)\]-]/) + const lead = /^\s*(\d{1,3})[\s._)\]-]/.exec(name) return { season: null, episode: lead ? Number(lead[1]) : null } } @@ -26,9 +26,9 @@ function parseBuiltin(name: string): ParsedEpisode { * при одной группе означает «сезон не трогаем». */ function parseCustom(name: string, pattern: string): ParsedEpisode | null { - let match: RegExpMatchArray | null + let match: RegExpExecArray | null try { - match = name.match(new RegExp(pattern, 'i')) + match = new RegExp(pattern, 'i').exec(name) } catch { return null // невалидный regex — просто игнорируем } diff --git a/frontend/src/features/admin/media/episode-regex.ts b/frontend/src/features/admin/media/episode-regex.ts index 18fcb65..024cdde 100644 --- a/frontend/src/features/admin/media/episode-regex.ts +++ b/frontend/src/features/admin/media/episode-regex.ts @@ -1,10 +1,10 @@ /** Готовые шаблоны для частых раскладок имён. Подпись переводится в UI по ключу. */ export const REGEX_PRESETS: { key: string; pattern: string }[] = [ - { key: 'seriesWord', pattern: '[Сс]ерия\\s*(\\d{1,3})' }, - { key: 'episodeWord', pattern: '[Ээ]пизод\\s*(\\d{1,3})' }, - { key: 'seasonEpisode', pattern: '[Ss](\\d{1,2})[Ee](\\d{1,3})' }, - { key: 'afterDash', pattern: '[-–—]\\s*(\\d{1,3})' }, - { key: 'firstNumber', pattern: '(?:^|\\D)(\\d{1,3})(?:\\D|$)' }, + { key: 'seriesWord', pattern: String.raw`[Сс]ерия\s*(\d{1,3})` }, + { key: 'episodeWord', pattern: String.raw`[Ээ]пизод\s*(\d{1,3})` }, + { key: 'seasonEpisode', pattern: String.raw`[Ss](\d{1,2})[Ee](\d{1,3})` }, + { key: 'afterDash', pattern: String.raw`[-–—]\s*(\d{1,3})` }, + { key: 'firstNumber', pattern: String.raw`(?:^|\D)(\d{1,3})(?:\D|$)` }, ] /** Числа в имени файла: позиция и текст — по ним строится кликабельный образец. */ @@ -16,7 +16,7 @@ export function findNumbers(fileName: string): { index: number; start: number; t })) } -const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&') +const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\/-]/g, String.raw`\$&`) const isLetter = (char: string) => /\p{L}/u.test(char) const isLetterOrDigit = (char: string) => /[\p{L}\p{N}]/u.test(char) @@ -44,9 +44,9 @@ export function buildEpisodeRegex(fileName: string, occurrenceIndex: number): st // Всегда до трёх цифр — как во встроенных шаблонах: правило строится по одному файлу, // а применяется ко всей папке, где рядом может лежать и «Серия 100». - const digits = '(\\d{1,3})' + const digits = String.raw`(\d{1,3})` const before = fileName.slice(0, target.start) - if (!before.trim()) return `^\\s*${digits}` + if (!before.trim()) return String.raw`^\s*${digits}` // Разделители между якорем и числом описываем классом, а не буквально: в соседних файлах // там встречается то пробел, то точка, то подчёркивание. @@ -56,9 +56,9 @@ export function buildEpisodeRegex(fileName: string, occurrenceIndex: number): st // к первому сезону, и на «S02E05» оно бы уже не сработало. const anchor = trailingRun(anchorSource, isLetter) - if (anchor) return `${escapeRegex(anchor)}${gap ? '[\\s._-]*' : ''}${digits}` + if (anchor) return `${escapeRegex(anchor)}${gap ? String.raw`[\s._-]*` : ''}${digits}` // Слова перед числом нет — цепляемся за последний разделитель («- 05», «(05)»). const punctuation = gap.trim().slice(-1) - return punctuation ? `${escapeRegex(punctuation)}\\s*${digits}` : `\\s${digits}` + return punctuation ? String.raw`${escapeRegex(punctuation)}\s*${digits}` : String.raw`\s${digits}` } diff --git a/frontend/src/features/admin/shows/ShowMetadataCard.tsx b/frontend/src/features/admin/shows/ShowMetadataCard.tsx index 7648993..ea46284 100644 --- a/frontend/src/features/admin/shows/ShowMetadataCard.tsx +++ b/frontend/src/features/admin/shows/ShowMetadataCard.tsx @@ -345,7 +345,7 @@ export function ShowMetadataCard({ {t('admin.metadata.missingTitle')} - {missing && missing.seasons.length === 0 && ( + {missing?.seasons.length === 0 && (

{t('admin.metadata.missingNoSeasons')}

)} {missing && missing.seasons.length > 0 && ( diff --git a/frontend/src/features/admin/users/UsersPanel.tsx b/frontend/src/features/admin/users/UsersPanel.tsx index e9cf1fb..4effa84 100644 --- a/frontend/src/features/admin/users/UsersPanel.tsx +++ b/frontend/src/features/admin/users/UsersPanel.tsx @@ -14,8 +14,7 @@ import { useTableSort } from '@/shared/lib/table-sort' import { SortHeader } from '@/shared/ui/sortable' import { toast } from '@/shared/ui/toast-store' import type { UserSummaryDto } from '@/shared/api/types' -import { changeUserRole } from '@/features/admin/roles/api' -import { listRoles } from '@/features/admin/roles/api' +import { changeUserRole, listRoles } from '@/features/admin/roles/api' import { blockUser, createUser, deleteUser, listUsers, resetUserPassword, unblockUser } from './api' const PAGE_SIZE = 20 diff --git a/frontend/src/features/streaming/AirPage.tsx b/frontend/src/features/streaming/AirPage.tsx index bf8f70b..8ee0e61 100644 --- a/frontend/src/features/streaming/AirPage.tsx +++ b/frontend/src/features/streaming/AirPage.tsx @@ -306,8 +306,8 @@ function buildGuide(entries: PublicEpgEntryDto[]): { const blocks: GuideBlock[] = [] for (const entry of entries) { if (entry.kind !== 'Program') continue - const last = blocks[blocks.length - 1] - if (last && last.showId === entry.showId) { + const last = blocks.at(-1) + if (last?.showId === entry.showId) { last.endsAtUtc = entry.endsAtUtc } else { blocks.push({ diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts index f4fb63b..9d5b5a6 100644 --- a/frontend/src/shared/api/client.ts +++ b/frontend/src/shared/api/client.ts @@ -25,25 +25,23 @@ type RequestOptions = { } export async function refreshAccessToken(): Promise { - if (!refreshInFlight) { - refreshInFlight = (async () => { - try { - const response = await fetch('/api/auth/refresh', { - method: 'POST', - credentials: 'include', - }) - if (!response.ok) return false - const data = (await response.json()) as { accessToken?: unknown } - if (typeof data?.accessToken !== 'string') return false - setAccessToken(data.accessToken) - return true - } catch { - return false - } finally { - refreshInFlight = null - } - })() - } + refreshInFlight ??= (async () => { + try { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include', + }) + if (!response.ok) return false + const data = (await response.json()) as { accessToken?: unknown } + if (typeof data?.accessToken !== 'string') return false + setAccessToken(data.accessToken) + return true + } catch { + return false + } finally { + refreshInFlight = null + } + })() return refreshInFlight } diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index b44ba23..a07f968 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -237,8 +237,7 @@ export const en = { toShowAuto: 'auto', toShowRegex: 'Episode regex', toShowRegexInvalid: 'invalid regex', - toShowHint: - 'Season and regex are optional: numbers are usually detected automatically (see below). Regex: 1 group = episode, 2 groups = season and episode. Example: ^(\\d+) for “01. Title.mkv”.', + toShowHint: String.raw`Season and regex are optional: numbers are usually detected automatically (see below). Regex: 1 group = episode, 2 groups = season and episode. Example: ^(\d+) for “01. Title.mkv”.`, toShowPreview: 'What we detect', toShowMatched: 'show detected for {{matched}} of {{total}}', applyToAll: 'Set for all…', diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index eb6c7e2..50e3430 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -238,8 +238,7 @@ export const ru = { toShowAuto: 'авто', toShowRegex: 'Regex серии', toShowRegexInvalid: 'некорректный regex', - toShowHint: - 'Сезон и regex — необязательны: обычно номера распознаются сами (см. ниже). Regex: 1 группа = серия, 2 группы = сезон и серия. Пример: ^(\\d+) для «01. Название.mkv».', + toShowHint: String.raw`Сезон и regex — необязательны: обычно номера распознаются сами (см. ниже). Regex: 1 группа = серия, 2 группы = сезон и серия. Пример: ^(\d+) для «01. Название.mkv».`, toShowPreview: 'Что распознаем', toShowMatched: 'шоу распознано у {{matched}} из {{total}}', applyToAll: 'Задать всем…', diff --git a/frontend/src/theme/ThemeProvider.tsx b/frontend/src/theme/ThemeProvider.tsx index effa6fb..350ddde 100644 --- a/frontend/src/theme/ThemeProvider.tsx +++ b/frontend/src/theme/ThemeProvider.tsx @@ -14,7 +14,7 @@ function applyTheme(theme: Theme) { } export function ThemeProvider({ children }: Readonly<{ children: ReactNode }>) { - const [theme, setThemeState] = useState( + const [theme, setTheme] = useState( () => (localStorage.getItem(THEME_STORAGE_KEY) as Theme | null) ?? 'dark', ) @@ -27,13 +27,14 @@ export function ThemeProvider({ children }: Readonly<{ children: ReactNode }>) { return () => media.removeEventListener('change', onChange) }, [theme]) - const setTheme = useCallback((next: Theme) => { + // Выбор темы переживает перезагрузку, поэтому наружу отдаём не голый сеттер, а обёртку с записью. + const changeTheme = useCallback((next: Theme) => { localStorage.setItem(THEME_STORAGE_KEY, next) - setThemeState(next) + setTheme(next) }, []) // Литерал в value пересоздавался бы на каждый рендер и перерисовывал всех потребителей темы. - const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]) + const value = useMemo(() => ({ theme, setTheme: changeTheme }), [theme, changeTheme]) return {children} }