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:
Leonid Pershin
2026-07-25 13:24:11 +03:00
parent f640af1fc4
commit a65bcf4258
34 changed files with 2009 additions and 170 deletions
@@ -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,