Files
TeleWave/frontend/src/features/admin/channels/ChannelDetail.tsx
T

1420 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <p className="text-muted-foreground">{t('common.loading')}</p>
const availableShows = shows?.filter((s) => !channel.shows.some((cs) => cs.showId === s.id)) ?? []
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Button asChild size="sm" variant="ghost">
<Link to="/admin/channels">
<ChevronLeft className="h-4 w-4" />
{t('admin.channels.title')}
</Link>
</Button>
<h2 className="crt-glow text-xl font-semibold">{channel.name}</h2>
<Badge variant="muted">{channel.slug}</Badge>
</div>
<Button
size="sm"
variant="outline"
disabled={regenerateMutation.isPending}
onClick={() => regenerateMutation.mutate()}
>
<RefreshCw className="h-4 w-4" />
{t('admin.channels.regenerate')}
</Button>
</div>
<SettingsCard channel={channel} readyAssets={ready?.items ?? []} onSaved={invalidate} onError={onError} />
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
{/* Шоу канала */}
<CollapsibleCard title={t('admin.channels.shows')} contentClassName="flex flex-col gap-3">
<AddShowForm
channelId={channelId}
options={availableShows.map((s) => ({ id: s.id, name: s.name }))}
onAdded={invalidate}
onError={onError}
/>
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<th className="py-2 font-medium">{t('admin.channels.show')}</th>
<th className="py-2 font-medium">{t('admin.channels.weight')}</th>
<th className="py-2 font-medium">{t('admin.channels.block')}</th>
<th className="py-2 font-medium">{t('admin.channels.on')}</th>
<th className="py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{channel.shows.map((row) => (
<ChannelShowRow
key={row.id}
channelId={channelId}
row={row}
onChanged={invalidate}
onError={onError}
/>
))}
{channel.shows.length === 0 && (
<tr>
<td className="py-3 text-muted-foreground" colSpan={5}>
{t('admin.channels.noShows')}
</td>
</tr>
)}
</tbody>
</table>
</CollapsibleCard>
{/* Реклама */}
<CollapsibleCard title={t('admin.channels.ads')} contentClassName="flex flex-col gap-3">
<AddAdForm
channelId={channelId}
options={(ready?.items ?? [])
.filter((a) => !channel.ads.some((ad) => ad.mediaAssetId === a.id))
.map((a) => ({ id: a.id, name: a.originalFileName }))}
onAdded={invalidate}
onError={onError}
/>
<ul className="flex flex-col divide-y divide-border">
{channel.ads.map((ad) => (
<li key={ad.id} className="flex items-center justify-between py-2 text-sm">
<span>{ad.assetName ?? '—'}</span>
<RemoveButton
onClick={() =>
removeChannelAd(channelId, ad.id).then(invalidate).catch(onError)
}
/>
</li>
))}
{channel.ads.length === 0 && (
<li className="py-2 text-muted-foreground">{t('admin.channels.noAds')}</li>
)}
</ul>
</CollapsibleCard>
{/* Override'ы / марафоны */}
<CollapsibleCard title={t('admin.channels.overrides')} contentClassName="flex flex-col gap-3">
<OverrideForm
channelId={channelId}
options={channel.shows.map((cs) => ({ id: cs.showId, name: cs.showName }))}
onCreated={invalidate}
onError={onError}
/>
<ul className="flex flex-col divide-y divide-border">
{channel.overrides.map((o) => (
<li key={o.id} className="flex items-center justify-between py-2 text-sm">
<span>
<Badge variant="muted">{t(`admin.channels.modes.${o.mode}`)}</Badge>{' '}
{formatTime(o.startsAtUtc)} {formatTime(o.endsAtUtc)} ·{' '}
{o.shows.map((s) => s.showName).join(', ')}
</span>
<RemoveButton
onClick={() => deleteOverride(channelId, o.id).then(invalidate).catch(onError)}
/>
</li>
))}
{channel.overrides.length === 0 && (
<li className="py-2 text-muted-foreground">{t('admin.channels.noOverrides')}</li>
)}
</ul>
</CollapsibleCard>
{/* Предпросмотр расписания */}
<CollapsibleCard title={t('admin.channels.schedule')}>
<SchedulePreview entries={schedule ?? []} />
</CollapsibleCard>
</div>
)
}
/** Карточка со сворачиваемым содержимым: клик по заголовку скрывает/раскрывает блок. */
function CollapsibleCard({
title,
defaultOpen = false,
contentClassName,
children,
}: {
title: string
defaultOpen?: boolean
contentClassName?: string
children: ReactNode
}) {
const [open, setOpen] = useState(defaultOpen)
return (
<Card>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex w-full items-center justify-between gap-2 p-6 text-left"
>
<CardTitle>{title}</CardTitle>
<ChevronDown
className={`h-5 w-5 shrink-0 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && <CardContent className={contentClassName}>{children}</CardContent>}
</Card>
)
}
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<AdInsertion>(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 (
<CollapsibleCard
title={t('admin.channels.settings')}
defaultOpen
contentClassName="grid gap-4 sm:grid-cols-2"
>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.name')}</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.adPolicy')}</Label>
<Select value={adInsertion} onValueChange={(v) => setAdInsertion(v as AdInsertion)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="BetweenBlocks">{t('admin.channels.betweenBlocks')}</SelectItem>
<SelectItem value="BetweenEpisodes">{t('admin.channels.betweenEpisodes')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.adsPerBreak')}</Label>
<Input
type="number"
min={0}
max={10}
value={adsPerBreak}
onChange={(e) => setAdsPerBreak(Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.filler')}</Label>
<Select value={fillerAssetId || 'none'} onValueChange={(v) => setFillerAssetId(v === 'none' ? '' : v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('admin.channels.noFiller')}</SelectItem>
{readyAssets.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.originalFileName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={isEnabled}
onChange={(e) => setIsEnabled(e.target.checked)}
/>
{t('admin.channels.enabledLabel')}
</label>
<div className="flex items-end justify-end sm:col-span-2">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</CollapsibleCard>
)
}
/** 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<BumperSettings>(channel.bumper)
const setField = <K extends keyof BumperSettings>(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 (
<CollapsibleCard title={t('admin.channels.bumpers')} contentClassName="flex flex-col gap-4">
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
className="mt-1"
checked={bumpersEnabled}
onChange={(e) => setBumpersEnabled(e.target.checked)}
/>
<span>
{t('admin.channels.bumpersLabel')}
<span className="block text-xs text-muted-foreground">
{t('admin.channels.bumpersHint')}
</span>
</span>
</label>
{/* Общие настройки */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperSelection')}</Label>
<Select
value={bumper.selection}
onValueChange={(v) => setField('selection', v as BumperSelection)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Rotation">{t('admin.channels.bumperSelectionRotation')}</SelectItem>
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
<SelectItem value="AlwaysFirst">
{t('admin.channels.bumperSelectionAlwaysFirst')}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperFont')}</Label>
<Select value={bumper.font} onValueChange={(v) => setField('font', v as BumperFont)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Sans">{t('admin.channels.bumperFontSans')}</SelectItem>
<SelectItem value="Serif">{t('admin.channels.bumperFontSerif')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperMinInterval')}</Label>
<Input
type="number"
min={0}
max={1440}
value={bumper.minIntervalMinutes}
onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))}
/>
</div>
</div>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
{/* Блоки заставок */}
<div className="border-t border-border pt-4">
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
</div>
<div className="flex flex-col gap-3">
{templates.map((template) => (
<BumperTemplateEditor
key={template.id}
channelId={channel.id}
template={template}
onChanged={onSaved}
onError={onError}
/>
))}
</div>
<div className="flex justify-center">
<Button
size="sm"
variant="outline"
disabled={addTemplate.isPending}
onClick={() => addTemplate.mutate()}
>
{t('admin.channels.bumperAddTemplate')}
</Button>
</div>
</CollapsibleCard>
)
}
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 (
<div className="flex flex-col gap-3 rounded-md border border-border bg-muted/30 p-4">
<div
className="flex cursor-pointer items-center justify-between gap-2"
onClick={() => setOpen((o) => !o)}
>
<div className="flex flex-wrap items-center gap-2">
<ChevronDown
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`}
/>
<span className="text-sm font-medium">{template.name}</span>
{template.isDefault && <Badge variant="muted">{t('admin.channels.bumperDefault')}</Badge>}
<span className="text-xs text-muted-foreground">
{template.hasAudio && template.audioDurationSeconds != null
? `≈${Math.round(template.audioDurationSeconds)} ${t('admin.channels.bumperSeconds')}`
: t('admin.channels.bumperDefaultDuration')}
</span>
</div>
{!template.isDefault && (
<Button
size="sm"
variant="destructive"
disabled={remove.isPending}
onClick={(e) => {
e.stopPropagation()
remove.mutate()
}}
>
{t('common.delete')}
</Button>
)}
</div>
{open && (
<>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperTemplateName')}</Label>
<Input value={name} maxLength={64} onChange={(e) => setName(e.target.value)} />
</div>
{colorFields.map(({ key, label }) => (
<div key={key} className="flex flex-col gap-1.5">
<Label>{label}</Label>
<div className="flex items-center gap-2">
<span
className="h-8 w-8 shrink-0 rounded border border-border"
style={{ backgroundColor: cssColor(colors[key]) }}
/>
<Input
value={colors[key]}
onChange={(e) => setColors((c) => ({ ...c, [key]: e.target.value }))}
/>
</div>
</div>
))}
</div>
<div className="grid gap-3 border-t border-border pt-3 sm:grid-cols-2">
<BumperFileUpload
channelId={channelId}
templateId={template.id}
kind="audio"
label={t('admin.channels.bumperAudio')}
hint={t('admin.channels.bumperAudioHint')}
has={template.hasAudio}
accept="audio/*"
upload={uploadBumperTemplateAudio}
clear={clearBumperTemplateAudio}
onSaved={onChanged}
onError={onError}
/>
<BumperBackgroundField
channelId={channelId}
templateId={template.id}
backgroundImageId={template.backgroundImageId}
onChanged={onChanged}
onError={onError}
/>
</div>
{/* Подблоки (текст-варианты) */}
<div className="flex flex-col gap-2 border-t border-border pt-3">
<p className="text-sm font-medium">{t('admin.channels.bumperVariants')}</p>
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperVariantsHint')}</p>
{[...template.variants]
.sort((a, b) => a.position - b.position)
.map((variant) => (
<BumperVariantEditor
key={variant.id}
channelId={channelId}
templateId={template.id}
variant={variant}
canRemove={template.variants.length > 1}
onChanged={onChanged}
onError={onError}
/>
))}
<div>
<Button
size="sm"
variant="outline"
disabled={addVariant.isPending}
onClick={() => addVariant.mutate()}
>
{t('admin.channels.bumperAddVariant')}
</Button>
</div>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<BumperPreviewPlayer channelId={channelId} templateId={template.id} onError={onError} />
</div>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</>
)}
</div>
)
}
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 = <K extends keyof typeof form>(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 (
<div className="flex flex-col gap-2 rounded-md border border-border bg-background/40 p-3">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperVariantName')}</Label>
<Input value={form.name} maxLength={64} onChange={(e) => set('name', e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperTextKind')}</Label>
<Select value={form.kind} onValueChange={(v) => set('kind', v as BumperTextKind)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NowNext">{t('admin.channels.bumperKindNowNext')}</SelectItem>
<SelectItem value="Free">{t('admin.channels.bumperKindFree')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperTrigger')}</Label>
<Select value={form.trigger} onValueChange={(v) => set('trigger', v as BumperTrigger)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="OnShowChange">
{t('admin.channels.bumperTriggerOnShowChange')}
</SelectItem>
<SelectItem value="BetweenEpisodes">
{t('admin.channels.bumperTriggerBetweenEpisodes')}
</SelectItem>
<SelectItem value="Both">{t('admin.channels.bumperTriggerBoth')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{form.kind === 'NowNext' ? (
<>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperNowLabel')}</Label>
<Input
value={form.nowLabel}
maxLength={64}
onChange={(e) => set('nowLabel', e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperNextLabel')}</Label>
<Input
value={form.nextLabel}
maxLength={64}
onChange={(e) => set('nextLabel', e.target.value)}
/>
</div>
</>
) : (
<>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperLine1')}</Label>
<Input
value={form.line1}
maxLength={120}
onChange={(e) => set('line1', e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperLine2')}</Label>
<Input
value={form.line2}
maxLength={120}
onChange={(e) => set('line2', e.target.value)}
/>
</div>
</>
)}
</div>
<div className="flex items-center justify-end gap-2">
{canRemove && (
<Button
size="sm"
variant="ghost"
disabled={remove.isPending}
onClick={() => remove.mutate()}
>
{t('common.delete')}
</Button>
)}
<Button
size="sm"
variant="outline"
disabled={save.isPending || !form.name.trim()}
onClick={() => save.mutate()}
>
{t('common.save')}
</Button>
</div>
</div>
)
}
function BumperPreviewPlayer({
channelId,
templateId,
onError,
}: {
channelId: string
templateId: string
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const videoRef = useRef<HTMLVideoElement>(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 (
<>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={render.isPending}
onClick={() => render.mutate()}
>
{render.isPending
? t('admin.channels.bumperPreviewRendering')
: t('admin.channels.bumperPreview')}
</Button>
<span className="text-xs text-muted-foreground">
{t('admin.channels.bumperPreviewHint')}
</span>
</div>
{ready && (
<video
ref={videoRef}
controls
playsInline
className="aspect-video w-full max-w-sm rounded-md border border-border bg-black"
/>
)}
</>
)
}
function BumperBackgroundField({
channelId,
templateId,
backgroundImageId,
onChanged,
onError,
}: {
channelId: string
templateId: string
backgroundImageId: string | null
onChanged: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const [galleryOpen, setGalleryOpen] = useState(false)
const setBg = useMutation({
mutationFn: (imageId: string) => setBumperTemplateBackground(channelId, templateId, imageId),
onSuccess: onChanged,
onError,
})
const clearBg = useMutation({
mutationFn: () => clearBumperTemplateBackground(channelId, templateId),
onSuccess: onChanged,
onError,
})
return (
<div className="flex flex-col gap-1.5">
<Label>
{t('admin.channels.bumperBackground')}{' '}
<span className={backgroundImageId ? 'text-emerald-500' : 'text-muted-foreground'}>
{backgroundImageId
? ${t('admin.channels.bumperFileLoaded')}`
: ${t('admin.channels.bumperFileDefault')}`}
</span>
</Label>
<span className="text-xs text-muted-foreground">{t('admin.channels.bumperBackgroundHint')}</span>
<div className="flex items-center gap-2">
{backgroundImageId && (
<img
src={imageUrl(backgroundImageId)}
alt=""
className="h-10 w-16 shrink-0 rounded border border-border object-cover"
/>
)}
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
{t('admin.channels.bumperBackgroundPick')}
</Button>
{backgroundImageId && (
<Button size="sm" variant="ghost" disabled={clearBg.isPending} onClick={() => clearBg.mutate()}>
{t('admin.channels.bumperReset')}
</Button>
)}
</div>
<ImageGallery
open={galleryOpen}
onOpenChange={setGalleryOpen}
category="BumperBackground"
onSelect={(img) => setBg.mutate(img.id)}
/>
</div>
)
}
function BumperFileUpload({
channelId,
templateId,
kind,
label,
hint,
has,
accept,
upload,
clear,
onSaved,
onError,
}: {
channelId: string
templateId: string
kind: string
label: string
hint: string
has: boolean
accept: string
upload: (id: string, templateId: string, file: File) => Promise<void>
clear: (id: string, templateId: string) => Promise<void>
onSaved: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const inputId = `bumper-${kind}-${templateId}`
const uploadMutation = useMutation({
mutationFn: (file: File) => upload(channelId, templateId, file),
onSuccess: onSaved,
onError,
})
const clearMutation = useMutation({
mutationFn: () => clear(channelId, templateId),
onSuccess: onSaved,
onError,
})
const busy = uploadMutation.isPending || clearMutation.isPending
return (
<div className="flex flex-col gap-1.5">
<Label>
{label}{' '}
<span className={has ? 'text-emerald-500' : 'text-muted-foreground'}>
{has ? ${t('admin.channels.bumperFileLoaded')}` : ${t('admin.channels.bumperFileDefault')}`}
</span>
</Label>
<span className="text-xs text-muted-foreground">{hint}</span>
<div className="flex items-center gap-2">
<input
id={inputId}
type="file"
accept={accept}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) uploadMutation.mutate(file)
e.target.value = ''
}}
/>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => document.getElementById(inputId)?.click()}
>
{t('admin.channels.bumperUpload')}
</Button>
{has && (
<Button size="sm" variant="ghost" disabled={busy} onClick={() => clearMutation.mutate()}>
{t('admin.channels.bumperReset')}
</Button>
)}
</div>
</div>
)
}
function AddShowForm({
channelId,
options,
onAdded,
onError,
}: {
channelId: string
options: { id: string; name: string }[]
onAdded: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const [showId, setShowId] = useState('')
const [weight, setWeight] = useState(1)
const [blockMode, setBlockMode] = useState<BlockMode>('Count')
const [blockValue, setBlockValue] = useState(1)
const add = useMutation({
mutationFn: () => addChannelShow(channelId, { showId, weight, blockMode, blockValue }),
onSuccess: () => {
setShowId('')
onAdded()
},
onError,
})
return (
<div className="flex flex-wrap items-end gap-2">
<Select value={showId} onValueChange={setShowId}>
<SelectTrigger className="w-48">
<SelectValue placeholder={t('admin.channels.pickShow')} />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.name}
</SelectItem>
))}
</SelectContent>
</Select>
<NumberField label={t('admin.channels.weight')} value={weight} onChange={setWeight} min={1} />
<Select value={blockMode} onValueChange={(v) => setBlockMode(v as BlockMode)}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Count">{t('admin.channels.blockCount')}</SelectItem>
<SelectItem value="Duration">{t('admin.channels.blockDuration')}</SelectItem>
</SelectContent>
</Select>
<NumberField
label={blockMode === 'Count' ? t('admin.channels.episodes') : t('admin.channels.minutes')}
value={blockValue}
onChange={setBlockValue}
min={1}
/>
<Button size="sm" disabled={!showId || add.isPending} onClick={() => add.mutate()}>
{t('common.create')}
</Button>
</div>
)
}
function ChannelShowRow({
channelId,
row,
onChanged,
onError,
}: {
channelId: string
row: ChannelShowDto
onChanged: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const [weight, setWeight] = useState(row.weight)
const [blockMode, setBlockMode] = useState<BlockMode>(row.blockMode)
const [blockValue, setBlockValue] = useState(row.blockValue)
const [isEnabled, setIsEnabled] = useState(row.isEnabled)
const save = useMutation({
mutationFn: () =>
updateChannelShow(channelId, row.id, { weight, blockMode, blockValue, isEnabled }),
onSuccess: onChanged,
onError,
})
return (
<tr className="border-b border-border last:border-0">
<td className="py-2">{row.showName}</td>
<td className="py-2">
<Input
type="number"
min={1}
value={weight}
onChange={(e) => setWeight(Number(e.target.value))}
className="h-8 w-16"
/>
</td>
<td className="py-2">
<div className="flex items-center gap-1">
<Select value={blockMode} onValueChange={(v) => setBlockMode(v as BlockMode)}>
<SelectTrigger className="h-8 w-36 whitespace-nowrap">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Count">{t('admin.channels.blockCount')}</SelectItem>
<SelectItem value="Duration">{t('admin.channels.blockDuration')}</SelectItem>
</SelectContent>
</Select>
<Input
type="number"
min={1}
value={blockValue}
onChange={(e) => setBlockValue(Number(e.target.value))}
className="h-8 w-16"
/>
</div>
</td>
<td className="py-2">
<input
type="checkbox"
checked={isEnabled}
onChange={(e) => setIsEnabled(e.target.checked)}
/>
</td>
<td className="py-2">
<div className="flex gap-2">
<Button size="sm" variant="outline" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
<RemoveButton
onClick={() => removeChannelShow(channelId, row.id).then(onChanged).catch(onError)}
/>
</div>
</td>
</tr>
)
}
function AddAdForm({
channelId,
options,
onAdded,
onError,
}: {
channelId: string
options: { id: string; name: string }[]
onAdded: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const [assetId, setAssetId] = useState('')
const add = useMutation({
mutationFn: () => addChannelAd(channelId, assetId),
onSuccess: () => {
setAssetId('')
onAdded()
},
onError,
})
return (
<div className="flex flex-wrap items-end gap-2">
<Select value={assetId} onValueChange={setAssetId}>
<SelectTrigger className="max-w-md">
<SelectValue placeholder={t('admin.channels.pickAd')} />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" disabled={!assetId || add.isPending} onClick={() => add.mutate()}>
{t('common.create')}
</Button>
</div>
)
}
function OverrideForm({
channelId,
options,
onCreated,
onError,
}: {
channelId: string
options: { id: string; name: string }[]
onCreated: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const [mode, setMode] = useState<OverrideMode>('Exclusive')
const [showId, setShowId] = useState('')
const [weight, setWeight] = useState(1)
const [start, setStart] = useState('')
const [end, setEnd] = useState('')
const create = useMutation({
mutationFn: () =>
createOverride(channelId, {
mode,
startsAtUtc: new Date(start).toISOString(),
endsAtUtc: new Date(end).toISOString(),
shows: [{ showId, weight }],
}),
onSuccess: () => {
setShowId('')
setStart('')
setEnd('')
onCreated()
},
onError,
})
const valid = showId && start && end && new Date(end) > new Date(start)
return (
<div className="flex flex-wrap items-end gap-2">
<Select value={mode} onValueChange={(v) => setMode(v as OverrideMode)}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Exclusive">{t('admin.channels.modes.Exclusive')}</SelectItem>
<SelectItem value="Boost">{t('admin.channels.modes.Boost')}</SelectItem>
</SelectContent>
</Select>
<Select value={showId} onValueChange={setShowId}>
<SelectTrigger className="w-44">
<SelectValue placeholder={t('admin.channels.pickShow')} />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.name}
</SelectItem>
))}
</SelectContent>
</Select>
{mode === 'Boost' && (
<NumberField label={t('admin.channels.weight')} value={weight} onChange={setWeight} min={1} />
)}
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.from')}</Label>
<Input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} className="w-52" />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.to')}</Label>
<Input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} className="w-52" />
</div>
<Button size="sm" disabled={!valid || create.isPending} onClick={() => create.mutate()}>
{t('common.create')}
</Button>
</div>
)
}
function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
const { t } = useTranslation()
if (entries.length === 0)
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
return (
<ul className="flex flex-col divide-y divide-border text-sm">
{entries.slice(0, 40).map((e) => (
<li key={e.id} className="flex items-center gap-3 py-1.5">
<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' ? (
<Badge variant="muted">{t('air.bumper')}</Badge>
) : (
<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>
)}
</li>
))}
</ul>
)
}
function NumberField({
label,
value,
onChange,
min,
}: {
label: string
value: number
onChange: (v: number) => void
min?: number
}) {
return (
<div className="flex flex-col gap-1.5">
<Label>{label}</Label>
<Input
type="number"
min={min}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-24"
/>
</div>
)
}
function RemoveButton({ onClick }: { onClick: () => void }) {
const { t } = useTranslation()
return (
<Button size="sm" variant="destructive" onClick={onClick}>
{t('common.delete')}
</Button>
)
}