1420 lines
45 KiB
TypeScript
1420 lines
45 KiB
TypeScript
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>
|
||
)
|
||
}
|