import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' import Hls from 'hls.js' import { type ReactNode, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ChevronDown, ChevronLeft, RefreshCw } from 'lucide-react' import { imageUrl } from '@/features/admin/images/api' import { ImageGallery } from '@/features/admin/images/ImageGallery' import { getAccessToken, HttpError } from '@/shared/api/client' import type { AdInsertion, BlockMode, BumperFont, BumperSelection, BumperSettings, BumperTemplateDto, BumperTextKind, BumperTextVariantDto, BumperTrigger, ChannelShowDto, OverrideMode, ScheduleEntryDto, } from '@/shared/api/types' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardTitle } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { toast } from '@/shared/ui/toast-store' import { listMedia } from '@/features/admin/media/api' import { listShows } from '@/features/admin/shows/api' import { addBumperTemplate, addBumperVariant, addChannelAd, addChannelShow, bumperPreviewPlaylistUrl, clearBumperTemplateAudio, clearBumperTemplateBackground, createOverride, deleteOverride, getChannel, getSchedule, regenerateSchedule, removeBumperTemplate, removeBumperVariant, removeChannelAd, removeChannelShow, renderBumperPreview, setBumperTemplateBackground, updateBumperTemplate, updateBumperVariant, updateChannelSettings, updateChannelShow, uploadBumperTemplateAudio, } from './api' function formatTime(iso: string) { return new Date(iso).toLocaleString([], { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', }) } export function ChannelDetail({ channelId }: { channelId: string }) { const { t } = useTranslation() const queryClient = useQueryClient() const { data: channel, isLoading } = useQuery({ queryKey: ['admin', 'channels', channelId], queryFn: () => getChannel(channelId), }) const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows }) const { data: ready } = useQuery({ queryKey: ['admin', 'media', 'ready'], queryFn: () => listMedia({ page: 1, pageSize: 100, statuses: ['Ready'] }), }) const { data: schedule } = useQuery({ queryKey: ['admin', 'channels', channelId, 'schedule'], queryFn: () => getSchedule(channelId, new Date(), new Date(Date.now() + 12 * 3600_000)), }) const invalidate = () => { void queryClient.invalidateQueries({ queryKey: ['admin', 'channels', channelId] }) } const invalidateSchedule = () => queryClient.invalidateQueries({ queryKey: ['admin', 'channels', channelId, 'schedule'] }) const onError = (error: unknown) => toast.error(error instanceof HttpError ? error.detail : t('common.error')) const regenerateMutation = useMutation({ mutationFn: () => regenerateSchedule(channelId), onSuccess: () => { toast.success(t('admin.channels.regenerated')) void invalidateSchedule() }, onError, }) if (isLoading || !channel) return

{t('common.loading')}

const availableShows = shows?.filter((s) => !channel.shows.some((cs) => cs.showId === s.id)) ?? [] return (

{channel.name}

{channel.slug}
{/* Шоу канала */} ({ id: s.id, name: s.name }))} onAdded={invalidate} onError={onError} /> {channel.shows.map((row) => ( ))} {channel.shows.length === 0 && ( )}
{t('admin.channels.show')} {t('admin.channels.weight')} {t('admin.channels.block')} {t('admin.channels.on')} {t('common.actions')}
{t('admin.channels.noShows')}
{/* Реклама */} !channel.ads.some((ad) => ad.mediaAssetId === a.id)) .map((a) => ({ id: a.id, name: a.originalFileName }))} onAdded={invalidate} onError={onError} />
    {channel.ads.map((ad) => (
  • {ad.assetName ?? '—'} removeChannelAd(channelId, ad.id).then(invalidate).catch(onError) } />
  • ))} {channel.ads.length === 0 && (
  • {t('admin.channels.noAds')}
  • )}
{/* Override'ы / марафоны */} ({ id: cs.showId, name: cs.showName }))} onCreated={invalidate} onError={onError} />
    {channel.overrides.map((o) => (
  • {t(`admin.channels.modes.${o.mode}`)}{' '} {formatTime(o.startsAtUtc)} – {formatTime(o.endsAtUtc)} ·{' '} {o.shows.map((s) => s.showName).join(', ')} deleteOverride(channelId, o.id).then(invalidate).catch(onError)} />
  • ))} {channel.overrides.length === 0 && (
  • {t('admin.channels.noOverrides')}
  • )}
{/* Предпросмотр расписания */}
) } /** Карточка со сворачиваемым содержимым: клик по заголовку скрывает/раскрывает блок. */ function CollapsibleCard({ title, defaultOpen = false, contentClassName, children, }: { title: string defaultOpen?: boolean contentClassName?: string children: ReactNode }) { const [open, setOpen] = useState(defaultOpen) return ( {open && {children}} ) } function SettingsCard({ channel, readyAssets, onSaved, onError, }: { channel: import('@/shared/api/types').ChannelDto readyAssets: { id: string; originalFileName: string }[] onSaved: () => void onError: (e: unknown) => void }) { const { t } = useTranslation() const [name, setName] = useState(channel.name) const [isEnabled, setIsEnabled] = useState(channel.isEnabled) const [adInsertion, setAdInsertion] = useState(channel.adInsertion) const [adsPerBreak, setAdsPerBreak] = useState(channel.adsPerBreak) const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '') useEffect(() => { setName(channel.name) setIsEnabled(channel.isEnabled) setAdInsertion(channel.adInsertion) setAdsPerBreak(channel.adsPerBreak) setFillerAssetId(channel.fillerAssetId ?? '') }, [channel]) const save = useMutation({ mutationFn: () => updateChannelSettings(channel.id, { name: name.trim(), isEnabled, adInsertion, adsPerBreak, // Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений. bumpersEnabled: channel.bumpersEnabled, bumper: channel.bumper, fillerAssetId: fillerAssetId || null, }), onSuccess: () => { toast.success(t('settings.saved')) onSaved() }, onError, }) return (
setName(e.target.value)} />
setAdsPerBreak(Number(e.target.value))} />
) } /** ffmpeg-цвет (0xRRGGBB / имя) → CSS для превью-плашки. */ function cssColor(value: string): string { const v = value.trim() if (v.startsWith('0x')) return `#${v.slice(2)}` return v } function BumperCard({ channel, onSaved, onError, }: { channel: import('@/shared/api/types').ChannelDto onSaved: () => void onError: (e: unknown) => void }) { const { t } = useTranslation() const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled) const [bumper, setBumper] = useState(channel.bumper) const setField = (key: K, value: BumperSettings[K]) => setBumper((prev) => ({ ...prev, [key]: value })) useEffect(() => { setBumpersEnabled(channel.bumpersEnabled) setBumper(channel.bumper) }, [channel]) // Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные // поля берём из канала без изменений (они правятся в своей карточке). const save = useMutation({ mutationFn: () => updateChannelSettings(channel.id, { name: channel.name, isEnabled: channel.isEnabled, adInsertion: channel.adInsertion, adsPerBreak: channel.adsPerBreak, bumpersEnabled, bumper, fillerAssetId: channel.fillerAssetId, }), onSuccess: () => { toast.success(t('settings.saved')) onSaved() }, onError, }) const addTemplate = useMutation({ mutationFn: () => addBumperTemplate(channel.id, ''), onSuccess: onSaved, onError, }) const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position) return ( {/* Общие настройки */}
setField('minIntervalMinutes', Number(e.target.value))} />
{/* Блоки заставок */}

{t('admin.channels.bumperTemplates')}

{t('admin.channels.bumperTemplatesHint')}

{templates.map((template) => ( ))}
) } function BumperTemplateEditor({ channelId, template, onChanged, onError, }: { channelId: string template: BumperTemplateDto onChanged: () => void onError: (e: unknown) => void }) { const { t } = useTranslation() const [open, setOpen] = useState(false) const [name, setName] = useState(template.name) const [colors, setColors] = useState({ backgroundColor: template.backgroundColor, backgroundColor2: template.backgroundColor2, accentColor: template.accentColor, textColor: template.textColor, }) useEffect(() => { setName(template.name) setColors({ backgroundColor: template.backgroundColor, backgroundColor2: template.backgroundColor2, accentColor: template.accentColor, textColor: template.textColor, }) }, [template]) const save = useMutation({ mutationFn: () => updateBumperTemplate(channelId, template.id, { name: name.trim(), ...colors }), onSuccess: () => { toast.success(t('settings.saved')) onChanged() }, onError, }) const remove = useMutation({ mutationFn: () => removeBumperTemplate(channelId, template.id), onSuccess: onChanged, onError, }) const addVariant = useMutation({ mutationFn: () => addBumperVariant(channelId, template.id, ''), onSuccess: onChanged, onError, }) const colorFields: { key: keyof typeof colors; label: string }[] = [ { key: 'backgroundColor', label: t('admin.channels.bumperBg') }, { key: 'backgroundColor2', label: t('admin.channels.bumperBg2') }, { key: 'accentColor', label: t('admin.channels.bumperAccent') }, { key: 'textColor', label: t('admin.channels.bumperText') }, ] return (
setOpen((o) => !o)} >
{template.name} {template.isDefault && {t('admin.channels.bumperDefault')}} {template.hasAudio && template.audioDurationSeconds != null ? `≈${Math.round(template.audioDurationSeconds)} ${t('admin.channels.bumperSeconds')}` : t('admin.channels.bumperDefaultDuration')}
{!template.isDefault && ( )}
{open && ( <>
setName(e.target.value)} />
{colorFields.map(({ key, label }) => (
setColors((c) => ({ ...c, [key]: e.target.value }))} />
))}
{/* Подблоки (текст-варианты) */}

{t('admin.channels.bumperVariants')}

{t('admin.channels.bumperVariantsHint')}

{[...template.variants] .sort((a, b) => a.position - b.position) .map((variant) => ( 1} onChanged={onChanged} onError={onError} /> ))}
)}
) } function BumperVariantEditor({ channelId, templateId, variant, canRemove, onChanged, onError, }: { channelId: string templateId: string variant: BumperTextVariantDto canRemove: boolean onChanged: () => void onError: (e: unknown) => void }) { const { t } = useTranslation() const [form, setForm] = useState({ name: variant.name, kind: variant.kind, nowLabel: variant.nowLabel, nextLabel: variant.nextLabel, line1: variant.line1, line2: variant.line2, trigger: variant.trigger, }) useEffect(() => { setForm({ name: variant.name, kind: variant.kind, nowLabel: variant.nowLabel, nextLabel: variant.nextLabel, line1: variant.line1, line2: variant.line2, trigger: variant.trigger, }) }, [variant]) const set = (key: K, value: (typeof form)[K]) => setForm((f) => ({ ...f, [key]: value })) const save = useMutation({ mutationFn: () => updateBumperVariant(channelId, templateId, variant.id, { ...form, name: form.name.trim() }), onSuccess: () => { toast.success(t('settings.saved')) onChanged() }, onError, }) const remove = useMutation({ mutationFn: () => removeBumperVariant(channelId, templateId, variant.id), onSuccess: onChanged, onError, }) return (
set('name', e.target.value)} />
{form.kind === 'NowNext' ? ( <>
set('nowLabel', e.target.value)} />
set('nextLabel', e.target.value)} />
) : ( <>
set('line1', e.target.value)} />
set('line2', e.target.value)} />
)}
{canRemove && ( )}
) } function BumperPreviewPlayer({ channelId, templateId, onError, }: { channelId: string templateId: string onError: (e: unknown) => void }) { const { t } = useTranslation() const videoRef = useRef(null) const [ready, setReady] = useState(false) const [bust, setBust] = useState(0) const render = useMutation({ mutationFn: () => renderBumperPreview(channelId, templateId), onSuccess: () => { setBust(Date.now()) setReady(true) }, onError, }) // Грузим отрендеренный превью-плейлист через hls.js, добавляя Bearer-токен (admin-роут под JWT). useEffect(() => { if (!ready) return const video = videoRef.current if (!video) return const src = `${bumperPreviewPlaylistUrl(channelId, templateId)}?t=${bust}` let hls: Hls | null = null if (Hls.isSupported()) { hls = new Hls({ xhrSetup: (xhr) => { const token = getAccessToken() if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`) }, }) hls.loadSource(src) hls.attachMedia(video) } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = src } return () => { hls?.destroy() } }, [ready, bust, channelId, templateId]) return ( <>
{t('admin.channels.bumperPreviewHint')}
{ready && (