Refactor various components to improve code clarity and maintainability. Update ListUsersQueryHandler to utilize UserListFilter for parameter handling. Refactor BumperSpecFactory and related classes to encapsulate input parameters into dedicated records, enhancing readability. Adjust MediaAsset and Slot classes to streamline content updates with new content models. Improve BumperRenderBackgroundService and MediaProcessingBackgroundService to use MediaReadyInfo for asset readiness, ensuring consistent parameter management across the application.
This commit is contained in:
@@ -66,8 +66,10 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
setApplyOpen(false)
|
||||
toast.success(t('admin.channels.applied', { count: result.added }))
|
||||
// Предупреждения показываем по одному: каждое указывает на конкретный слот.
|
||||
for (const warning of result.warnings)
|
||||
toast.error(`${t(`admin.channels.warnings.${warning.kind}`)}: ${warning.details}`)
|
||||
for (const warning of result.warnings) {
|
||||
const kind = t(`admin.channels.warnings.${warning.kind}`)
|
||||
toast.error(`${kind}: ${warning.details}`)
|
||||
}
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
|
||||
@@ -39,6 +39,23 @@ const KIND_COLORS: Record<JunctionElementKind, string> = {
|
||||
Filler: 'bg-muted-foreground/40',
|
||||
}
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t']
|
||||
|
||||
/** Что уточняет подпись врезки: имя блока заставки либо объём («×3» / «×5 мин»). */
|
||||
function elementSuffix(element: JunctionElementDto, t: Translate) {
|
||||
if (element.kind === 'Bumper')
|
||||
return element.bumperTemplateName ? ` · ${element.bumperTemplateName}` : ''
|
||||
|
||||
const units = element.amountMode === 'Duration' ? t('admin.channels.minutesShort') : ''
|
||||
return ` ×${element.amountValue}${units}`
|
||||
}
|
||||
|
||||
/** Подсказка сегмента линейки: вид врезки и её оценочная длительность. */
|
||||
function elementTitle(element: JunctionElementDto, seconds: number, t: Translate) {
|
||||
const kind = t(`admin.channels.junctionKinds.${element.kind}`)
|
||||
return `${kind} · ${formatClock(seconds)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Прикидка длины врезки для линейки. По длительности — точная, по количеству — среднее единицы
|
||||
* группы: в смешанной группе «одна единица» это то ли ролик, то ли блок, поэтому число заведомо
|
||||
@@ -280,11 +297,7 @@ function JunctionChain({
|
||||
)}
|
||||
>
|
||||
{t(`admin.channels.junctionKinds.${element.kind}`)}
|
||||
{element.kind === 'Bumper'
|
||||
? element.bumperTemplateName
|
||||
? ` · ${element.bumperTemplateName}`
|
||||
: ''
|
||||
: ` ×${element.amountValue}${element.amountMode === 'Duration' ? t('admin.channels.minutesShort') : ''}`}
|
||||
{elementSuffix(element, t)}
|
||||
{element.isRequired && ' *'}
|
||||
</button>
|
||||
</span>
|
||||
@@ -303,7 +316,7 @@ function JunctionChain({
|
||||
key={element.id}
|
||||
className={KIND_COLORS[element.kind]}
|
||||
style={{ width: `${(estimates[index].seconds / total) * 100}%` }}
|
||||
title={`${t(`admin.channels.junctionKinds.${element.kind}`)} · ${formatClock(estimates[index].seconds)}`}
|
||||
title={elementTitle(element, estimates[index].seconds, t)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,53 @@ import type { ScheduleEntryDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { formatTime } from '../lib/format'
|
||||
|
||||
/** Что стоит в строке расписания: реклама, заставка-переход или программа с номером серии. */
|
||||
function EntryLabel({ entry }: { entry: ScheduleEntryDto }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (entry.kind === 'Ad') return <Badge variant="muted">{t('air.ad')}</Badge>
|
||||
|
||||
if (entry.kind === 'Bumper')
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="muted" className="shrink-0">
|
||||
{t('air.bumper')}
|
||||
</Badge>
|
||||
{(entry.bumperName || entry.bumperText) && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">
|
||||
{entry.bumperName}
|
||||
{entry.bumperName && entry.bumperText ? ' · ' : ''}
|
||||
{entry.bumperText}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<span>
|
||||
{entry.showName ?? '—'}
|
||||
<EpisodeSuffix entry={entry} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** «· S02E05» либо «· серия N» — что удалось распознать; ничего, если ни того ни другого нет. */
|
||||
function EpisodeSuffix({ entry }: { entry: ScheduleEntryDto }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (entry.seasonEpisode)
|
||||
return <span className="text-muted-foreground"> · {entry.seasonEpisode}</span>
|
||||
|
||||
if (entry.episodeIndex == null) return null
|
||||
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
· {t('air.episode')} {entry.episodeIndex + 1}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function SchedulePreview({
|
||||
entries,
|
||||
onShowTrace,
|
||||
@@ -22,36 +69,7 @@ export function SchedulePreview({
|
||||
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
||||
{formatTime(e.startsAtUtc)}
|
||||
</span>
|
||||
{e.kind === 'Ad' ? (
|
||||
<Badge variant="muted">{t('air.ad')}</Badge>
|
||||
) : e.kind === 'Bumper' ? (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="muted" className="shrink-0">
|
||||
{t('air.bumper')}
|
||||
</Badge>
|
||||
{(e.bumperName || e.bumperText) && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">
|
||||
{e.bumperName}
|
||||
{e.bumperName && e.bumperText ? ' · ' : ''}
|
||||
{e.bumperText}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{e.showName ?? '—'}
|
||||
{e.seasonEpisode ? (
|
||||
<span className="text-muted-foreground"> · {e.seasonEpisode}</span>
|
||||
) : (
|
||||
e.episodeIndex != null && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
· {t('air.episode')} {e.episodeIndex + 1}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<EntryLabel entry={e} />
|
||||
<button
|
||||
type="button"
|
||||
title={t('admin.channels.whyHere')}
|
||||
|
||||
@@ -21,7 +21,7 @@ const KIND_COLORS: Record<PlannedItemKind, string> = {
|
||||
}
|
||||
|
||||
/** Что видит зритель как программу — врезки в программу передач не попадают. */
|
||||
const PROGRAMME_KINDS: PlannedItemKind[] = ['Program', 'Fallback', 'SignOff']
|
||||
const PROGRAMME_KINDS = new Set<PlannedItemKind>(['Program', 'Fallback', 'SignOff'])
|
||||
|
||||
/**
|
||||
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
|
||||
@@ -99,7 +99,7 @@ export function TemplatePreview({ channelId }: { channelId: string }) {
|
||||
|
||||
function Programme({ preview }: { preview: SchedulePreviewDto }) {
|
||||
const { t } = useTranslation()
|
||||
const items = preview.items.filter((i) => PROGRAMME_KINDS.includes(i.kind))
|
||||
const items = preview.items.filter((i) => PROGRAMME_KINDS.has(i.kind))
|
||||
|
||||
if (items.length === 0)
|
||||
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||
@@ -202,11 +202,14 @@ function TapeRow({ item, preview }: { item: PreviewItemDto; preview: SchedulePre
|
||||
/** Предупреждения, сгруппированные по виду: десять однотипных строк читаются как одна проблема. */
|
||||
function Problems({ preview }: { preview: SchedulePreviewDto }) {
|
||||
const { t } = useTranslation()
|
||||
// Ключ строки — «вид + порядковый номер внутри вида»: у предупреждения нет своего id, а текст
|
||||
// повторяется (одна и та же причина на разных слотах), и позиция здесь — единственное, что его
|
||||
// отличает. Считаем ключ при группировке, чтобы список не пересобирался на каждый рендер.
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, string[]>()
|
||||
const map = new Map<string, { key: string; text: string }[]>()
|
||||
for (const warning of preview.warnings) {
|
||||
const list = map.get(warning.kind) ?? []
|
||||
list.push(warning.details)
|
||||
list.push({ key: `${warning.kind}#${list.length}`, text: warning.details })
|
||||
map.set(warning.kind, list)
|
||||
}
|
||||
return [...map.entries()]
|
||||
@@ -223,9 +226,9 @@ function Problems({ preview }: { preview: SchedulePreviewDto }) {
|
||||
<span className="font-medium text-amber-500">
|
||||
{t(`admin.channels.warnings.${kind}`)} · {details.length}
|
||||
</span>
|
||||
{details.slice(0, 20).map((detail, index) => (
|
||||
<span key={index} className="text-muted-foreground">
|
||||
{detail}
|
||||
{details.slice(0, 20).map((detail) => (
|
||||
<span key={detail.key} className="text-muted-foreground">
|
||||
{detail.text}
|
||||
</span>
|
||||
))}
|
||||
{details.length > 20 && (
|
||||
|
||||
@@ -142,9 +142,15 @@ export function GalleryBrowser({
|
||||
</div>
|
||||
|
||||
<div className="mt-3 max-h-[55vh] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
{isLoading && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
|
||||
) : images && images.length > 0 ? (
|
||||
)}
|
||||
{!isLoading && sorted.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{t('admin.gallery.empty')}
|
||||
</p>
|
||||
)}
|
||||
{!isLoading && sorted.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5">
|
||||
{sorted.map((img) => (
|
||||
<div key={img.id} className="group relative">
|
||||
@@ -175,10 +181,6 @@ export function GalleryBrowser({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{t('admin.gallery.empty')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -9,5 +9,6 @@ export function formatClock(seconds: number | null | undefined): string {
|
||||
const m = Math.floor(total / 60) % 60
|
||||
const h = Math.floor(total / 3600)
|
||||
const mm = h > 0 ? String(m).padStart(2, '0') : String(m)
|
||||
return `${h > 0 ? `${h}:` : ''}${mm}:${String(s).padStart(2, '0')}`
|
||||
const hh = h > 0 ? `${h}:` : ''
|
||||
return `${hh}${mm}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
@@ -32,11 +32,11 @@ export function UploadSnackbar() {
|
||||
|
||||
const done = items.filter((i) => i.status === 'done').length
|
||||
const hasItems = items.length > 0
|
||||
const header = hasItems
|
||||
? active
|
||||
? t('admin.media.uploadingCount', { done, total: items.length })
|
||||
: t('admin.media.uploadedCount', { count: done })
|
||||
: t('admin.media.skippedDuplicates', { count: skipped })
|
||||
|
||||
let header: string
|
||||
if (!hasItems) header = t('admin.media.skippedDuplicates', { count: skipped })
|
||||
else if (active) header = t('admin.media.uploadingCount', { done, total: items.length })
|
||||
else header = t('admin.media.uploadedCount', { count: done })
|
||||
|
||||
return (
|
||||
<div className="crt-panel fixed bottom-4 right-4 z-50 w-96 max-w-[calc(100vw-2rem)] rounded-md shadow-lg">
|
||||
|
||||
@@ -34,6 +34,16 @@ type Candidate = { asset: MediaAssetDto; parsed: ParsedEpisode }
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
/** Пока идёт пакетное добавление — прогресс вместо подписи; серии добавляются по одной. */
|
||||
function addButtonLabel(
|
||||
progress: { current: number; total: number } | null,
|
||||
count: number,
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
) {
|
||||
if (progress) return `${progress.current}/${progress.total}`
|
||||
return `${t('admin.shows.addSelected')} (${count})`
|
||||
}
|
||||
|
||||
export function ShowDetail({ showId }: { showId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -231,9 +241,7 @@ export function ShowDetail({ showId }: { showId: string }) {
|
||||
{t('admin.shows.deselectAll')}
|
||||
</Button>
|
||||
<Button size="sm" disabled={selected.length === 0 || adding != null} onClick={() => void bulkAdd()}>
|
||||
{adding
|
||||
? `${adding.current}/${adding.total}`
|
||||
: `${t('admin.shows.addSelected')} (${isSingle ? Math.min(1, selected.length) : selected.length})`}
|
||||
{addButtonLabel(adding, isSingle ? Math.min(1, selected.length) : selected.length, t)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -347,20 +347,7 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{s.expected == null ? (
|
||||
<p className="mt-1 text-xs text-amber-500">
|
||||
{t('admin.metadata.missingUnknown')}
|
||||
</p>
|
||||
) : s.missing.length === 0 ? (
|
||||
<p className="mt-1 text-xs text-emerald-500">
|
||||
{t('admin.metadata.missingNone')}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{t('admin.metadata.missingList')}:{' '}
|
||||
<span className="text-foreground">{s.missing.join(', ')}</span>
|
||||
</p>
|
||||
)}
|
||||
<SeasonGapNote gap={s} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -370,3 +357,21 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Итог по сезону: чего не хватает — или что полный состав сезона неизвестен. */
|
||||
function SeasonGapNote({ gap }: { gap: MissingEpisodesReport['seasons'][number] }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (gap.expected == null)
|
||||
return <p className="mt-1 text-xs text-amber-500">{t('admin.metadata.missingUnknown')}</p>
|
||||
|
||||
if (gap.missing.length === 0)
|
||||
return <p className="mt-1 text-xs text-emerald-500">{t('admin.metadata.missingNone')}</p>
|
||||
|
||||
return (
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{t('admin.metadata.missingList')}:{' '}
|
||||
<span className="text-foreground">{gap.missing.join(', ')}</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,12 +30,10 @@ export function LoginForm({ onSuccess }: { onSuccess: () => void }) {
|
||||
applyAuthResponse(auth)
|
||||
onSuccess()
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof HttpError && error.status === 401
|
||||
? t('auth.invalidCredentials')
|
||||
: error instanceof HttpError && error.status === 403
|
||||
? t('auth.blocked')
|
||||
: t('auth.genericError')
|
||||
const status = error instanceof HttpError ? error.status : 0
|
||||
let message = t('auth.genericError')
|
||||
if (status === 401) message = t('auth.invalidCredentials')
|
||||
else if (status === 403) message = t('auth.blocked')
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,29 @@ function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
/** Кадр серии, если он есть; иначе постер шоу (он вертикальный, отсюда другая ширина). */
|
||||
function EntryThumb({ entry }: { 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)
|
||||
@@ -194,49 +217,38 @@ export function AirPage() {
|
||||
</aside>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{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>
|
||||
) : (
|
||||
<ChannelPlayer
|
||||
key={`${selected}-${attempt}`}
|
||||
slug={selected}
|
||||
channel={currentChannel}
|
||||
nextUp={nextUp}
|
||||
flash={flash}
|
||||
onUnavailable={handleUnavailable}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
{/* До выдачи 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">
|
||||
{currentEntry?.episodeStillImageId ? (
|
||||
<img
|
||||
src={imageUrl(currentEntry.episodeStillImageId)}
|
||||
alt=""
|
||||
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
|
||||
/>
|
||||
) : currentEntry?.showPosterImageId ? (
|
||||
<img
|
||||
src={imageUrl(currentEntry.showPosterImageId)}
|
||||
alt=""
|
||||
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
|
||||
/>
|
||||
) : null}
|
||||
<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>
|
||||
|
||||
@@ -25,5 +25,6 @@ export function getEpg(slug: string, from?: Date, to?: Date) {
|
||||
if (from) query.set('from', from.toISOString())
|
||||
if (to) query.set('to', to.toISOString())
|
||||
const qs = query.toString()
|
||||
return apiRequest<PublicEpgEntryDto[]>(`/channels/${slug}/epg${qs ? `?${qs}` : ''}`)
|
||||
const suffix = qs ? `?${qs}` : ''
|
||||
return apiRequest<PublicEpgEntryDto[]>(`/channels/${slug}/epg${suffix}`)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export function sortRows<T>(
|
||||
if (av == null) return 1
|
||||
if (bv == null) return -1
|
||||
if (typeof av === 'string' && typeof bv === 'string') return av.localeCompare(bv) * dir
|
||||
return (av < bv ? -1 : av > bv ? 1 : 0) * dir
|
||||
if (av < bv) return -dir
|
||||
if (av > bv) return dir
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,13 +17,15 @@ export function SortHeader({
|
||||
className?: string
|
||||
}) {
|
||||
const active = sort.key === sortKey
|
||||
const Icon = !active ? ChevronsUpDown : sort.desc ? ArrowDown : ArrowUp
|
||||
const direction = sort.desc ? 'descending' : 'ascending'
|
||||
let Icon = ChevronsUpDown
|
||||
if (active) Icon = sort.desc ? ArrowDown : ArrowUp
|
||||
return (
|
||||
// aria-sort — атрибут заголовка столбца, а не кнопки внутри него: у роли button его нет,
|
||||
// и скринридер там его просто не прочтёт.
|
||||
<th
|
||||
className={cn('px-4 py-2 font-medium', className)}
|
||||
aria-sort={active ? (sort.desc ? 'descending' : 'ascending') : 'none'}
|
||||
aria-sort={active ? direction : 'none'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user