Refactor components to enhance code clarity and maintainability by updating prop types to use Readonly for better immutability. Adjust Result class methods for consistency, and streamline query functions in ChannelDetail and other components to improve performance and readability.
ci / build-backend (push) Successful in 1m41s
ci / build-frontend (push) Successful in 47s
ci / tests (push) Successful in 2m40s
ci / sonar (push) Successful in 5m35s

This commit is contained in:
Leonid Pershin
2026-07-27 01:14:53 +03:00
parent ecb5417170
commit 5449c05b5b
56 changed files with 132 additions and 124 deletions
@@ -31,7 +31,7 @@ import { ViewerCard } from './components/ViewerCard'
const TABS = ['settings', 'grid', 'rules', 'junctions', 'bumpers', 'viewer', 'air'] as const
type ChannelTab = (typeof TABS)[number]
export function ChannelDetail({ channelId }: { channelId: string }) {
export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [applyOpen, setApplyOpen] = useState(false)
@@ -52,7 +52,7 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
})
const { data: schedule } = useQuery({
queryKey: qk.channels.schedule(channelId),
queryFn: () => getSchedule(channelId, new Date(), new Date(Date.now() + 12 * 3600_000)),
queryFn: () => getSchedule(channelId, new Date(), new Date(Date.now() + 12 * 3_600_000)),
})
const invalidate = () => {
@@ -25,13 +25,13 @@ export function ApplyDialog({
pending,
onApply,
onClose,
}: {
}: Readonly<{
channelId: string
utcOffsetMinutes: number
pending: boolean
onApply: () => void
onClose: () => void
}) {
}>) {
const { t } = useTranslation()
const { data, isFetching } = useQuery({
queryKey: qk.channels.diff(channelId),
@@ -13,13 +13,13 @@ export function BumperBackgroundField({
backgroundImageId,
onChanged,
onError,
}: {
}: Readonly<{
channelId: string
templateId: string
backgroundImageId: string | null
onChanged: () => void
onError: (e: unknown) => void
}) {
}>) {
const { t } = useTranslation()
const [galleryOpen, setGalleryOpen] = useState(false)
@@ -15,12 +15,12 @@ export function BumperCard({
bare,
onSaved,
onError,
}: {
}: Readonly<{
channel: ChannelDto
bare?: boolean
onSaved: () => void
onError: (e: unknown) => void
}) {
}>) {
const { t } = useTranslation()
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
@@ -15,7 +15,7 @@ export function BumperFileUpload({
clear,
onSaved,
onError,
}: {
}: Readonly<{
channelId: string
templateId: string
kind: string
@@ -27,7 +27,7 @@ export function BumperFileUpload({
clear: (id: string, templateId: string) => Promise<void>
onSaved: () => void
onError: (e: unknown) => void
}) {
}>) {
const { t } = useTranslation()
const inputId = `bumper-${kind}-${templateId}`
@@ -11,12 +11,12 @@ export function BumperPreviewPlayer({
templateId,
variants,
onError,
}: {
}: Readonly<{
channelId: string
templateId: string
variants: BumperTextVariantDto[]
onError: (e: unknown) => void
}) {
}>) {
const { t } = useTranslation()
const [ready, setReady] = useState(false)
const [bust, setBust] = useState(0)
@@ -26,12 +26,12 @@ export function BumperTemplateEditor({
template,
onChanged,
onError,
}: {
}: Readonly<{
channelId: string
template: BumperTemplateDto
onChanged: () => void
onError: (e: unknown) => void
}) {
}>) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [name, setName] = useState(template.name)
@@ -16,14 +16,14 @@ export function BumperVariantEditor({
canRemove,
onChanged,
onError,
}: {
}: Readonly<{
channelId: string
templateId: string
variant: BumperTextVariantDto
canRemove: boolean
onChanged: () => void
onError: (e: unknown) => void
}) {
}>) {
const { t } = useTranslation()
const [form, setForm] = useState({
name: variant.name,
@@ -9,14 +9,14 @@ export function CollapsibleCard({
bare = false,
contentClassName,
children,
}: {
}: Readonly<{
title: string
defaultOpen?: boolean
/** Без своего заголовка и сворачивания — когда карточка и так лежит во вкладке с этим названием. */
bare?: boolean
contentClassName?: string
children: ReactNode
}) {
}>) {
const [open, setOpen] = useState(defaultOpen)
if (bare)
@@ -19,11 +19,11 @@ export function EntryTraceDialog({
entryId,
utcOffsetMinutes,
onClose,
}: {
}: Readonly<{
entryId: string
utcOffsetMinutes: number
onClose: () => void
}) {
}>) {
const { t } = useTranslation()
const { data } = useQuery({
queryKey: qk.entries.trace(entryId),
@@ -99,7 +99,7 @@ function strategySummary(data: EntryTraceDto, t: Translate) {
])
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
function Row({ label, children }: Readonly<{ label: string; children: React.ReactNode }>) {
return (
<>
<dt className="text-muted-foreground">{label}</dt>
@@ -38,13 +38,13 @@ export function GridTab({
templateError,
onChanged,
onError,
}: {
}: Readonly<{
channelId: string
template: ScheduleTemplateDto | undefined
templateError: unknown
onChanged: () => void
onError: (error: unknown) => void
}) {
}>) {
const { t } = useTranslation()
const [draft, setDraft] = useState<SlotDraft | null>(null)
const [activeLayerId, setActiveLayerId] = useState<string | null>(null)
@@ -141,17 +141,17 @@ export function GridTab({
slot,
weekday,
startMinutes,
}: {
}: Readonly<{
slot: SlotDto
weekday: number
startMinutes: number
}) => updateSlot(slot.id, { ...toSlotBody(slot), weekday, targetStart: toTime(startMinutes) }),
}>) => updateSlot(slot.id, { ...toSlotBody(slot), weekday, targetStart: toTime(startMinutes) }),
onSuccess: onChanged,
onError,
})
const resizeSlotMutation = useMutation({
mutationFn: ({ slot, minutes }: { slot: SlotDto; minutes: number }) =>
mutationFn: ({ slot, minutes }: Readonly<{ slot: SlotDto; minutes: number }>) =>
updateSlot(slot.id, { ...toSlotBody(slot), targetDurationMinutes: minutes }),
onSuccess: onChanged,
onError,
@@ -159,7 +159,7 @@ export function GridTab({
/** Копирование дня: слоты «каждый день» не копируются — они и так есть во всех колонках. */
const copyDayMutation = useMutation({
mutationFn: async ({ from, to }: { from: number; to: number[] }) => {
mutationFn: async ({ from, to }: Readonly<{ from: number; to: number[] }>) => {
const sources = (template?.layers ?? []).flatMap((layer) =>
layer.slots.filter((slot) => slot.weekday === from).map((slot) => ({ layer, slot })),
)
@@ -44,14 +44,14 @@ export function JunctionElementDialog({
onClose,
onChanged,
onError,
}: {
}: Readonly<{
junctionId: string
element: JunctionElementDto
bumperTemplates: BumperTemplateDto[]
onClose: () => void
onChanged: () => void
onError: (error: unknown) => void
}) {
}>) {
const { t } = useTranslation()
const [body, setBody] = useState<JunctionElementBody>(() => toBody(element))
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
@@ -86,13 +86,13 @@ export function JunctionsCard({
bare,
onChanged,
onError,
}: {
}: Readonly<{
channel: ChannelDto
template: ScheduleTemplateDto | undefined
bare?: boolean
onChanged: () => void
onError: (error: unknown) => void
}) {
}>) {
const { t } = useTranslation()
const [newName, setNewName] = useState('')
@@ -186,13 +186,13 @@ function JunctionChain({
groups,
onChanged,
onError,
}: {
}: Readonly<{
junction: JunctionTemplateDto
channel: ChannelDto
groups: GroupSummaryDto[] | undefined
onChanged: () => void
onError: (error: unknown) => void
}) {
}>) {
const { t } = useTranslation()
const [name, setName] = useState<string | null>(null)
const [dragged, setDragged] = useState<string | null>(null)
@@ -29,12 +29,12 @@ export function LayerApplicabilityDialog({
onClose,
onChanged,
onError,
}: {
}: Readonly<{
layer: GridLayerDto
onClose: () => void
onChanged: () => void
onError: (error: unknown) => void
}) {
}>) {
const { t } = useTranslation()
const [name, setName] = useState(layer.name)
const [weekdays, setWeekdays] = useState<number[]>(layer.applicability?.weekdays ?? [])
@@ -199,12 +199,12 @@ function Section({
onAdd,
empty,
children,
}: {
}: Readonly<{
title: string
onAdd: () => void
empty: boolean
children: React.ReactNode
}) {
}>) {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-2">
@@ -228,11 +228,11 @@ function MonthDay({
value,
prefix,
onChange,
}: {
}: Readonly<{
value: AnnualRange
prefix: 'from' | 'to'
onChange: (part: Partial<AnnualRange>) => void
}) {
}>) {
const month = prefix === 'from' ? value.fromMonth : value.toMonth
const day = prefix === 'from' ? value.fromDay : value.toDay
@@ -37,12 +37,12 @@ export function RulesCard({
bare,
onChanged,
onError,
}: {
}: Readonly<{
template: ScheduleTemplateDto
bare?: boolean
onChanged: () => void
onError: (error: unknown) => void
}) {
}>) {
const { t } = useTranslation()
const [windows, setWindows] = useState<WindowRow[]>(() =>
toRows(template.rules?.maxAudienceByTime ?? []),
@@ -56,7 +56,7 @@ export function ScheduleGrid({
onMoveSlot,
onResizeSlot,
onCopyDay,
}: {
}: Readonly<{
template: ScheduleTemplateDto
selectedSlotId: string | null
/** Дата, на которую смотрим сетку («показать 25 декабря»); null — все слои разом. */
@@ -67,7 +67,7 @@ export function ScheduleGrid({
onMoveSlot: (slot: SlotDto, weekday: number, startMinutes: number) => void
onResizeSlot: (slot: SlotDto, durationMinutes: number) => void
onCopyDay: (fromWeekday: number) => void
}) {
}>) {
const { t } = useTranslation()
const dayStart = template.dayStartTime.slice(0, 5)
const dayStartMinutes = minutesOf(dayStart)
@@ -280,7 +280,7 @@ export function LayerList({
onToggle,
onReorder,
onEditApplicability,
}: {
}: Readonly<{
template: ScheduleTemplateDto
activeLayerId: string | null
viewDate: string | null
@@ -289,7 +289,7 @@ export function LayerList({
onToggle: (layer: GridLayerDto) => void
onReorder: (layerIdsTopFirst: string[]) => void
onEditApplicability: (layer: GridLayerDto) => void
}) {
}>) {
const { t } = useTranslation()
const [dragged, setDragged] = useState<string | null>(null)
@@ -5,7 +5,7 @@ import { Badge } from '@/shared/ui/badge'
import { formatTime } from '../lib/format'
/** Что стоит в строке расписания: реклама, заставка-переход или программа с номером серии. */
function EntryLabel({ entry }: { entry: ScheduleEntryDto }) {
function EntryLabel({ entry }: Readonly<{ entry: ScheduleEntryDto }>) {
const { t } = useTranslation()
if (entry.kind === 'Ad') return <Badge variant="muted">{t('air.ad')}</Badge>
@@ -35,7 +35,7 @@ function EntryLabel({ entry }: { entry: ScheduleEntryDto }) {
}
/** «· S02E05» либо «· серия N» — что удалось распознать; ничего, если ни того ни другого нет. */
function EpisodeSuffix({ entry }: { entry: ScheduleEntryDto }) {
function EpisodeSuffix({ entry }: Readonly<{ entry: ScheduleEntryDto }>) {
const { t } = useTranslation()
if (entry.seasonEpisode)
@@ -54,10 +54,10 @@ function EpisodeSuffix({ entry }: { entry: ScheduleEntryDto }) {
export function SchedulePreview({
entries,
onShowTrace,
}: {
}: Readonly<{
entries: ScheduleEntryDto[]
onShowTrace: (entryId: string) => void
}) {
}>) {
const { t } = useTranslation()
if (entries.length === 0)
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
@@ -16,13 +16,13 @@ export function SettingsCard({
bare,
onSaved,
onError,
}: {
}: Readonly<{
channel: ChannelDto
readyAssets: { id: string; originalFileName: string }[]
bare?: boolean
onSaved: () => void
onError: (e: unknown) => void
}) {
}>) {
const { t } = useTranslation()
const [name, setName] = useState(channel.name)
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
@@ -68,12 +68,12 @@ export function SlotInspector({
draft,
onClose,
onChanged,
}: {
}: Readonly<{
channelId: string
draft: SlotDraft
onClose: () => void
onChanged: () => void
}) {
}>) {
const { t } = useTranslation()
const [body, setBody] = useState<SlotBody>(() =>
draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults),
@@ -14,11 +14,11 @@ export function TemplateIssues({
channelId,
slotsById,
onGoToSlot,
}: {
}: Readonly<{
channelId: string
slotsById: Map<string, SlotDto>
onGoToSlot: (slot: SlotDto) => void
}) {
}>) {
const { t } = useTranslation()
const { data: issues } = useQuery({
queryKey: qk.channels.issues(channelId),
@@ -53,11 +53,11 @@ function IssueRow({
issue,
slot,
onGoToSlot,
}: {
}: Readonly<{
issue: TemplateIssueDto
slot: SlotDto | undefined
onGoToSlot: (slot: SlotDto) => void
}) {
}>) {
const { t } = useTranslation()
const Icon = issue.severity === 'Error' ? CircleAlert : AlertTriangle
@@ -27,7 +27,7 @@ const PROGRAMME_KINDS = new Set<PlannedItemKind>(['Program', 'Fallback', 'SignOf
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
* курсоров. Заставки приходят резервом известной длины — реальный рендер только при применении.
*/
export function TemplatePreview({ channelId }: { channelId: string }) {
export function TemplatePreview({ channelId }: Readonly<{ channelId: string }>) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [days, setDays] = useState(1)
@@ -97,7 +97,7 @@ export function TemplatePreview({ channelId }: { channelId: string }) {
)
}
function Programme({ preview }: { preview: SchedulePreviewDto }) {
function Programme({ preview }: Readonly<{ preview: SchedulePreviewDto }>) {
const { t } = useTranslation()
const items = preview.items.filter((i) => PROGRAMME_KINDS.has(i.kind))
@@ -143,7 +143,7 @@ function loadByHour(preview: SchedulePreviewDto): { hour: Date; minutes: number
.map(([hour, minutes]) => ({ hour: new Date(hour), minutes }))
}
function Tape({ preview }: { preview: SchedulePreviewDto }) {
function Tape({ preview }: Readonly<{ preview: SchedulePreviewDto }>) {
const { t } = useTranslation()
const load = useMemo(() => loadByHour(preview), [preview])
const peak = Math.max(1, ...load.map((l) => l.minutes))
@@ -180,7 +180,10 @@ function Tape({ preview }: { preview: SchedulePreviewDto }) {
)
}
function TapeRow({ item, preview }: { item: PreviewItemDto; preview: SchedulePreviewDto }) {
function TapeRow({
item,
preview,
}: Readonly<{ item: PreviewItemDto; preview: SchedulePreviewDto }>) {
const { t } = useTranslation()
const minutes =
(new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
@@ -200,7 +203,7 @@ function TapeRow({ item, preview }: { item: PreviewItemDto; preview: SchedulePre
}
/** Предупреждения, сгруппированные по виду: десять однотипных строк читаются как одна проблема. */
function Problems({ preview }: { preview: SchedulePreviewDto }) {
function Problems({ preview }: Readonly<{ preview: SchedulePreviewDto }>) {
const { t } = useTranslation()
// Ключ строки — «вид + порядковый номер внутри вида»: у предупреждения нет своего id, а текст
// повторяется (одна и та же причина на разных слотах), и позиция здесь — единственное, что его
@@ -250,7 +253,7 @@ function Problems({ preview }: { preview: SchedulePreviewDto }) {
* Тепловая карта повторов: матрица «шоу × вещательные сутки», яркость — число показов. Сразу видно,
* что один фильм крутится четыре раза за неделю.
*/
function RepeatHeatmap({ preview }: { preview: SchedulePreviewDto }) {
function RepeatHeatmap({ preview }: Readonly<{ preview: SchedulePreviewDto }>) {
const { t } = useTranslation()
const { days, rows } = useMemo(() => {
@@ -21,12 +21,12 @@ export function ViewerCard({
bare,
onSaved,
onError,
}: {
}: Readonly<{
channel: ChannelDto
bare?: boolean
onSaved: () => void
onError: (error: unknown) => void
}) {
}>) {
const { t } = useTranslation()
const [viewer, setViewer] = useState<ViewerSettings>(channel.viewer)
const [galleryOpen, setGalleryOpen] = useState(false)
@@ -22,7 +22,7 @@ import {
updateCollection,
} from './api'
export function CollectionDetail({ collectionId }: { collectionId: string }) {
export function CollectionDetail({ collectionId }: Readonly<{ collectionId: string }>) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [galleryOpen, setGalleryOpen] = useState(false)
@@ -73,12 +73,12 @@ export function GenresPanel() {
mutationFn: ({
id,
...body
}: {
}: Readonly<{
id: string
name: string
sortOrder: number
aliases: string[]
}) => updateGenre(id, body),
}>) => updateGenre(id, body),
onSuccess: invalidate,
})
const deleteMutation = useMutation({
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
import { splitDuration } from './format'
/** Объём эфира: «118 ч 40 мин». Ноль — прочерк, потому что «0 ч» читается как сбой подсчёта. */
export function DurationLabel({ seconds }: { seconds: number }) {
export function DurationLabel({ seconds }: Readonly<{ seconds: number }>) {
const { t } = useTranslation()
const parts = splitDuration(seconds)
if (!parts) return <></>
@@ -25,7 +25,7 @@ import { GroupFilterPanel } from './GroupFilterPanel'
const EMPTY_FILTER: GroupFilter = {}
export function GroupDetail({ groupId }: { groupId: string }) {
export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
const { t } = useTranslation()
const queryClient = useQueryClient()
@@ -88,7 +88,7 @@ export function GroupDetail({ groupId }: { groupId: string }) {
onError,
})
const weightMutation = useMutation({
mutationFn: ({ itemId, weight }: { itemId: string; weight: number }) =>
mutationFn: ({ itemId, weight }: Readonly<{ itemId: string; weight: number }>) =>
setGroupItemWeight(groupId, itemId, weight),
onSuccess: invalidate,
onError,
@@ -14,10 +14,10 @@ const SHOW_KINDS: ShowKind[] = ['Series', 'Single']
export function GroupFilterPanel({
filter,
onChange,
}: {
}: Readonly<{
filter: GroupFilter
onChange: (next: GroupFilter) => void
}) {
}>) {
const { t } = useTranslation()
const { data: genres } = useQuery({ queryKey: qk.genres.all, queryFn: listGenres })
@@ -25,11 +25,11 @@ export function GalleryBrowser({
category = 'Library',
onSelect,
onClose,
}: {
}: Readonly<{
category?: ImageCategory
onSelect?: (image: ImagePick) => void
onClose?: () => void
}) {
}>) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [active, setActive] = useState<ImageCategory>(category)
@@ -193,12 +193,12 @@ export function ImageGallery({
onOpenChange,
category,
onSelect,
}: {
}: Readonly<{
open: boolean
onOpenChange: (open: boolean) => void
category?: ImageCategory
onSelect?: (image: ImagePick) => void
}) {
}>) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -16,10 +16,10 @@ import { formatClock } from './format'
export function BlockBuilder({
onSaved,
onError,
}: {
}: Readonly<{
onSaved: () => void
onError: (error: unknown) => void
}) {
}>) {
const { t } = useTranslation()
const [name, setName] = useState('')
const [items, setItems] = useState<ClipDragItem[]>([])
@@ -15,7 +15,7 @@ import { formatClock } from './format'
* Группы роликов собираются здесь же, а не в общем редакторе групп (см. 6.7): в выбранную группу
* перетаскиваются и отдельные ролики, и готовые блоки — стык умеет и то и другое.
*/
export function ClipGroupPanel({ onError }: { onError: (error: unknown) => void }) {
export function ClipGroupPanel({ onError }: Readonly<{ onError: (error: unknown) => void }>) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [selected, setSelected] = useState('')
@@ -48,7 +48,7 @@ export function InterstitialsPanel() {
const onError = useApiError()
const renameMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => renameShow(id, name),
mutationFn: ({ id, name }: Readonly<{ id: string; name: string }>) => renameShow(id, name),
onSuccess: () => {
setRenaming(null)
invalidate()
@@ -45,7 +45,7 @@ function formatSize(bytes: number): string {
* Номера сезона и серии распознаются здесь же и в таком виде и уходят на сервер: что показано,
* то и сохранится.
*/
export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
export function ManualInboxDialog({ onClose }: Readonly<{ onClose: () => void }>) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [selected, setSelected] = useState<string[]>([])
@@ -261,7 +261,7 @@ export function MediaPanel() {
)
}
function MediaRow({ asset, onDelete }: { asset: MediaAssetDto; onDelete: () => void }) {
function MediaRow({ asset, onDelete }: Readonly<{ asset: MediaAssetDto; onDelete: () => void }>) {
const { t } = useTranslation()
return (
<tr className="border-b border-border last:border-0">
@@ -3,7 +3,7 @@ import { AlertCircle, Check, ChevronDown, ChevronUp, Clock, Loader2, RotateCw, X
import { cn } from '@/shared/lib/cn'
import { type UploadItem, useUploadStore } from './upload-store'
function StatusIcon({ status }: { status: UploadItem['status'] }) {
function StatusIcon({ status }: Readonly<{ status: UploadItem['status'] }>) {
switch (status) {
case 'done':
return <Check className="h-3.5 w-3.5 shrink-0 text-primary" />
@@ -23,7 +23,10 @@ import { useUploadStore } from './upload-store'
/** Radix Select запрещает пустое значение — под «в библиотеку» используем спец-значение. */
const LIBRARY_VALUE = '__library__'
export function UploadToShowDialog({ files, onClose }: { files: File[]; onClose: () => void }) {
export function UploadToShowDialog({
files,
onClose,
}: Readonly<{ files: File[]; onClose: () => void }>) {
const { t } = useTranslation()
const enqueue = useUploadStore((s) => s.enqueue)
const [seasonStr, setSeasonStr] = useState('')
@@ -50,7 +50,7 @@ export function RolesPanel() {
})
const renameMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => updateRole(id, name),
mutationFn: ({ id, name }: Readonly<{ id: string; name: string }>) => updateRole(id, name),
onSuccess: invalidate,
onError,
})
@@ -44,7 +44,7 @@ function addButtonLabel(
return `${t('admin.shows.addSelected')} (${count})`
}
export function ShowDetail({ showId }: { showId: string }) {
export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [filter, setFilter] = useState('')
@@ -15,7 +15,10 @@ import { setShowGenres } from './api'
* Жанры шоу: бейджи в шапке карточки + диалог правки. Основной жанр отмечается отдельно —
* он показывается в списке шоу и участвует в отборе контента наравне с остальными.
*/
export function ShowGenresField({ show, onChanged }: { show: ShowDto; onChanged: () => void }) {
export function ShowGenresField({
show,
onChanged,
}: Readonly<{ show: ShowDto; onChanged: () => void }>) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [selected, setSelected] = useState<string[]>([])
@@ -28,7 +28,10 @@ import {
updateMetadata,
} from './api'
export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged: () => void }) {
export function ShowMetadataCard({
show,
onChanged,
}: Readonly<{ show: ShowDto; onChanged: () => void }>) {
const { t } = useTranslation()
const [galleryOpen, setGalleryOpen] = useState(false)
const [provider, setProvider] = useState('')
@@ -359,7 +362,7 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
}
/** Итог по сезону: чего не хватает — или что полный состав сезона неизвестен. */
function SeasonGapNote({ gap }: { gap: MissingEpisodesReport['seasons'][number] }) {
function SeasonGapNote({ gap }: Readonly<{ gap: MissingEpisodesReport['seasons'][number] }>) {
const { t } = useTranslation()
if (gap.expected == null)
@@ -64,7 +64,7 @@ export function UsersPanel() {
const unblockMutation = useMutation({ mutationFn: unblockUser, onSuccess: invalidate, onError })
const deleteMutation = useMutation({ mutationFn: deleteUser, onSuccess: invalidate, onError })
const changeRoleMutation = useMutation({
mutationFn: ({ userId, roleId: nextRoleId }: { userId: string; roleId: string }) =>
mutationFn: ({ userId, roleId: nextRoleId }: Readonly<{ userId: string; roleId: string }>) =>
changeUserRole(userId, nextRoleId),
onSuccess: invalidate,
onError,
@@ -302,11 +302,11 @@ function ResetPasswordDialog({
user,
onClose,
onError,
}: {
}: Readonly<{
user: UserSummaryDto
onClose: () => void
onError: (e: unknown) => void
}) {
}>) {
const { t } = useTranslation()
const [password, setPassword] = useState('')
+1 -1
View File
@@ -16,7 +16,7 @@ const schema = z.object({
type FormValues = z.infer<typeof schema>
export function LoginForm({ onSuccess }: { onSuccess: () => void }) {
export function LoginForm({ onSuccess }: Readonly<{ onSuccess: () => void }>) {
const { t } = useTranslation()
const {
register: registerField,
+1 -1
View File
@@ -16,7 +16,7 @@ const schema = z.object({
type FormValues = z.infer<typeof schema>
export function RegisterForm({ onSuccess }: { onSuccess: () => void }) {
export function RegisterForm({ onSuccess }: Readonly<{ onSuccess: () => void }>) {
const { t } = useTranslation()
const {
register: registerField,
+1 -1
View File
@@ -15,7 +15,7 @@ function formatTime(iso: string) {
}
/** Кадр серии, если он есть; иначе постер шоу (он вертикальный, отсюда другая ширина). */
function EntryThumb({ entry }: { entry: PublicEpgEntryDto | undefined }) {
function EntryThumb({ entry }: Readonly<{ entry: PublicEpgEntryDto | undefined }>) {
if (entry?.episodeStillImageId)
return (
<img
@@ -58,7 +58,7 @@ export function ChannelPlayer({
nextUp,
flash,
onUnavailable,
}: {
}: Readonly<{
slug: string
/** Канал, чьи оверлеи рисуем. Всё опционально: канал без логотипа и без шума — норма. */
channel?: PublicChannelDto
@@ -67,7 +67,7 @@ export function ChannelPlayer({
/** Показать чёрный кадр с номером — переключение по номерам, как на телевизоре. */
flash?: boolean
onUnavailable?: () => void
}) {
}>) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
@@ -19,11 +19,11 @@ export function ChannelLogo({
imageId,
corner,
opacity,
}: {
}: Readonly<{
imageId: string
corner: LogoCorner
opacity: number
}) {
}>) {
return (
<img
src={imageUrl(imageId)}
@@ -52,7 +52,7 @@ export function ScreenClock() {
}
/** Плашка «Далее: …» — данные уже есть в EPG, отдельного запроса не нужно. */
export function NextUpBanner({ title }: { title: string }) {
export function NextUpBanner({ title }: Readonly<{ title: string }>) {
const { t } = useTranslation()
return (
<span className="pointer-events-none absolute bottom-14 left-3 rounded bg-black/60 px-2 py-1 text-sm text-white">
@@ -65,7 +65,7 @@ export function NextUpBanner({ title }: { title: string }) {
* Аналоговый фильтр: лёгкий VHS-шум, дрожание и размытие краёв. Переборщить очень легко, поэтому
* сила регулируется, а вклад каждого слоя от неё убывает нелинейно.
*/
export function AnalogFilter({ strength }: { strength: number }) {
export function AnalogFilter({ strength }: Readonly<{ strength: number }>) {
const s = Math.min(1, Math.max(0, strength))
return (
<>
@@ -91,7 +91,7 @@ export function AnalogFilter({ strength }: { strength: number }) {
}
/** Короткий чёрный кадр с номером канала — как при переключении на телевизоре. */
export function ChannelFlash({ number, name }: { number: number | null; name: string }) {
export function ChannelFlash({ number, name }: Readonly<{ number: number | null; name: string }>) {
return (
<div className="pointer-events-none absolute inset-0 flex items-start justify-end bg-black">
<span className="m-6 flex items-baseline gap-2 text-white">
+1 -1
View File
@@ -8,7 +8,7 @@ import {
let nextId = 1
export function ToastProvider({ children }: { children: ReactNode }) {
export function ToastProvider({ children }: Readonly<{ children: ReactNode }>) {
const [toasts, setToasts] = useState<ToastItem[]>([])
const push = useCallback((message: string, variant: ToastVariant) => {
+1 -1
View File
@@ -18,6 +18,6 @@ const badgeVariants = cva(
export type BadgeProps = HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>
export function Badge({ className, variant, ...props }: BadgeProps) {
export function Badge({ className, variant, ...props }: Readonly<BadgeProps>) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { cn } from '@/shared/lib/cn'
* Мини-плеер HLS для админки: плейлист и сегменты лежат под admin-роутами (JWT), поэтому запросы
* идут через hls.js с Bearer-заголовком. Нативный путь (Safari) — только там, где hls.js не нужен.
*/
export function HlsVideo({ src, className }: { src: string; className?: string }) {
export function HlsVideo({ src, className }: Readonly<{ src: string; className?: string }>) {
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
+2 -2
View File
@@ -6,11 +6,11 @@ export function Pager({
page,
totalPages,
onChange,
}: {
}: Readonly<{
page: number
totalPages: number
onChange: (page: number) => void
}) {
}>) {
const { t } = useTranslation()
if (totalPages <= 1) return null
+2 -2
View File
@@ -9,13 +9,13 @@ export function SortHeader({
sort,
onToggle,
className,
}: {
}: Readonly<{
label: string
sortKey: string
sort: SortState
onToggle: (key: string) => void
className?: string
}) {
}>) {
const active = sort.key === sortKey
const direction = sort.desc ? 'descending' : 'ascending'
let Icon = ChevronsUpDown
+2 -2
View File
@@ -21,12 +21,12 @@ function ToastItem({
message,
variant,
onDismiss,
}: {
}: Readonly<{
id: number
message: string
variant: 'default' | 'success' | 'error'
onDismiss: (id: number) => void
}) {
}>) {
const { t } = useTranslation()
useEffect(() => {
+1 -1
View File
@@ -13,7 +13,7 @@ function applyTheme(theme: Theme) {
root.classList.toggle('dark', resolve(theme) === 'dark')
}
export function ThemeProvider({ children }: { children: ReactNode }) {
export function ThemeProvider({ children }: Readonly<{ children: ReactNode }>) {
const [theme, setThemeState] = useState<Theme>(
() => (localStorage.getItem(THEME_STORAGE_KEY) as Theme | null) ?? 'dark',
)