From b80163b6b4110c5923fcb8984ac2a87099365d72 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 27 Jul 2026 02:09:47 +0300 Subject: [PATCH] Refactor regex handling and improve null checks across various components Updated regex patterns in episode parsing and metadata handling to use String.raw for better readability and maintainability. Enhanced null checks in BlockBuilder, MediaPanel, and ShowMetadataCard components to prevent potential runtime errors. Consolidated imports in UsersPanel for cleaner code structure. --- .../admin/interstitials/BlockBuilder.tsx | 2 +- .../src/features/admin/media/MediaPanel.tsx | 2 +- .../src/features/admin/media/episode-parse.ts | 10 +++--- .../src/features/admin/media/episode-regex.ts | 20 +++++------ .../features/admin/shows/ShowMetadataCard.tsx | 2 +- .../src/features/admin/users/UsersPanel.tsx | 3 +- frontend/src/features/streaming/AirPage.tsx | 4 +-- frontend/src/shared/api/client.ts | 36 +++++++++---------- frontend/src/shared/lib/locales/en.ts | 3 +- frontend/src/shared/lib/locales/ru.ts | 3 +- frontend/src/theme/ThemeProvider.tsx | 9 ++--- 11 files changed, 45 insertions(+), 49 deletions(-) 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} }