Add Prettier to the frontend and gate formatting in CI

Форматтера у фронтенда не было: стиль держался вручную и успел разъехаться в 50 файлах.
Ставим Prettier с настройками под уже сложившийся стиль (без точек с запятой, одинарные кавычки,
ширина 100 — подобрана замером: при 100 расходится меньше файлов, чем при 96 или 110) и
прогоняем его по коду. `src/routeTree.gen.ts` исключён — его переписывает плагин роутера.

Чтобы форматирование больше не расходилось незаметно, добавлены проверки в CI: `csharpier check`
для бэкенда (его отсутствие и позволило накопиться 79 неотформатированным файлам) и
`prettier --check` для фронтенда. Версии форматтеров прибиты точно, без кареток: минорка меняет
вывод и красит CI на файлах, которых никто не трогал.

`.editorconfig` задаёт редакторам те же отступы и LF ещё до форматтера; значения совпадают с
настройками csharpier и Prettier намеренно — оба его читают.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-07-27 01:38:12 +03:00
co-authored by Claude Opus 5
parent 0442056367
commit 0606ea3e6e
58 changed files with 5158 additions and 4903 deletions
+332 -339
View File
@@ -1,339 +1,332 @@
import { useQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Radio, RotateCw } from 'lucide-react'
import { qk } from '@/shared/api/query-keys'
import type { PublicEpgEntryDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { ChannelPlayer } from './ChannelPlayer'
import { getEpg, getViewerFeatures, imageUrl, listChannels, watchChannel } from './api'
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
/** Кадр серии, если он есть; иначе постер шоу (он вертикальный, отсюда другая ширина). */
function EntryThumb({ entry }: Readonly<{ entry: PublicEpgEntryDto | undefined }>) {
if (entry?.episodeStillImageId)
return (
<img
src={imageUrl(entry.episodeStillImageId)}
alt=""
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
/>
)
if (entry?.showPosterImageId)
return (
<img
src={imageUrl(entry.showPosterImageId)}
alt=""
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
/>
)
return null
}
export function AirPage() {
const { t } = useTranslation()
const [selected, setSelected] = useState<string | null>(null)
const [watchReady, setWatchReady] = useState(false)
const [playerError, setPlayerError] = useState(false)
const [attempt, setAttempt] = useState(0)
const [flash, setFlash] = useState(false)
const handleUnavailable = useCallback(() => setPlayerError(true), [])
const retry = () => {
setPlayerError(false)
setAttempt((a) => a + 1)
}
const { data: channels, isLoading } = useQuery({
queryKey: qk.air.channels,
queryFn: listChannels,
})
const { data: features } = useQuery({ queryKey: qk.air.features, queryFn: getViewerFeatures })
const numbersEnabled = features?.channelNumbersEnabled ?? false
const currentChannel = channels?.find((c) => c.slug === selected)
/**
* Переключение по номерам: список уже отсортирован сервером, поэтому «вверх-вниз» — это шаг
* по нему. Короткий чёрный кадр с номером ставится сразу, до готовности потока.
*/
const step = useCallback(
(delta: number) => {
if (!channels || channels.length === 0) return
const index = channels.findIndex((c) => c.slug === selected)
const next = channels[(index + delta + channels.length) % channels.length]
if (!next || next.slug === selected) return
setFlash(true)
setSelected(next.slug)
},
[channels, selected],
)
useEffect(() => {
if (!flash) return
const id = window.setTimeout(() => setFlash(false), 900)
return () => window.clearTimeout(id)
}, [flash, selected])
useEffect(() => {
if (!numbersEnabled) return
const onKey = (event: KeyboardEvent) => {
// Не перехватываем стрелки, пока фокус в поле ввода: там они двигают каретку.
const target = event.target as HTMLElement | null
if (target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return
if (event.key === 'ArrowUp' || event.key === 'PageUp') {
event.preventDefault()
step(-1)
} else if (event.key === 'ArrowDown' || event.key === 'PageDown') {
event.preventDefault()
step(1)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [numbersEnabled, step])
useEffect(() => {
if (!selected && channels && channels.length > 0) setSelected(channels[0].slug)
}, [channels, selected])
useEffect(() => {
if (!selected) return
setWatchReady(false)
setPlayerError(false)
let cancelled = false
void watchChannel(selected)
.then(() => {
if (!cancelled) setWatchReady(true)
})
.catch(() => {
// Выдача stream-cookie не удалась (403/500/сеть) — показываем offline-панель с кнопкой ретрая,
// а не бесконечный скелетон. Ретрай (attempt) заново дёрнет watchChannel.
if (!cancelled) {
setWatchReady(true)
setPlayerError(true)
}
})
return () => {
cancelled = true
}
}, [selected, attempt])
// Stream-cookie короткоживущий (TTL на сервере ~30 мин) — периодически перевыпускаем, пока смотрим,
// иначе плейлист/сегменты начнут отдавать 401 посреди эфира. Тихо: ошибку словит перезагрузка плейлиста.
useEffect(() => {
if (!selected || playerError) return
const id = window.setInterval(
() => {
void watchChannel(selected).catch(() => undefined)
},
20 * 60_000,
)
return () => window.clearInterval(id)
}, [selected, playerError])
const { data: epg } = useQuery({
queryKey: qk.air.epg(selected),
queryFn: () =>
getEpg(
selected!,
new Date(Date.now() - 30 * 60_000),
new Date(Date.now() + 3 * 60 * 60_000),
),
enabled: !!selected,
refetchInterval: 60_000,
})
const { current, upcoming, currentEntry } = useMemo(() => buildGuide(epg ?? []), [epg])
// Плашка «Далее» — только на исходе программы: висеть весь эфир ей незачем.
const nextUp =
current && upcoming.length > 0 && new Date(current.endsAtUtc).getTime() - Date.now() < 60_000
? upcoming[0].showName
: null
if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
if (!channels || channels.length === 0)
return (
<div className="flex flex-col gap-2">
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
<p className="text-muted-foreground">{t('air.noChannels')}</p>
</div>
)
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
{numbersEnabled && (
<span className="text-xs text-muted-foreground">{t('air.numbersHint')}</span>
)}
</div>
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
<aside className="flex gap-2 overflow-x-auto md:flex-col md:overflow-visible">
{channels.map((channel) => (
<button
key={channel.id}
type="button"
onClick={() => setSelected(channel.slug)}
className={cn(
'flex shrink-0 items-center gap-2 rounded-sm border border-border px-3 py-2 text-left text-sm hover:bg-muted md:shrink',
selected === channel.slug && 'border-primary text-primary',
)}
>
{numbersEnabled && channel.number !== null && (
<span className="w-6 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{channel.number}
</span>
)}
{channel.currentShowPosterImageId ? (
<img
src={imageUrl(channel.currentShowPosterImageId)}
alt=""
className="h-10 w-7 shrink-0 rounded object-cover"
/>
) : (
<Radio className="h-4 w-4 shrink-0" />
)}
<span className="flex min-w-0 flex-col">
<span className="truncate">{channel.name}</span>
{channel.currentShowName && (
<span className="truncate text-xs text-muted-foreground">
{channel.currentShowName}
</span>
)}
</span>
</button>
))}
</aside>
<div className="flex flex-col gap-4">
{/* До выдачи cookie tw_stream плеер грузить нечем — держим место заглушкой. */}
{(!selected || !watchReady) && (
<div className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
)}
{selected && watchReady && playerError && (
<div className="crt-panel flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-md text-center">
<Radio className="h-10 w-10 text-muted-foreground" strokeWidth={1} />
<div className="flex flex-col gap-1">
<p className="font-medium">{t('air.offline')}</p>
<p className="text-sm text-muted-foreground">{t('air.offlineHint')}</p>
</div>
<Button size="sm" variant="outline" onClick={retry}>
<RotateCw className="h-4 w-4" />
{t('air.retry')}
</Button>
</div>
)}
{selected && watchReady && !playerError && (
<ChannelPlayer
key={`${selected}-${attempt}`}
slug={selected}
channel={currentChannel}
nextUp={nextUp}
flash={flash}
onUnavailable={handleUnavailable}
/>
)}
<div className="flex flex-col gap-3">
{current && (
<div className="crt-panel flex gap-3 rounded-md p-3">
<EntryThumb entry={currentEntry} />
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<Badge>{t('air.now')}</Badge>
<span className="font-medium">{current.showName}</span>
</div>
{currentEntry?.episodeTitle && (
<span className="text-sm">{currentEntry.episodeTitle}</span>
)}
<span className="text-xs text-muted-foreground">
{formatTime(current.startsAtUtc)} {formatTime(current.endsAtUtc)}
</span>
{currentEntry?.episodeOverview && (
<p className="line-clamp-3 text-xs text-muted-foreground">
{currentEntry.episodeOverview}
</p>
)}
</div>
</div>
)}
{upcoming.length > 0 && (
<div className="crt-panel rounded-md">
<div className="border-b border-border px-4 py-2 text-xs uppercase tracking-wide text-muted-foreground">
{t('air.next')}
</div>
<ul className="divide-y divide-border">
{upcoming.slice(0, 6).map((block) => (
<li key={block.key} className="flex items-center gap-3 px-4 py-2 text-sm">
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
{formatTime(block.startsAtUtc)} {formatTime(block.endsAtUtc)}
</span>
<span>{block.showName}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
</div>
)
}
type GuideBlock = {
key: string
showId: string | null
showName: string
startsAtUtc: string
endsAtUtc: string
}
/**
* Строит телегид: рекламу/заставки не показываем, а подряд идущие серии одного шоу склеиваем в один
* блок с диапазоном «с – по». Отдельно возвращаем текущую серию (для метаданных карточки «сейчас»).
*/
function buildGuide(entries: PublicEpgEntryDto[]): {
current?: GuideBlock
upcoming: GuideBlock[]
currentEntry?: 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) {
last.endsAtUtc = entry.endsAtUtc
} else {
blocks.push({
key: entry.startsAtUtc,
showId: entry.showId,
showName: entry.showName ?? '—',
startsAtUtc: entry.startsAtUtc,
endsAtUtc: entry.endsAtUtc,
})
}
}
const now = Date.now()
const active = (start: string, end: string) =>
new Date(start).getTime() <= now && new Date(end).getTime() > now
const current = blocks.find((b) => active(b.startsAtUtc, b.endsAtUtc))
const upcoming = blocks.filter((b) => new Date(b.startsAtUtc).getTime() > now)
const currentEntry = entries.find(
(e) => e.kind === 'Program' && active(e.startsAtUtc, e.endsAtUtc),
)
return { current, upcoming, currentEntry }
}
import { useQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Radio, RotateCw } from 'lucide-react'
import { qk } from '@/shared/api/query-keys'
import type { PublicEpgEntryDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { ChannelPlayer } from './ChannelPlayer'
import { getEpg, getViewerFeatures, imageUrl, listChannels, watchChannel } from './api'
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
/** Кадр серии, если он есть; иначе постер шоу (он вертикальный, отсюда другая ширина). */
function EntryThumb({ entry }: Readonly<{ entry: PublicEpgEntryDto | undefined }>) {
if (entry?.episodeStillImageId)
return (
<img
src={imageUrl(entry.episodeStillImageId)}
alt=""
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
/>
)
if (entry?.showPosterImageId)
return (
<img
src={imageUrl(entry.showPosterImageId)}
alt=""
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
/>
)
return null
}
export function AirPage() {
const { t } = useTranslation()
const [selected, setSelected] = useState<string | null>(null)
const [watchReady, setWatchReady] = useState(false)
const [playerError, setPlayerError] = useState(false)
const [attempt, setAttempt] = useState(0)
const [flash, setFlash] = useState(false)
const handleUnavailable = useCallback(() => setPlayerError(true), [])
const retry = () => {
setPlayerError(false)
setAttempt((a) => a + 1)
}
const { data: channels, isLoading } = useQuery({
queryKey: qk.air.channels,
queryFn: listChannels,
})
const { data: features } = useQuery({ queryKey: qk.air.features, queryFn: getViewerFeatures })
const numbersEnabled = features?.channelNumbersEnabled ?? false
const currentChannel = channels?.find((c) => c.slug === selected)
/**
* Переключение по номерам: список уже отсортирован сервером, поэтому «вверх-вниз» — это шаг
* по нему. Короткий чёрный кадр с номером ставится сразу, до готовности потока.
*/
const step = useCallback(
(delta: number) => {
if (!channels || channels.length === 0) return
const index = channels.findIndex((c) => c.slug === selected)
const next = channels[(index + delta + channels.length) % channels.length]
if (!next || next.slug === selected) return
setFlash(true)
setSelected(next.slug)
},
[channels, selected],
)
useEffect(() => {
if (!flash) return
const id = window.setTimeout(() => setFlash(false), 900)
return () => window.clearTimeout(id)
}, [flash, selected])
useEffect(() => {
if (!numbersEnabled) return
const onKey = (event: KeyboardEvent) => {
// Не перехватываем стрелки, пока фокус в поле ввода: там они двигают каретку.
const target = event.target as HTMLElement | null
if (target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return
if (event.key === 'ArrowUp' || event.key === 'PageUp') {
event.preventDefault()
step(-1)
} else if (event.key === 'ArrowDown' || event.key === 'PageDown') {
event.preventDefault()
step(1)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [numbersEnabled, step])
useEffect(() => {
if (!selected && channels && channels.length > 0) setSelected(channels[0].slug)
}, [channels, selected])
useEffect(() => {
if (!selected) return
setWatchReady(false)
setPlayerError(false)
let cancelled = false
void watchChannel(selected)
.then(() => {
if (!cancelled) setWatchReady(true)
})
.catch(() => {
// Выдача stream-cookie не удалась (403/500/сеть) — показываем offline-панель с кнопкой ретрая,
// а не бесконечный скелетон. Ретрай (attempt) заново дёрнет watchChannel.
if (!cancelled) {
setWatchReady(true)
setPlayerError(true)
}
})
return () => {
cancelled = true
}
}, [selected, attempt])
// Stream-cookie короткоживущий (TTL на сервере ~30 мин) — периодически перевыпускаем, пока смотрим,
// иначе плейлист/сегменты начнут отдавать 401 посреди эфира. Тихо: ошибку словит перезагрузка плейлиста.
useEffect(() => {
if (!selected || playerError) return
const id = window.setInterval(() => {
void watchChannel(selected).catch(() => undefined)
}, 20 * 60_000)
return () => window.clearInterval(id)
}, [selected, playerError])
const { data: epg } = useQuery({
queryKey: qk.air.epg(selected),
queryFn: () =>
getEpg(selected!, new Date(Date.now() - 30 * 60_000), new Date(Date.now() + 3 * 60 * 60_000)),
enabled: !!selected,
refetchInterval: 60_000,
})
const { current, upcoming, currentEntry } = useMemo(() => buildGuide(epg ?? []), [epg])
// Плашка «Далее» — только на исходе программы: висеть весь эфир ей незачем.
const nextUp =
current && upcoming.length > 0 && new Date(current.endsAtUtc).getTime() - Date.now() < 60_000
? upcoming[0].showName
: null
if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
if (!channels || channels.length === 0)
return (
<div className="flex flex-col gap-2">
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
<p className="text-muted-foreground">{t('air.noChannels')}</p>
</div>
)
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
{numbersEnabled && (
<span className="text-xs text-muted-foreground">{t('air.numbersHint')}</span>
)}
</div>
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
<aside className="flex gap-2 overflow-x-auto md:flex-col md:overflow-visible">
{channels.map((channel) => (
<button
key={channel.id}
type="button"
onClick={() => setSelected(channel.slug)}
className={cn(
'flex shrink-0 items-center gap-2 rounded-sm border border-border px-3 py-2 text-left text-sm hover:bg-muted md:shrink',
selected === channel.slug && 'border-primary text-primary',
)}
>
{numbersEnabled && channel.number !== null && (
<span className="w-6 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{channel.number}
</span>
)}
{channel.currentShowPosterImageId ? (
<img
src={imageUrl(channel.currentShowPosterImageId)}
alt=""
className="h-10 w-7 shrink-0 rounded object-cover"
/>
) : (
<Radio className="h-4 w-4 shrink-0" />
)}
<span className="flex min-w-0 flex-col">
<span className="truncate">{channel.name}</span>
{channel.currentShowName && (
<span className="truncate text-xs text-muted-foreground">
{channel.currentShowName}
</span>
)}
</span>
</button>
))}
</aside>
<div className="flex flex-col gap-4">
{/* До выдачи cookie tw_stream плеер грузить нечем — держим место заглушкой. */}
{(!selected || !watchReady) && (
<div className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
)}
{selected && watchReady && playerError && (
<div className="crt-panel flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-md text-center">
<Radio className="h-10 w-10 text-muted-foreground" strokeWidth={1} />
<div className="flex flex-col gap-1">
<p className="font-medium">{t('air.offline')}</p>
<p className="text-sm text-muted-foreground">{t('air.offlineHint')}</p>
</div>
<Button size="sm" variant="outline" onClick={retry}>
<RotateCw className="h-4 w-4" />
{t('air.retry')}
</Button>
</div>
)}
{selected && watchReady && !playerError && (
<ChannelPlayer
key={`${selected}-${attempt}`}
slug={selected}
channel={currentChannel}
nextUp={nextUp}
flash={flash}
onUnavailable={handleUnavailable}
/>
)}
<div className="flex flex-col gap-3">
{current && (
<div className="crt-panel flex gap-3 rounded-md p-3">
<EntryThumb entry={currentEntry} />
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<Badge>{t('air.now')}</Badge>
<span className="font-medium">{current.showName}</span>
</div>
{currentEntry?.episodeTitle && (
<span className="text-sm">{currentEntry.episodeTitle}</span>
)}
<span className="text-xs text-muted-foreground">
{formatTime(current.startsAtUtc)} {formatTime(current.endsAtUtc)}
</span>
{currentEntry?.episodeOverview && (
<p className="line-clamp-3 text-xs text-muted-foreground">
{currentEntry.episodeOverview}
</p>
)}
</div>
</div>
)}
{upcoming.length > 0 && (
<div className="crt-panel rounded-md">
<div className="border-b border-border px-4 py-2 text-xs uppercase tracking-wide text-muted-foreground">
{t('air.next')}
</div>
<ul className="divide-y divide-border">
{upcoming.slice(0, 6).map((block) => (
<li key={block.key} className="flex items-center gap-3 px-4 py-2 text-sm">
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
{formatTime(block.startsAtUtc)} {formatTime(block.endsAtUtc)}
</span>
<span>{block.showName}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
</div>
)
}
type GuideBlock = {
key: string
showId: string | null
showName: string
startsAtUtc: string
endsAtUtc: string
}
/**
* Строит телегид: рекламу/заставки не показываем, а подряд идущие серии одного шоу склеиваем в один
* блок с диапазоном «с – по». Отдельно возвращаем текущую серию (для метаданных карточки «сейчас»).
*/
function buildGuide(entries: PublicEpgEntryDto[]): {
current?: GuideBlock
upcoming: GuideBlock[]
currentEntry?: 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) {
last.endsAtUtc = entry.endsAtUtc
} else {
blocks.push({
key: entry.startsAtUtc,
showId: entry.showId,
showName: entry.showName ?? '—',
startsAtUtc: entry.startsAtUtc,
endsAtUtc: entry.endsAtUtc,
})
}
}
const now = Date.now()
const active = (start: string, end: string) =>
new Date(start).getTime() <= now && new Date(end).getTime() > now
const current = blocks.find((b) => active(b.startsAtUtc, b.endsAtUtc))
const upcoming = blocks.filter((b) => new Date(b.startsAtUtc).getTime() > now)
const currentEntry = entries.find(
(e) => e.kind === 'Program' && active(e.startsAtUtc, e.endsAtUtc),
)
return { current, upcoming, currentEntry }
}