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}
}
|