Add bumper variant management: implement API endpoints for adding, updating, and removing bumper text variants, enhance data models to support variant details, and update scheduling logic to utilize variants. Refactor related components for improved bumper template handling and ensure proper error management for variant operations.
This commit is contained in:
@@ -14,6 +14,9 @@ import type {
|
||||
BumperSelection,
|
||||
BumperSettings,
|
||||
BumperTemplateDto,
|
||||
BumperTextKind,
|
||||
BumperTextVariantDto,
|
||||
BumperTrigger,
|
||||
ChannelShowDto,
|
||||
OverrideMode,
|
||||
ScheduleEntryDto,
|
||||
@@ -29,6 +32,7 @@ import { listMedia } from '@/features/admin/media/api'
|
||||
import { listShows } from '@/features/admin/shows/api'
|
||||
import {
|
||||
addBumperTemplate,
|
||||
addBumperVariant,
|
||||
addChannelAd,
|
||||
addChannelShow,
|
||||
bumperPreviewPlaylistUrl,
|
||||
@@ -40,11 +44,13 @@ import {
|
||||
getSchedule,
|
||||
regenerateSchedule,
|
||||
removeBumperTemplate,
|
||||
removeBumperVariant,
|
||||
removeChannelAd,
|
||||
removeChannelShow,
|
||||
renderBumperPreview,
|
||||
setBumperTemplateBackground,
|
||||
updateBumperTemplate,
|
||||
updateBumperVariant,
|
||||
updateChannelSettings,
|
||||
updateChannelShow,
|
||||
uploadBumperTemplateAudio,
|
||||
@@ -483,31 +489,7 @@ function BumperCard({
|
||||
onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperNowLabel')}</Label>
|
||||
<Input
|
||||
value={bumper.nowLabel}
|
||||
maxLength={64}
|
||||
onChange={(e) => setField('nowLabel', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperNextLabel')}</Label>
|
||||
<Input
|
||||
value={bumper.nextLabel}
|
||||
maxLength={64}
|
||||
onChange={(e) => setField('nextLabel', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bumper.onlyBetweenDifferentShows}
|
||||
onChange={(e) => setField('onlyBetweenDifferentShows', e.target.checked)}
|
||||
/>
|
||||
{t('admin.channels.bumperOnlyDifferent')}
|
||||
</label>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
@@ -589,6 +571,11 @@ function BumperTemplateEditor({
|
||||
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') },
|
||||
@@ -677,6 +664,35 @@ function BumperTemplateEditor({
|
||||
/>
|
||||
</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>
|
||||
@@ -692,6 +708,166 @@ function BumperTemplateEditor({
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -3,6 +3,8 @@ import type {
|
||||
AdInsertion,
|
||||
BlockMode,
|
||||
BumperSettings,
|
||||
BumperTextKind,
|
||||
BumperTrigger,
|
||||
ChannelDto,
|
||||
ChannelSummaryDto,
|
||||
CreatedIdResponse,
|
||||
@@ -137,6 +139,42 @@ export function uploadBumperTemplateAudio(id: string, templateId: string, file:
|
||||
return uploadBumperTemplateFile(id, templateId, 'audio', file)
|
||||
}
|
||||
|
||||
export type BumperVariantBody = {
|
||||
name: string
|
||||
kind: BumperTextKind
|
||||
nowLabel: string
|
||||
nextLabel: string
|
||||
line1: string
|
||||
line2: string
|
||||
trigger: BumperTrigger
|
||||
}
|
||||
|
||||
export function addBumperVariant(id: string, templateId: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants`,
|
||||
{ method: 'POST', body: { name } },
|
||||
)
|
||||
}
|
||||
|
||||
export function updateBumperVariant(
|
||||
id: string,
|
||||
templateId: string,
|
||||
variantId: string,
|
||||
body: BumperVariantBody,
|
||||
) {
|
||||
return apiRequest<void>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||
{ method: 'PUT', body },
|
||||
)
|
||||
}
|
||||
|
||||
export function removeBumperVariant(id: string, templateId: string, variantId: string) {
|
||||
return apiRequest<void>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
}
|
||||
|
||||
/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */
|
||||
export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||
|
||||
@@ -128,15 +128,27 @@ export type OverrideMode = 'Exclusive' | 'Boost'
|
||||
export type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper'
|
||||
export type BumperFont = 'Sans' | 'Serif'
|
||||
export type BumperSelection = 'Rotation' | 'Random' | 'AlwaysFirst'
|
||||
export type BumperTextKind = 'NowNext' | 'Free'
|
||||
export type BumperTrigger = 'OnShowChange' | 'BetweenEpisodes' | 'Both'
|
||||
|
||||
/** Общие для канала настройки заставок (стиль/звук — на каждом блоке, см. BumperTemplateDto). */
|
||||
/** Общие для канала настройки заставок (стиль/звук/текст — на блоках и подблоках). */
|
||||
export type BumperSettings = {
|
||||
font: BumperFont
|
||||
minIntervalMinutes: number
|
||||
selection: BumperSelection
|
||||
}
|
||||
|
||||
/** Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока. */
|
||||
export type BumperTextVariantDto = {
|
||||
id: string
|
||||
position: number
|
||||
name: string
|
||||
kind: BumperTextKind
|
||||
nowLabel: string
|
||||
nextLabel: string
|
||||
minIntervalMinutes: number
|
||||
onlyBetweenDifferentShows: boolean
|
||||
selection: BumperSelection
|
||||
line1: string
|
||||
line2: string
|
||||
trigger: BumperTrigger
|
||||
}
|
||||
|
||||
export type BumperTemplateDto = {
|
||||
@@ -151,6 +163,7 @@ export type BumperTemplateDto = {
|
||||
backgroundImageId: string | null
|
||||
hasAudio: boolean
|
||||
audioDurationSeconds: number | null
|
||||
variants: BumperTextVariantDto[]
|
||||
}
|
||||
|
||||
export type ChannelSummaryDto = {
|
||||
|
||||
@@ -220,6 +220,20 @@ const resources = {
|
||||
'Каждый блок — свой звук и оформление. Первый блок дефолтный, его нельзя удалить. Длительность заставки — по длине звука.',
|
||||
bumperAddTemplate: 'Добавить блок',
|
||||
bumperTemplateName: 'Название',
|
||||
bumperVariants: 'Подблоки (текст)',
|
||||
bumperVariantsHint:
|
||||
'Разный текст на одной музыке и оформлении блока. Правило показа — у каждого подблока своё.',
|
||||
bumperAddVariant: 'Добавить текст',
|
||||
bumperVariantName: 'Название',
|
||||
bumperTextKind: 'Режим текста',
|
||||
bumperKindNowNext: 'Сейчас / Далее',
|
||||
bumperKindFree: 'Свободный текст',
|
||||
bumperLine1: 'Строка 1',
|
||||
bumperLine2: 'Строка 2',
|
||||
bumperTrigger: 'Показывать',
|
||||
bumperTriggerOnShowChange: 'При смене шоу',
|
||||
bumperTriggerBetweenEpisodes: 'Между сериями',
|
||||
bumperTriggerBoth: 'Оба',
|
||||
bumperDefault: 'по умолчанию',
|
||||
bumperSeconds: 'с',
|
||||
bumperDefaultDuration: '≈8 с (джингл)',
|
||||
@@ -526,6 +540,20 @@ const resources = {
|
||||
'Each block has its own sound and style. The first block is the default and cannot be removed. Bumper length follows the sound length.',
|
||||
bumperAddTemplate: 'Add block',
|
||||
bumperTemplateName: 'Name',
|
||||
bumperVariants: 'Sub-blocks (text)',
|
||||
bumperVariantsHint:
|
||||
'Different text over the same music and style. Each sub-block has its own show rule.',
|
||||
bumperAddVariant: 'Add text',
|
||||
bumperVariantName: 'Name',
|
||||
bumperTextKind: 'Text mode',
|
||||
bumperKindNowNext: 'Now / Next',
|
||||
bumperKindFree: 'Free text',
|
||||
bumperLine1: 'Line 1',
|
||||
bumperLine2: 'Line 2',
|
||||
bumperTrigger: 'Show on',
|
||||
bumperTriggerOnShowChange: 'Show change',
|
||||
bumperTriggerBetweenEpisodes: 'Between episodes',
|
||||
bumperTriggerBoth: 'Both',
|
||||
bumperDefault: 'default',
|
||||
bumperSeconds: 's',
|
||||
bumperDefaultDuration: '≈8 s (jingle)',
|
||||
|
||||
Reference in New Issue
Block a user