Implement BumperEndpoints and remove deprecated bumper-related functionality
ci / build-backend (push) Successful in 1m39s
ci / build-frontend (push) Failing after 26s
ci / tests (push) Skipped
ci / sonar (push) Skipped

Added new BumperEndpoints to the API for managing bumper templates and variants, enhancing the channel management capabilities. Removed outdated bumper-related commands and handlers from the application, streamlining the codebase and improving maintainability. Updated ChannelEndpoints to reflect these changes and ensure proper routing for the new endpoints.
This commit is contained in:
Leonid Pershin
2026-07-27 22:01:56 +03:00
parent ee0b4d2d01
commit ba3721eb92
140 changed files with 7326 additions and 3609 deletions
@@ -0,0 +1,111 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Plus } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listChannels } from '@/features/admin/channels/api'
import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button'
import { Card, CardContent } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { createBumperTemplate, listBumperTemplates } from './api'
import { BumperTemplateEditor } from './components/BumperTemplateEditor'
/**
* Блоки заставок — общие для всех каналов, как группы. Канал выбирается тут же и только для
* образцов подстановки: посмотреть общий блок надо глазами конкретного канала, иначе
* `{channel}` не на что заменить.
*/
export function BumpersPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const onError = useApiError()
const [name, setName] = useState('')
const [channelId, setChannelId] = useState<string>('')
const { data: templates } = useQuery({
queryKey: qk.bumpers.all,
queryFn: listBumperTemplates,
})
const { data: channels } = useQuery({ queryKey: qk.channels.all, queryFn: listChannels })
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: qk.bumpers.all })
}
const create = useMutation({
mutationFn: () => createBumperTemplate(name.trim()),
onSuccess: () => {
setName('')
invalidate()
},
onError,
})
return (
<div className="flex flex-col gap-4">
<Card>
<CardContent className="flex flex-col gap-4">
<p className="text-xs text-muted-foreground">{t('admin.bumpers.hint')}</p>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.bumpers.sampleChannel')}</Label>
<select
className="h-9 min-w-56 rounded-md border border-border bg-transparent px-2 text-sm"
value={channelId}
onChange={(e) => setChannelId(e.target.value)}
>
<option value="">{t('admin.bumpers.sampleChannelNone')}</option>
{(channels ?? []).map((channel) => (
<option key={channel.id} value={channel.id}>
{channel.name}
</option>
))}
</select>
<span className="text-xs text-muted-foreground">
{t('admin.bumpers.sampleChannelHint')}
</span>
</div>
<div className="flex flex-1 items-end gap-2">
<div className="flex flex-1 flex-col gap-1.5">
<Label>{t('admin.bumpers.newName')}</Label>
<Input
placeholder={t('admin.bumpers.newNamePlaceholder')}
value={name}
maxLength={64}
onChange={(e) => setName(e.target.value)}
/>
</div>
<Button
size="sm"
variant="outline"
disabled={!name.trim() || create.isPending}
onClick={() => create.mutate()}
>
<Plus className="h-4 w-4" /> {t('common.create')}
</Button>
</div>
</div>
</CardContent>
</Card>
<div className="flex flex-col gap-3">
{(templates ?? []).map((template) => (
<BumperTemplateEditor
key={template.id}
template={template}
channelId={channelId || null}
onChanged={invalidate}
onError={onError}
/>
))}
{templates?.length === 0 && (
<p className="text-sm text-muted-foreground">{t('admin.bumpers.empty')}</p>
)}
</div>
</div>
)
}
+122
View File
@@ -0,0 +1,122 @@
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
import type {
BumperBackground,
BumperFont,
BumperLineDto,
BumperTemplateDto,
BumperTrigger,
CreatedIdResponse,
} from '@/shared/api/types'
/** Блоки заставок общие для всех каналов — свой раздел, а не подраздел канала. */
export function listBumperTemplates() {
return apiRequest<BumperTemplateDto[]>('/admin/bumpers')
}
export function createBumperTemplate(name: string) {
return apiRequest<CreatedIdResponse>('/admin/bumpers', { method: 'POST', body: { name } })
}
export type BumperStyleBody = {
name: string
font: BumperFont
backgroundColor: string
backgroundColor2: string
accentColor: string
textColor: string
}
export function updateBumperTemplate(templateId: string, body: BumperStyleBody) {
return apiRequest<void>(`/admin/bumpers/${templateId}`, { method: 'PUT', body })
}
export function deleteBumperTemplate(templateId: string) {
return apiRequest<void>(`/admin/bumpers/${templateId}`, { method: 'DELETE' })
}
export type BumperVariantBody = {
name: string
trigger: BumperTrigger
background: BumperBackground
weight: number
lines: BumperLineDto[]
}
export function addBumperVariant(templateId: string, name: string) {
return apiRequest<CreatedIdResponse>(`/admin/bumpers/${templateId}/variants`, {
method: 'POST',
body: { name },
})
}
export function updateBumperVariant(
templateId: string,
variantId: string,
body: BumperVariantBody,
) {
return apiRequest<void>(`/admin/bumpers/${templateId}/variants/${variantId}`, {
method: 'PUT',
body,
})
}
export function removeBumperVariant(templateId: string, variantId: string) {
return apiRequest<void>(`/admin/bumpers/${templateId}/variants/${variantId}`, {
method: 'DELETE',
})
}
export function setBumperBackground(templateId: string, imageId: string) {
return apiRequest<void>(`/admin/bumpers/${templateId}/background`, {
method: 'PUT',
body: { imageId },
})
}
export function clearBumperBackground(templateId: string) {
return apiRequest<void>(`/admin/bumpers/${templateId}/background`, { method: 'DELETE' })
}
export function clearBumperAudio(templateId: string) {
return apiRequest<void>(`/admin/bumpers/${templateId}/audio`, { method: 'DELETE' })
}
/**
* Синхронно рендерит примеры всех подблоков. Канал нужен только для образцов подстановки: блок
* общий, но посмотреть его надо глазами конкретного канала — иначе `{channel}` не на что заменить.
*/
export function renderBumperPreviews(templateId: string, channelId: string | null) {
const query = channelId ? `?channelId=${channelId}` : ''
return apiRequest<void>(`/admin/bumpers/${templateId}/preview${query}`, { method: 'POST' })
}
export function bumperPreviewPlaylistUrl(templateId: string, variantId: string) {
return `/api/admin/bumpers/${templateId}/preview/${variantId}/index.m3u8`
}
/** Загрузка звука блока: тело — файл, имя — в query (как в uploadMedia). */
export function uploadBumperAudio(templateId: string, file: File): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const query = new URLSearchParams({ fileName: file.name })
xhr.open('PUT', `/api/admin/bumpers/${templateId}/audio?${query.toString()}`)
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
let detail = `HTTP ${xhr.status}`
try {
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
detail = problem.detail ?? problem.title ?? detail
} catch {
/* пусто */
}
reject(new HttpError({ detail }, xhr.status))
}
}
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
xhr.send(file)
})
}
@@ -0,0 +1,150 @@
import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { imageUrl } from '@/features/admin/images/api'
import { ImageGallery } from '@/features/admin/images/ImageGallery'
import { Button } from '@/shared/ui/button'
import { Label } from '@/shared/ui/label'
import {
clearBumperAudio,
clearBumperBackground,
setBumperBackground,
uploadBumperAudio,
} from '../api'
/** Звук блока: его длина и задаёт длительность заставки, без него синтезируется джингл. */
export function BumperAudioField({
templateId,
hasAudio,
onChanged,
onError,
}: Readonly<{
templateId: string
hasAudio: boolean
onChanged: () => void
onError: (e: unknown) => void
}>) {
const { t } = useTranslation()
const inputId = `bumper-audio-${templateId}`
const upload = useMutation({
mutationFn: (file: File) => uploadBumperAudio(templateId, file),
onSuccess: onChanged,
onError,
})
const clear = useMutation({
mutationFn: () => clearBumperAudio(templateId),
onSuccess: onChanged,
onError,
})
const busy = upload.isPending || clear.isPending
return (
<div className="flex flex-col gap-1.5">
<Label>
{t('admin.bumpers.audio')}{' '}
<span className={hasAudio ? 'text-emerald-500' : 'text-muted-foreground'}>
{hasAudio ? `· ${t('admin.bumpers.fileLoaded')}` : `· ${t('admin.bumpers.fileDefault')}`}
</span>
</Label>
<span className="text-xs text-muted-foreground">{t('admin.bumpers.audioHint')}</span>
<div className="flex items-center gap-2">
<input
id={inputId}
type="file"
accept="audio/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) upload.mutate(file)
e.target.value = ''
}}
/>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => document.getElementById(inputId)?.click()}
>
{t('admin.bumpers.upload')}
</Button>
{hasAudio && (
<Button size="sm" variant="ghost" disabled={busy} onClick={() => clear.mutate()}>
{t('admin.bumpers.reset')}
</Button>
)}
</div>
</div>
)
}
/** Фон блока — из общего реестра изображений, как и всё остальное с картинками. */
export function BumperBackgroundField({
templateId,
backgroundImageId,
onChanged,
onError,
}: Readonly<{
templateId: string
backgroundImageId: string | null
onChanged: () => void
onError: (e: unknown) => void
}>) {
const { t } = useTranslation()
const [galleryOpen, setGalleryOpen] = useState(false)
const set = useMutation({
mutationFn: (imageId: string) => setBumperBackground(templateId, imageId),
onSuccess: onChanged,
onError,
})
const clear = useMutation({
mutationFn: () => clearBumperBackground(templateId),
onSuccess: onChanged,
onError,
})
return (
<div className="flex flex-col gap-1.5">
<Label>
{t('admin.bumpers.background')}{' '}
<span className={backgroundImageId ? 'text-emerald-500' : 'text-muted-foreground'}>
{backgroundImageId
? `· ${t('admin.bumpers.fileLoaded')}`
: `· ${t('admin.bumpers.fileDefault')}`}
</span>
</Label>
<span className="text-xs text-muted-foreground">
{t('admin.bumpers.backgroundFieldHint')}
</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.bumpers.backgroundPick')}
</Button>
{backgroundImageId && (
<Button
size="sm"
variant="ghost"
disabled={clear.isPending}
onClick={() => clear.mutate()}
>
{t('admin.bumpers.reset')}
</Button>
)}
</div>
<ImageGallery
open={galleryOpen}
onOpenChange={setGalleryOpen}
category="BumperBackground"
onSelect={(img) => set.mutate(img.id)}
/>
</div>
)
}
@@ -0,0 +1,66 @@
import { imageUrl } from '@/features/admin/images/api'
import { cssColor } from '@/features/admin/channels/lib/format'
import type { BumperLineDto, BumperTemplateDto } from '@/shared/api/types'
import { resolveSample } from '../placeholders'
/** Размеры строк в долях высоты кадра — те же пропорции, что и в ffmpeg-раскладке. */
const SIZE = { Title: 0.1, Label: 0.045, Caption: 0.036 } as const
/**
* Кадр заставки, нарисованный в браузере. Не замена ffmpeg-рендеру, а то, без чего редактор
* не работает: ждать несколько секунд ffmpeg ради проверки опечатки нельзя, а плейсхолдеры
* в поле ввода выглядят кодом, а не кадром.
*/
export function BumperFramePreview({
template,
lines,
showPoster,
height = 200,
}: Readonly<{
template: BumperTemplateDto
lines: BumperLineDto[]
showPoster?: boolean
height?: number
}>) {
const visible = lines.map((l) => ({ ...l, text: resolveSample(l.text) })).filter((l) => l.text)
const background = template.backgroundImageId
? { backgroundImage: `url(${imageUrl(template.backgroundImageId)})`, backgroundSize: 'cover' }
: {
backgroundImage: `linear-gradient(135deg, ${cssColor(template.backgroundColor)}, ${cssColor(
template.backgroundColor2,
)})`,
}
return (
<div
className="relative flex w-full flex-col items-center justify-center gap-1 overflow-hidden rounded-md border border-border"
style={{ height, aspectRatio: '16 / 9', ...background }}
>
{/* Постер шоу рендер размывает и затемняет — здесь достаточно затемнения-заглушки. */}
{showPoster && !template.backgroundImageId && (
<div className="absolute inset-0 bg-black/40" />
)}
{visible.length === 0 && <span className="relative text-xs text-white/50"></span>}
{visible.map((line, index) => (
<span
key={`${line.text}-${index}`}
className="relative max-w-[92%] truncate text-center"
style={{
fontSize: height * SIZE[line.style],
lineHeight: 1.2,
fontFamily: template.font === 'Serif' ? 'Georgia, serif' : undefined,
letterSpacing: line.style === 'Label' ? '0.15em' : undefined,
color:
line.color === 'Accent'
? cssColor(template.accentColor)
: cssColor(template.textColor),
textShadow: '0 1px 2px rgba(0,0,0,.6)',
}}
>
{line.text}
</span>
))}
</div>
)
}
@@ -0,0 +1,205 @@
import { GripVertical, Plus, Trash2 } from 'lucide-react'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import type { BumperLineColor, BumperLineDto, BumperLineStyle } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { cn } from '@/shared/lib/cn'
import { hasVolatileToken, PLACEHOLDERS, resolveSample, unknownTokens } from '../placeholders'
const STYLES: BumperLineStyle[] = ['Label', 'Title', 'Caption']
const COLORS: BumperLineColor[] = ['Accent', 'Text']
const MAX_LINES = 6
/** Готовые наборы строк. Данные редактора, а не сущность: пресет просто заполняет список. */
function presets(t: (key: string) => string): { key: string; lines: BumperLineDto[] }[] {
const label = (text: string): BumperLineDto => ({
style: 'Label',
color: 'Accent',
text,
})
const title = (text: string): BumperLineDto => ({ style: 'Title', color: 'Text', text })
return [
{
key: 'nowNext',
lines: [
label(t('admin.bumpers.presetNow')),
title('{now.title}'),
label(t('admin.bumpers.presetNext')),
title('{next.title}'),
],
},
{
key: 'nextAt',
lines: [label(`${t('admin.bumpers.presetNextAt')} {next.time}`), title('{next.title}')],
},
{
key: 'channel',
lines: [title('{channel}'), { style: 'Caption', color: 'Accent', text: '{weekday}, {time}' }],
},
]
}
/**
* Строки заставки: порядок перетаскиванием, палитра плейсхолдеров под фокусированным полем,
* пресеты кнопкой. Ошибка ввода (незнакомый плейсхолдер) видна сразу — сервер её всё равно
* отвергнет, но узнавать об этом при сохранении неудобно.
*/
export function BumperLinesEditor({
lines,
onChange,
}: Readonly<{
lines: BumperLineDto[]
onChange: (lines: BumperLineDto[]) => void
}>) {
const { t } = useTranslation()
const focused = useRef<number | null>(null)
const inputs = useRef<(HTMLInputElement | null)[]>([])
const dragged = useRef<number | null>(null)
const patch = (index: number, part: Partial<BumperLineDto>) =>
onChange(lines.map((line, i) => (i === index ? { ...line, ...part } : line)))
const add = () => {
const line: BumperLineDto = { style: 'Title', color: 'Text', text: '' }
onChange([...lines, line].slice(0, MAX_LINES))
}
const remove = (index: number) => onChange(lines.filter((_, i) => i !== index))
const move = (from: number, to: number) => {
if (from === to) return
const next = [...lines]
const [line] = next.splice(from, 1)
next.splice(to, 0, line)
onChange(next)
}
/** Вставка плейсхолдера в позицию курсора — иначе его пришлось бы допечатывать руками. */
const insert = (token: string) => {
const index = focused.current ?? lines.length - 1
if (index < 0) return
const input = inputs.current[index]
const text = lines[index].text
const at = input?.selectionStart ?? text.length
patch(index, { text: `${text.slice(0, at)}{${token}}${text.slice(at)}` })
requestAnimationFrame(() => {
input?.focus()
const caret = at + token.length + 2
input?.setSelectionRange(caret, caret)
})
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs uppercase tracking-wide text-muted-foreground">
{t('admin.bumpers.presets')}
</span>
{presets(t).map((preset) => (
<Button
key={preset.key}
size="sm"
variant="outline"
onClick={() => onChange(preset.lines)}
>
{t(`admin.bumpers.preset_${preset.key}`)}
</Button>
))}
</div>
{lines.map((line, index) => {
const unknown = unknownTokens(line.text)
return (
<div
key={index}
draggable
onDragStart={() => {
dragged.current = index
}}
onDragOver={(e) => e.preventDefault()}
onDrop={() => {
if (dragged.current !== null) move(dragged.current, index)
dragged.current = null
}}
className="flex flex-wrap items-center gap-2 rounded-md border border-border bg-background/40 p-2"
>
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-xs"
value={line.style}
onChange={(e) => patch(index, { style: e.target.value as BumperLineStyle })}
>
{STYLES.map((style) => (
<option key={style} value={style}>
{t(`admin.bumpers.lineStyles.${style}`)}
</option>
))}
</select>
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-xs"
value={line.color}
onChange={(e) => patch(index, { color: e.target.value as BumperLineColor })}
>
{COLORS.map((color) => (
<option key={color} value={color}>
{t(`admin.bumpers.lineColors.${color}`)}
</option>
))}
</select>
<Input
ref={(el) => {
inputs.current[index] = el
}}
className={cn('h-8 min-w-40 flex-1', unknown.length > 0 && 'border-destructive')}
value={line.text}
maxLength={120}
onFocus={() => {
focused.current = index
}}
onChange={(e) => patch(index, { text: e.target.value })}
/>
<Button size="sm" variant="ghost" onClick={() => remove(index)}>
<Trash2 className="h-4 w-4" />
</Button>
<div className="w-full pl-6 text-xs">
{unknown.length > 0 ? (
<span className="text-destructive">
{t('admin.bumpers.unknownPlaceholder', { tokens: unknown.join(', ') })}
</span>
) : (
<span className="text-muted-foreground">
{resolveSample(line.text) || '—'}
{hasVolatileToken(line.text) && (
<span className="ml-2 text-amber-500">{t('admin.bumpers.volatileHint')}</span>
)}
</span>
)}
</div>
</div>
)
})}
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" disabled={lines.length >= MAX_LINES} onClick={add}>
<Plus className="h-4 w-4" /> {t('admin.bumpers.addLine')}
</Button>
</div>
{/* Палитра: клик вставляет плейсхолдер в фокусированное поле, подсказка показывает образец. */}
<div className="flex flex-wrap gap-1">
{PLACEHOLDERS.map((placeholder) => (
<button
key={placeholder.token}
type="button"
title={`${t(`admin.bumpers.tokens.${placeholder.token}`)}${placeholder.sample}`}
onClick={() => insert(placeholder.token)}
className="rounded border border-border px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground hover:border-primary hover:text-foreground"
>
{`{${placeholder.token}}`}
</button>
))}
</div>
</div>
)
}
@@ -6,14 +6,18 @@ import { Button } from '@/shared/ui/button'
import { HlsVideo } from '@/shared/ui/hls-video'
import { bumperPreviewPlaylistUrl, renderBumperPreviews } from '../api'
/**
* Настоящий ffmpeg-рендер примера. Кнопкой, а не автоматически: он занимает несколько секунд,
* а на каждый ввод в поле его гонять нельзя для этого есть кадр-предпросмотр в браузере.
*/
export function BumperPreviewPlayer({
channelId,
templateId,
channelId,
variants,
onError,
}: Readonly<{
channelId: string
templateId: string
channelId: string | null
variants: BumperTextVariantDto[]
onError: (e: unknown) => void
}>) {
@@ -22,7 +26,7 @@ export function BumperPreviewPlayer({
const [bust, setBust] = useState(0)
const render = useMutation({
mutationFn: () => renderBumperPreviews(channelId, templateId),
mutationFn: () => renderBumperPreviews(templateId, channelId),
onSuccess: () => {
setBust(Date.now())
setReady(true)
@@ -39,13 +43,9 @@ export function BumperPreviewPlayer({
disabled={render.isPending}
onClick={() => render.mutate()}
>
{render.isPending
? t('admin.channels.bumperPreviewRendering')
: t('admin.channels.bumperPreview')}
{render.isPending ? t('admin.bumpers.rendering') : t('admin.bumpers.render')}
</Button>
<span className="text-xs text-muted-foreground">
{t('admin.channels.bumperPreviewHint')}
</span>
<span className="text-xs text-muted-foreground">{t('admin.bumpers.renderHint')}</span>
</div>
{ready && (
<div className="grid gap-3 sm:grid-cols-2">
@@ -54,9 +54,7 @@ export function BumperPreviewPlayer({
.map((v) => (
<div key={v.id} className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">{v.name}</span>
<HlsVideo
src={`${bumperPreviewPlaylistUrl(channelId, templateId, v.id)}?t=${bust}`}
/>
<HlsVideo src={`${bumperPreviewPlaylistUrl(templateId, v.id)}?t=${bust}`} />
</div>
))}
</div>
@@ -1,41 +1,37 @@
import { useMutation } from '@tanstack/react-query'
import { ChevronDown } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronDown } from 'lucide-react'
import type { BumperTemplateDto } from '@/shared/api/types'
import { cssColor } from '@/features/admin/channels/lib/format'
import type { BumperFont, BumperTemplateDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
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 {
addBumperVariant,
clearBumperTemplateAudio,
removeBumperTemplate,
updateBumperTemplate,
uploadBumperTemplateAudio,
} from '../api'
import { cssColor } from '../lib/format'
import { BumperBackgroundField } from './BumperBackgroundField'
import { BumperFileUpload } from './BumperFileUpload'
import { addBumperVariant, deleteBumperTemplate, updateBumperTemplate } from '../api'
import { BumperAudioField, BumperBackgroundField } from './BumperFileFields'
import { BumperPreviewPlayer } from './BumperPreviewPlayer'
import { BumperVariantEditor } from './BumperVariantEditor'
export function BumperTemplateEditor({
channelId,
template,
channelId,
onChanged,
onError,
}: Readonly<{
channelId: string
template: BumperTemplateDto
/** Канал, чьими глазами смотрим на образцы подстановки; null — общие заглушки. */
channelId: string | null
onChanged: () => void
onError: (e: unknown) => void
}>) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [name, setName] = useState(template.name)
const [colors, setColors] = useState({
const [style, setStyle] = useState({
name: template.name,
font: template.font,
backgroundColor: template.backgroundColor,
backgroundColor2: template.backgroundColor2,
accentColor: template.accentColor,
@@ -43,8 +39,9 @@ export function BumperTemplateEditor({
})
useEffect(() => {
setName(template.name)
setColors({
setStyle({
name: template.name,
font: template.font,
backgroundColor: template.backgroundColor,
backgroundColor2: template.backgroundColor2,
accentColor: template.accentColor,
@@ -53,8 +50,7 @@ export function BumperTemplateEditor({
}, [template])
const save = useMutation({
mutationFn: () =>
updateBumperTemplate(channelId, template.id, { name: name.trim(), ...colors }),
mutationFn: () => updateBumperTemplate(template.id, { ...style, name: style.name.trim() }),
onSuccess: () => {
toast.success(t('settings.saved'))
onChanged()
@@ -62,28 +58,25 @@ export function BumperTemplateEditor({
onError,
})
const remove = useMutation({
mutationFn: () => removeBumperTemplate(channelId, template.id),
mutationFn: () => deleteBumperTemplate(template.id),
onSuccess: onChanged,
onError,
})
const addVariant = useMutation({
mutationFn: () => addBumperVariant(channelId, template.id, ''),
mutationFn: () => addBumperVariant(template.id, t('admin.bumpers.newVariantName')),
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') },
]
const colorFields = [
{ key: 'backgroundColor', label: t('admin.bumpers.colorBg') },
{ key: 'backgroundColor2', label: t('admin.bumpers.colorBg2') },
{ key: 'accentColor', label: t('admin.bumpers.colorAccent') },
{ key: 'textColor', label: t('admin.bumpers.colorText') },
] as const
return (
<div className="flex flex-col gap-3 rounded-md border border-border bg-muted/30 p-4">
{/* Сворачивание висит на кнопке, а не на всей строке: кликабельный div недоступен с клавиатуры.
Кнопка удаления при этом вынесена наружу вложенная кнопка внутри кнопки недопустима,
и заодно ей больше не нужен stopPropagation. */}
<div className="flex items-center justify-between gap-2">
<button
type="button"
@@ -95,31 +88,57 @@ export function BumperTemplateEditor({
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')}
? `${Math.round(template.audioDurationSeconds)} ${t('admin.bumpers.seconds')}`
: t('admin.bumpers.defaultDuration')}
</span>
<span className="text-xs text-muted-foreground">
· {t('admin.bumpers.variantsCount', { count: template.variants.length })}
</span>
{/* Блок общий: сколько врезок на него ссылается — не справка, а условие правки. */}
{template.usageCount > 0 && (
<Badge variant="muted">
{t('admin.bumpers.usedInJunctions', { count: template.usageCount })}
</Badge>
)}
</button>
{!template.isDefault && (
<Button
size="sm"
variant="destructive"
disabled={remove.isPending}
onClick={() => remove.mutate()}
>
{t('common.delete')}
</Button>
)}
<Button
size="sm"
variant="destructive"
disabled={remove.isPending || template.usageCount > 0}
title={template.usageCount > 0 ? t('admin.bumpers.cannotDeleteUsed') : undefined}
onClick={() => 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)} />
<Label>{t('admin.bumpers.name')}</Label>
<Input
value={style.name}
maxLength={64}
onChange={(e) => setStyle((s) => ({ ...s, name: e.target.value }))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.bumpers.font')}</Label>
<Select
value={style.font}
onValueChange={(v) => setStyle((s) => ({ ...s, font: v as BumperFont }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Sans">{t('admin.bumpers.fontSans')}</SelectItem>
<SelectItem value="Serif">{t('admin.bumpers.fontSerif')}</SelectItem>
</SelectContent>
</Select>
</div>
{colorFields.map(({ key, label }) => (
<div key={key} className="flex flex-col gap-1.5">
@@ -127,11 +146,11 @@ export function BumperTemplateEditor({
<div className="flex items-center gap-2">
<span
className="h-8 w-8 shrink-0 rounded border border-border"
style={{ backgroundColor: cssColor(colors[key]) }}
style={{ backgroundColor: cssColor(style[key]) }}
/>
<Input
value={colors[key]}
onChange={(e) => setColors((c) => ({ ...c, [key]: e.target.value }))}
value={style[key]}
onChange={(e) => setStyle((s) => ({ ...s, [key]: e.target.value }))}
/>
</div>
</div>
@@ -139,21 +158,13 @@ export function BumperTemplateEditor({
</div>
<div className="grid gap-3 border-t border-border pt-3 sm:grid-cols-2">
<BumperFileUpload
channelId={channelId}
<BumperAudioField
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}
hasAudio={template.hasAudio}
onChanged={onChanged}
onError={onError}
/>
<BumperBackgroundField
channelId={channelId}
templateId={template.id}
backgroundImageId={template.backgroundImageId}
onChanged={onChanged}
@@ -161,19 +172,21 @@ export function BumperTemplateEditor({
/>
</div>
{/* Подблоки (текст-варианты) */}
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('admin.bumpers.saveStyle')}
</Button>
</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>
<p className="text-sm font-medium">{t('admin.bumpers.variants')}</p>
<p className="text-xs text-muted-foreground">{t('admin.bumpers.variantsHint')}</p>
{[...template.variants]
.sort((a, b) => a.position - b.position)
.map((variant) => (
<BumperVariantEditor
key={variant.id}
channelId={channelId}
templateId={template.id}
template={template}
variant={variant}
canRemove={template.variants.length > 1}
onChanged={onChanged}
@@ -187,25 +200,19 @@ export function BumperTemplateEditor({
disabled={addVariant.isPending}
onClick={() => addVariant.mutate()}
>
{t('admin.channels.bumperAddVariant')}
{t('admin.bumpers.addVariant')}
</Button>
</div>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<BumperPreviewPlayer
channelId={channelId}
templateId={template.id}
channelId={channelId}
variants={template.variants}
onError={onError}
/>
</div>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</>
)}
</div>
@@ -0,0 +1,183 @@
import { useMutation } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
BumperBackground,
BumperLineDto,
BumperTemplateDto,
BumperTextVariantDto,
BumperTrigger,
} from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
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 { removeBumperVariant, updateBumperVariant } from '../api'
import { unknownTokens } from '../placeholders'
import { BumperFramePreview } from './BumperFramePreview'
import { BumperLinesEditor } from './BumperLinesEditor'
const TRIGGERS: BumperTrigger[] = ['OnShowChange', 'BetweenEpisodes', 'Both']
const BACKGROUNDS: BumperBackground[] = ['Template', 'NextPoster', 'NowPoster']
/**
* Подблок: слева строки, справа постоянный предпросмотр кадра. Предпросмотр не опциональный —
* без него текст с плейсхолдерами приходится читать как код.
*/
export function BumperVariantEditor({
template,
variant,
canRemove,
onChanged,
onError,
}: Readonly<{
template: BumperTemplateDto
variant: BumperTextVariantDto
canRemove: boolean
onChanged: () => void
onError: (e: unknown) => void
}>) {
const { t } = useTranslation()
const [form, setForm] = useState({
name: variant.name,
trigger: variant.trigger,
background: variant.background,
weight: variant.weight,
lines: variant.lines,
})
useEffect(() => {
setForm({
name: variant.name,
trigger: variant.trigger,
background: variant.background,
weight: variant.weight,
lines: variant.lines,
})
}, [variant])
const set = <K extends keyof typeof form>(key: K, value: (typeof form)[K]) =>
setForm((f) => ({ ...f, [key]: value }))
const save = useMutation({
mutationFn: () =>
updateBumperVariant(template.id, variant.id, { ...form, name: form.name.trim() }),
onSuccess: () => {
toast.success(t('settings.saved'))
onChanged()
},
onError,
})
const remove = useMutation({
mutationFn: () => removeBumperVariant(template.id, variant.id),
onSuccess: onChanged,
onError,
})
const setLines = (lines: BumperLineDto[]) => set('lines', lines)
const broken = form.lines.some((line) => unknownTokens(line.text).length > 0)
const empty = form.lines.every((line) => !line.text.trim())
return (
<div className="flex flex-col gap-3 rounded-md border border-border bg-background/40 p-3">
<div className="grid gap-3 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<div className="flex flex-col gap-3">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.bumpers.variantName')}</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.bumpers.trigger')}</Label>
<Select
value={form.trigger}
onValueChange={(v) => set('trigger', v as BumperTrigger)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIGGERS.map((trigger) => (
<SelectItem key={trigger} value={trigger}>
{t(`admin.bumpers.triggers.${trigger}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.bumpers.background')}</Label>
<Select
value={form.background}
onValueChange={(v) => set('background', v as BumperBackground)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BACKGROUNDS.map((background) => (
<SelectItem key={background} value={background}>
{t(`admin.bumpers.backgrounds.${background}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.bumpers.weight')}</Label>
<Input
type="number"
min={0}
max={1000}
value={form.weight}
onChange={(e) =>
set('weight', Math.max(0, Math.round(Number(e.target.value)) || 0))
}
/>
</div>
</div>
<BumperLinesEditor lines={form.lines} onChange={setLines} />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.bumpers.framePreview')}</Label>
<BumperFramePreview
template={template}
lines={form.lines}
showPoster={form.background !== 'Template'}
/>
<span className="text-xs text-muted-foreground">
{t('admin.bumpers.framePreviewHint')}
</span>
</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() || broken || empty}
onClick={() => save.mutate()}
>
{t('common.save')}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,51 @@
/**
* Плейсхолдеры текста заставки. Список — зеркало серверного (BumperPlaceholders.Tokens): сервер
* отвергает незнакомые при сохранении, редактор подсказывает знакомые и показывает подстановку.
*
* Образцы нужны и палитре, и живому предпросмотру: без них поле «ДАЛЕЕ В {next.time}» выглядит
* как строка кода, а не как кадр.
*/
export type PlaceholderSample = {
token: string
sample: string
}
export const PLACEHOLDERS: PlaceholderSample[] = [
{ token: 'channel', sample: 'Первый' },
{ token: 'channel.number', sample: '4' },
{ token: 'now.title', sample: 'Симпсоны' },
{ token: 'next.title', sample: 'Терминатор 2' },
{ token: 'now.episode', sample: 'с5э12' },
{ token: 'next.episode', sample: 'с1э3' },
{ token: 'next.year', sample: '1991' },
{ token: 'next.genre', sample: 'Боевик' },
{ token: 'next.time', sample: '21:30' },
{ token: 'time', sample: '21:24' },
{ token: 'date', sample: '6 апреля' },
{ token: 'weekday', sample: 'понедельник' },
{ token: 'slot', sample: 'Вечернее кино' },
]
/** Плейсхолдеры момента показа: с ними каждый показ уникален и кэш рендера перестаёт работать. */
export const VOLATILE_TOKENS = new Set(['time', 'date', 'weekday'])
const TOKEN_PATTERN = /\{([a-zA-Z][a-zA-Z.]*)\}/g
const SAMPLES = new Map(PLACEHOLDERS.map((p) => [p.token, p.sample]))
/** Как строка будет выглядеть в кадре: подстановка образцами + схлопывание лишних пробелов. */
export function resolveSample(text: string) {
return text
.replace(TOKEN_PATTERN, (_, token: string) => SAMPLES.get(token) ?? '')
.replace(/[ \t]{2,}/g, ' ')
.trim()
}
/** Плейсхолдеры строки, которых нет в списке допустимых, — их сервер отвергнет при сохранении. */
export function unknownTokens(text: string) {
return [...text.matchAll(TOKEN_PATTERN)].map((m) => m[1]).filter((token) => !SAMPLES.has(token))
}
export function hasVolatileToken(text: string) {
return [...text.matchAll(TOKEN_PATTERN)].some((m) => VOLATILE_TOKENS.has(m[1]))
}
@@ -19,7 +19,6 @@ import {
restoreChannelTemplate,
} from './api'
import { ApplyDialog } from './components/ApplyDialog'
import { BumperCard } from './components/BumperCard'
import { EntryTraceDialog } from './components/EntryTraceDialog'
import { GridTab } from './components/GridTab'
import { JunctionsCard } from './components/JunctionsCard'
@@ -29,7 +28,7 @@ import { SettingsCard } from './components/SettingsCard'
import { ViewerCard } from './components/ViewerCard'
/** Вкладки экрана канала: настройки первыми — с них канал и начинается. */
const TABS = ['settings', 'grid', 'rules', 'junctions', 'bumpers', 'viewer', 'air'] as const
const TABS = ['settings', 'grid', 'rules', 'junctions', 'viewer', 'air'] as const
type ChannelTab = (typeof TABS)[number]
export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) {
@@ -180,17 +179,7 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) {
))}
{tab === 'junctions' && (
<JunctionsCard
channel={channel}
template={template}
bare
onChanged={invalidate}
onError={onError}
/>
)}
{tab === 'bumpers' && (
<BumperCard channel={channel} bare onSaved={invalidate} onError={onError} />
<JunctionsCard template={template} bare onChanged={invalidate} onError={onError} />
)}
{tab === 'viewer' && (
+1 -211
View File
@@ -1,9 +1,6 @@
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
import { apiRequest } from '@/shared/api/client'
import type {
ApplyResultDto,
BumperSettings,
BumperTextKind,
BumperTrigger,
ChannelDto,
ChannelSummaryDto,
CopyTemplateResultDto,
@@ -14,10 +11,6 @@ import type {
GridPlanDto,
GridProfileDto,
GridProfileKind,
JunctionAmountMode,
JunctionConditions,
JunctionElementKind,
JunctionTemplateDto,
LayerApplicability,
PlanningRules,
RestoreTemplateResultDto,
@@ -45,8 +38,6 @@ export function createChannel(body: { name: string; slug: string }) {
type ChannelSettingsBody = {
name: string
isEnabled: boolean
bumpersEnabled: boolean
bumper: BumperSettings
fillerAssetId: string | null
}
@@ -209,207 +200,6 @@ export function deleteSlot(slotId: string) {
// ── Стыки канала ──────────────────────────────────────────────────────────
export function listJunctions(channelId: string) {
return apiRequest<JunctionTemplateDto[]>(`/admin/channels/${channelId}/junctions`)
}
export function createJunction(channelId: string, name: string) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${channelId}/junctions`, {
method: 'POST',
body: { name },
})
}
export function renameJunction(junctionId: string, name: string) {
return apiRequest<void>(`/admin/junctions/${junctionId}`, { method: 'PUT', body: { name } })
}
export function deleteJunction(junctionId: string) {
return apiRequest<void>(`/admin/junctions/${junctionId}`, { method: 'DELETE' })
}
export function addJunctionElement(junctionId: string, kind: JunctionElementKind) {
return apiRequest<CreatedIdResponse>(`/admin/junctions/${junctionId}/elements`, {
method: 'POST',
body: { kind },
})
}
/** Тело врезки: то же для любого типа — лишние поля сервер обнуляет сам (см. JunctionElement.Update). */
export type JunctionElementBody = {
kind: JunctionElementKind
groupId: string | null
bumperTemplateId: string | null
amountMode: JunctionAmountMode
amountValue: number
isRequired: boolean
conditions: JunctionConditions | null
}
export function updateJunctionElement(
junctionId: string,
elementId: string,
body: JunctionElementBody,
) {
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
method: 'PUT',
body,
})
}
export function removeJunctionElement(junctionId: string, elementId: string) {
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
method: 'DELETE',
})
}
/** Порядок врезок: не упомянутые остаются после перечисленных. */
export function reorderJunction(junctionId: string, elementIdsInOrder: string[]) {
return apiRequest<void>(`/admin/junctions/${junctionId}/order`, {
method: 'PUT',
body: { elementIdsInOrder },
})
}
type BumperTemplateStyleBody = {
name: string
backgroundColor: string
backgroundColor2: string
accentColor: string
textColor: string
}
export function addBumperTemplate(id: string, name: string) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/bumper/templates`, {
method: 'POST',
body: { name },
})
}
export function updateBumperTemplate(
id: string,
templateId: string,
body: BumperTemplateStyleBody,
) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
method: 'PUT',
body,
})
}
export function removeBumperTemplate(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
method: 'DELETE',
})
}
/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */
function uploadBumperTemplateFile(
id: string,
templateId: string,
kind: 'audio' | 'background',
file: File,
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const query = new URLSearchParams({ fileName: file.name })
xhr.open(
'PUT',
`/api/admin/channels/${id}/bumper/templates/${templateId}/${kind}?${query.toString()}`,
)
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
let detail = `HTTP ${xhr.status}`
try {
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
detail = problem.detail ?? problem.title ?? detail
} catch {
/* пусто */
}
reject(new HttpError({ detail }, xhr.status))
}
}
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
xhr.send(file)
})
}
export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) {
return uploadBumperTemplateFile(id, templateId, 'audio', file)
}
type BumperVariantBody = {
name: string
kind: BumperTextKind
nowLabel: string
nextLabel: string
line1: string
line2: string
trigger: BumperTrigger
weight: number
}
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`, {
method: 'PUT',
body: { imageId },
})
}
export function clearBumperTemplateAudio(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, {
method: 'DELETE',
})
}
export function clearBumperTemplateBackground(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
method: 'DELETE',
})
}
/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */
export function renderBumperPreviews(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
method: 'POST',
})
}
export function bumperPreviewPlaylistUrl(id: string, templateId: string, variantId: string) {
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/${variantId}/index.m3u8`
}
export function getSchedule(id: string, from: Date, to: Date) {
const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() })
return apiRequest<ScheduleEntryDto[]>(`/admin/channels/${id}/schedule?${query.toString()}`)
@@ -1,80 +0,0 @@
import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { imageUrl } from '@/features/admin/images/api'
import { ImageGallery } from '@/features/admin/images/ImageGallery'
import { Button } from '@/shared/ui/button'
import { Label } from '@/shared/ui/label'
import { clearBumperTemplateBackground, setBumperTemplateBackground } from '../api'
export function BumperBackgroundField({
channelId,
templateId,
backgroundImageId,
onChanged,
onError,
}: Readonly<{
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>
)
}
@@ -1,153 +0,0 @@
import { useMutation } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { addBumperTemplate, updateChannelSettings } from '../api'
import { BumperTemplateEditor } from './BumperTemplateEditor'
import { CollapsibleCard } from './CollapsibleCard'
export function BumperCard({
channel,
bare,
onSaved,
onError,
}: Readonly<{
channel: ChannelDto
bare?: boolean
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,
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')}
bare={bare}
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">
<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="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
<SelectItem value="WeightedRandom">
{t('admin.channels.bumperSelectionWeighted')}
</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>
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperConditionsHint')}</p>
<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>
)
}
@@ -1,85 +0,0 @@
import { useMutation } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { Label } from '@/shared/ui/label'
export function BumperFileUpload({
channelId,
templateId,
kind,
label,
hint,
has,
accept,
upload,
clear,
onSaved,
onError,
}: Readonly<{
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>
)
}
@@ -1,185 +0,0 @@
import { useMutation } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { BumperTextKind, BumperTextVariantDto, BumperTrigger } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
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 { removeBumperVariant, updateBumperVariant } from '../api'
export function BumperVariantEditor({
channelId,
templateId,
variant,
canRemove,
onChanged,
onError,
}: Readonly<{
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,
weight: variant.weight,
})
useEffect(() => {
setForm({
name: variant.name,
kind: variant.kind,
nowLabel: variant.nowLabel,
nextLabel: variant.nextLabel,
line1: variant.line1,
line2: variant.line2,
trigger: variant.trigger,
weight: variant.weight,
})
}, [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 className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperVariantWeight')}</Label>
<Input
type="number"
min={0}
max={1000}
value={form.weight}
onChange={(e) => set('weight', Math.max(0, Math.round(Number(e.target.value)) || 0))}
/>
<span className="text-xs text-muted-foreground">
{t('admin.channels.bumperVariantWeightHint')}
</span>
</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>
)
}
@@ -371,12 +371,7 @@ export function GridTab({
}}
/>
{draft && (
<SlotInspector
channelId={channelId}
draft={draft}
onClose={() => setDraft(null)}
onChanged={onChanged}
/>
<SlotInspector draft={draft} onClose={() => setDraft(null)} onChanged={onChanged} />
)}
</div>
</div>
@@ -1,217 +0,0 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listGroups } from '@/features/admin/groups/api'
import type {
BumperTemplateDto,
JunctionAmountMode,
JunctionElementDto,
JunctionElementKind,
} from '@/shared/api/types'
import { qk } from '@/shared/api/query-keys'
import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { removeJunctionElement, updateJunctionElement, type JunctionElementBody } from '../api'
const KINDS: JunctionElementKind[] = ['Ad', 'Promo', 'Bumper', 'Filler']
const AMOUNT_MODES: JunctionAmountMode[] = ['Count', 'Duration']
function toBody(element: JunctionElementDto): JunctionElementBody {
return {
kind: element.kind,
groupId: element.groupId,
bumperTemplateId: element.bumperTemplateId,
amountMode: element.amountMode,
amountValue: element.amountValue,
isRequired: element.isRequired,
conditions: element.conditions ?? { onlyOnElementChange: false, minMinutesBetween: 0 },
}
}
/** Параметры одной врезки: тип, источник, сколько её и при каких условиях ставить. */
export function JunctionElementDialog({
junctionId,
element,
bumperTemplates,
onClose,
onChanged,
onError,
}: Readonly<{
junctionId: string
element: JunctionElementDto
bumperTemplates: BumperTemplateDto[]
onClose: () => void
onChanged: () => void
onError: (error: unknown) => void
}>) {
const { t } = useTranslation()
const [body, setBody] = useState<JunctionElementBody>(() => toBody(element))
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
const patch = (part: Partial<JunctionElementBody>) => setBody((prev) => ({ ...prev, ...part }))
const save = useMutation({
mutationFn: () => updateJunctionElement(junctionId, element.id, body),
onSuccess: () => {
onChanged()
onClose()
},
onError,
})
const remove = useMutation({
mutationFn: () => removeJunctionElement(junctionId, element.id),
onSuccess: () => {
onChanged()
onClose()
},
onError,
})
const isBumper = body.kind === 'Bumper'
const conditions = body.conditions ?? { onlyOnElementChange: false, minMinutesBetween: 0 }
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.channels.junctionElement')}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.junctionKind')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.kind}
onChange={(e) => patch({ kind: e.target.value as JunctionElementKind })}
>
{KINDS.map((kind) => (
<option key={kind} value={kind}>
{t(`admin.channels.junctionKinds.${kind}`)}
</option>
))}
</select>
</div>
{isBumper ? (
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperTemplate')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.bumperTemplateId ?? ''}
onChange={(e) => patch({ bumperTemplateId: e.target.value || null })}
>
<option value="">{t('admin.channels.pickBumperTemplate')}</option>
{bumperTemplates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</div>
) : (
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.group')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.groupId ?? ''}
onChange={(e) => patch({ groupId: e.target.value || null })}
>
<option value="">{t('admin.channels.pickGroup')}</option>
{(groups ?? []).map((group) => (
<option key={group.id} value={group.id}>
{group.name} · {group.itemCount}
</option>
))}
</select>
</div>
)}
{!isBumper && (
<>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.junctionAmountMode')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.amountMode}
onChange={(e) => patch({ amountMode: e.target.value as JunctionAmountMode })}
>
{AMOUNT_MODES.map((mode) => (
<option key={mode} value={mode}>
{t(`admin.channels.junctionAmountModes.${mode}`)}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label>
{body.amountMode === 'Count'
? t('admin.channels.junctionCount')
: t('admin.channels.junctionMinutes')}
</Label>
<Input
type="number"
min={1}
value={body.amountValue}
onChange={(e) => patch({ amountValue: Number(e.target.value) })}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
{t('admin.channels.junctionAmountHint')}
</p>
</>
)}
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={body.isRequired}
onChange={(e) => patch({ isRequired: e.target.checked })}
/>
{t('admin.channels.junctionRequired')}
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={conditions.onlyOnElementChange}
onChange={(e) =>
patch({ conditions: { ...conditions, onlyOnElementChange: e.target.checked } })
}
/>
{t('admin.channels.junctionOnlyOnChange')}
</label>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.junctionMinInterval')}</Label>
<Input
type="number"
min={0}
className="w-32"
value={conditions.minMinutesBetween}
onChange={(e) =>
patch({ conditions: { ...conditions, minMinutesBetween: Number(e.target.value) } })
}
/>
<p className="text-xs text-muted-foreground">
{t('admin.channels.junctionMinIntervalHint')}
</p>
</div>
</div>
<DialogFooter>
<Button size="sm" variant="destructive" onClick={() => remove.mutate()}>
{t('common.delete')}
</Button>
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,115 +1,39 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { ChevronRight, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { Link } from '@tanstack/react-router'
import { ExternalLink } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { listBumperTemplates } from '@/features/admin/bumpers/api'
import { listGroups } from '@/features/admin/groups/api'
import { listJunctions } from '@/features/admin/junctions/api'
import { formatClock } from '@/features/admin/interstitials/format'
import type {
ChannelDto,
GroupSummaryDto,
JunctionElementDto,
JunctionElementKind,
JunctionTemplateDto,
ScheduleTemplateDto,
} from '@/shared/api/types'
import { stepSeconds, toSteps } from '@/features/admin/junctions/lib'
import type { JunctionTemplateDto, ScheduleTemplateDto } from '@/shared/api/types'
import { qk } from '@/shared/api/query-keys'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { cn } from '@/shared/lib/cn'
import {
addJunctionElement,
createJunction,
deleteJunction,
listJunctions,
renameJunction,
reorderJunction,
updateTemplate,
} from '../api'
import { updateTemplate } from '../api'
import { CollapsibleCard } from './CollapsibleCard'
import { JunctionElementDialog } from './JunctionElementDialog'
/** Заставка без загруженного звука синтезируется фиксированной длины — та же цифра, что на сервере. */
const DEFAULT_BUMPER_SECONDS = 8
const KIND_COLORS: Record<JunctionElementKind, string> = {
Ad: 'bg-amber-500/70',
Promo: 'bg-sky-500/70',
Bumper: 'bg-violet-500/70',
Filler: 'bg-muted-foreground/40',
}
type Translate = ReturnType<typeof useTranslation>['t']
/** Что уточняет подпись врезки: имя блока заставки либо объём («×3» / «×5 мин»). */
function elementSuffix(element: JunctionElementDto, t: Translate) {
if (element.kind === 'Bumper')
return element.bumperTemplateName ? ` · ${element.bumperTemplateName}` : ''
const units = element.amountMode === 'Duration' ? t('admin.channels.minutesShort') : ''
return ` ×${element.amountValue}${units}`
}
/** Подсказка сегмента линейки: вид врезки и её оценочная длительность. */
function elementTitle(element: JunctionElementDto, seconds: number, t: Translate) {
const kind = t(`admin.channels.junctionKinds.${element.kind}`)
return `${kind} · ${formatClock(seconds)}`
}
/**
* Прикидка длины врезки для линейки. По длительности — точная, по количеству — среднее единицы
* группы: в смешанной группе «одна единица» это то ли ролик, то ли блок, поэтому число заведомо
* приблизительное и помечается как оценка.
* Стыки канала: что канал выбрал, а не как стык устроен. Сами стыки общие и правятся в своём
* разделе — держать их редактор ещё и здесь значило бы иметь два места для одного и того же.
*/
function estimateSeconds(
element: JunctionElementDto,
groups: GroupSummaryDto[] | undefined,
channel: ChannelDto,
): { seconds: number; exact: boolean } {
if (element.kind === 'Bumper') {
const template = channel.bumperTemplates.find((b) => b.id === element.bumperTemplateId)
return { seconds: template?.audioDurationSeconds ?? DEFAULT_BUMPER_SECONDS, exact: true }
}
if (element.amountMode === 'Duration') return { seconds: element.amountValue * 60, exact: true }
const group = groups?.find((g) => g.id === element.groupId)
if (!group || group.unitCount === 0) return { seconds: 0, exact: false }
return {
seconds: (element.amountValue * group.totalDurationSeconds) / group.unitCount,
exact: false,
}
}
export function JunctionsCard({
channel,
template,
bare,
onChanged,
onError,
}: Readonly<{
channel: ChannelDto
template: ScheduleTemplateDto | undefined
bare?: boolean
onChanged: () => void
onError: (error: unknown) => void
}>) {
const { t } = useTranslation()
const [newName, setNewName] = useState('')
const { data: junctions } = useQuery({
queryKey: qk.channels.junctions(channel.id),
queryFn: () => listJunctions(channel.id),
})
const { data: junctions } = useQuery({ queryKey: qk.junctions.all, queryFn: listJunctions })
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
const createMutation = useMutation({
mutationFn: () => createJunction(channel.id, newName.trim()),
onSuccess: () => {
setNewName('')
onChanged()
},
onError,
})
const { data: bumpers } = useQuery({ queryKey: qk.bumpers.all, queryFn: listBumperTemplates })
const defaultMutation = useMutation({
mutationFn: (junctionId: string | null) =>
@@ -123,6 +47,25 @@ export function JunctionsCard({
onError,
})
// Что реально играет в этом канале: стык по умолчанию плюс всё, на что ссылаются слоты.
const usedIds = new Set<string>()
if (template?.defaultJunctionId) usedIds.add(template.defaultJunctionId)
for (const layer of template?.layers ?? [])
for (const slot of layer.slots) {
if (slot.junctionBetweenId) usedIds.add(slot.junctionBetweenId)
if (slot.junctionAfterId) usedIds.add(slot.junctionAfterId)
}
const used = (junctions ?? []).filter((j) => usedIds.has(j.id))
const summary = (junction: JunctionTemplateDto) => {
const steps = toSteps(junction.elements)
const total = steps.reduce((sum, step) => sum + stepSeconds(step, groups, bumpers).seconds, 0)
const chain = steps
.map((step) => step.elements.map((e) => t(`admin.junctions.kinds.${e.kind}`)).join(' | '))
.join(' → ')
return { chain: chain || t('admin.junctions.empty'), total }
}
return (
<CollapsibleCard title={t('admin.channels.junctions')} bare={bare}>
<div className="flex flex-col gap-4">
@@ -146,194 +89,34 @@ export function JunctionsCard({
</div>
)}
{(junctions ?? []).map((junction) => (
<JunctionChain
key={junction.id}
junction={junction}
channel={channel}
groups={groups}
onChanged={onChanged}
onError={onError}
/>
))}
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">{t('admin.channels.junctionsUsed')}</p>
{used.length === 0 && (
<p className="text-xs text-muted-foreground">{t('admin.channels.junctionsNoneUsed')}</p>
)}
{used.map((junction) => {
const { chain, total } = summary(junction)
return (
<div
key={junction.id}
className="flex flex-wrap items-center gap-2 rounded-md border border-border px-3 py-2 text-xs"
>
<span className="font-medium">{junction.name}</span>
<span className="text-muted-foreground">{chain}</span>
<span className="ml-auto text-muted-foreground"> {formatClock(total)}</span>
</div>
)
})}
</div>
<div className="flex max-w-md gap-2">
<Input
placeholder={t('admin.channels.newJunctionName')}
value={newName}
maxLength={128}
onChange={(e) => setNewName(e.target.value)}
/>
<Button
size="sm"
variant="outline"
disabled={!newName.trim() || createMutation.isPending}
onClick={() => createMutation.mutate()}
>
<Plus className="h-4 w-4" /> {t('common.create')}
<div>
<Button asChild size="sm" variant="outline">
<Link to="/admin/junctions">
<ExternalLink className="h-4 w-4" /> {t('admin.channels.openJunctionEditor')}
</Link>
</Button>
</div>
</div>
</CollapsibleCard>
)
}
const ADDABLE: JunctionElementKind[] = ['Ad', 'Promo', 'Bumper', 'Filler']
function JunctionChain({
junction,
channel,
groups,
onChanged,
onError,
}: Readonly<{
junction: JunctionTemplateDto
channel: ChannelDto
groups: GroupSummaryDto[] | undefined
onChanged: () => void
onError: (error: unknown) => void
}>) {
const { t } = useTranslation()
const [name, setName] = useState<string | null>(null)
const [dragged, setDragged] = useState<string | null>(null)
const [editing, setEditing] = useState<JunctionElementDto | null>(null)
const renameMutation = useMutation({
mutationFn: (value: string) => renameJunction(junction.id, value),
onSuccess: () => {
setName(null)
onChanged()
},
onError,
})
const deleteMutation = useMutation({
mutationFn: () => deleteJunction(junction.id),
onSuccess: onChanged,
onError,
})
const addMutation = useMutation({
mutationFn: (kind: JunctionElementKind) => addJunctionElement(junction.id, kind),
onSuccess: onChanged,
onError,
})
const reorderMutation = useMutation({
mutationFn: (order: string[]) => reorderJunction(junction.id, order),
onSuccess: onChanged,
onError,
})
const elements = [...junction.elements].sort((a, b) => a.position - b.position)
const estimates = elements.map((element) => estimateSeconds(element, groups, channel))
const total = estimates.reduce((sum, e) => sum + e.seconds, 0)
const exact = estimates.every((e) => e.exact)
const dropOn = (targetId: string) => {
if (!dragged || dragged === targetId) return
const order = elements.map((e) => e.id).filter((id) => id !== dragged)
order.splice(order.indexOf(targetId), 0, dragged)
setDragged(null)
reorderMutation.mutate(order)
}
return (
<div className="crt-panel flex flex-col gap-2 rounded-md p-3">
<div className="flex flex-wrap items-center gap-2">
<Input
className="h-8 max-w-56"
value={name ?? junction.name}
maxLength={128}
onChange={(e) => setName(e.target.value)}
onBlur={() =>
name !== null && name.trim() && name !== junction.name
? renameMutation.mutate(name.trim())
: setName(null)
}
/>
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
value=""
onChange={(e) =>
e.target.value && addMutation.mutate(e.target.value as JunctionElementKind)
}
>
<option value="">{t('admin.channels.addJunctionElement')}</option>
{ADDABLE.map((kind) => (
<option key={kind} value={kind}>
{t(`admin.channels.junctionKinds.${kind}`)}
</option>
))}
</select>
<span className="ml-auto text-xs text-muted-foreground">
{exact ? '' : '≈ '}
{formatClock(total)}
</span>
<Button size="sm" variant="ghost" onClick={() => deleteMutation.mutate()}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Цепочка: что играет между концом одной программы и началом следующей. */}
<div className="flex flex-wrap items-center gap-1 text-xs">
<span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground">
{t('admin.channels.junctionFrom')}
</span>
{elements.length === 0 && (
<>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">{t('admin.channels.junctionEmpty')}</span>
</>
)}
{elements.map((element) => (
<span key={element.id} className="flex items-center gap-1">
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
<button
type="button"
draggable
onDragStart={() => setDragged(element.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => dropOn(element.id)}
onClick={() => setEditing(element)}
className={cn(
'cursor-grab rounded border border-border px-2 py-1 hover:border-primary',
element.isRequired && 'border-primary/70',
)}
>
{t(`admin.channels.junctionKinds.${element.kind}`)}
{elementSuffix(element, t)}
{element.isRequired && ' *'}
</button>
</span>
))}
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground">
{t('admin.channels.junctionTo')}
</span>
</div>
{/* Линейка: доля каждой врезки в стыке. Пустые (без группы) в неё не попадают. */}
{total > 0 && (
<div className="flex h-2 overflow-hidden rounded-full bg-muted/40">
{elements.map((element, index) => (
<div
key={element.id}
className={KIND_COLORS[element.kind]}
style={{ width: `${(estimates[index].seconds / total) * 100}%` }}
title={elementTitle(element, estimates[index].seconds, t)}
/>
))}
</div>
)}
{editing && (
<JunctionElementDialog
junctionId={junction.id}
element={editing}
bumperTemplates={channel.bumperTemplates}
onClose={() => setEditing(null)}
onChanged={onChanged}
onError={onError}
/>
)}
</div>
)
}
@@ -47,9 +47,6 @@ export function SettingsCard({
await updateChannelSettings(channel.id, {
name: name.trim(),
isEnabled,
// Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений.
bumpersEnabled: channel.bumpersEnabled,
bumper: channel.bumper,
fillerAssetId: fillerAssetId || null,
})
await updateChannelTime(channel.id, {
@@ -3,6 +3,7 @@ import { Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listGroups } from '@/features/admin/groups/api'
import { listJunctions } from '@/features/admin/junctions/api'
import type {
Daypart,
OverflowPolicy,
@@ -17,14 +18,7 @@ import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import {
createSlot,
deleteSlot,
listJunctions,
toSlotBody,
updateSlot,
type SlotBody,
} from '../api'
import { createSlot, deleteSlot, toSlotBody, updateSlot, type SlotBody } from '../api'
const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night']
const SLOT_KINDS: SlotKind[] = ['Content', 'Repeat', 'SignOff']
@@ -65,12 +59,10 @@ function emptyBody(defaults?: Partial<SlotBody>): SlotBody {
}
export function SlotInspector({
channelId,
draft,
onClose,
onChanged,
}: Readonly<{
channelId: string
draft: SlotDraft
onClose: () => void
onChanged: () => void
@@ -85,10 +77,8 @@ export function SlotInspector({
}, [draft])
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
const { data: junctions } = useQuery({
queryKey: qk.channels.junctions(channelId),
queryFn: () => listJunctions(channelId),
})
// Стыки общие для всех каналов — слот только выбирает, какой поставить.
const { data: junctions } = useQuery({ queryKey: qk.junctions.all, queryFn: listJunctions })
const onError = useApiError()
@@ -0,0 +1,83 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Plus } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listBumperTemplates } from '@/features/admin/bumpers/api'
import { listGroups } from '@/features/admin/groups/api'
import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button'
import { Card, CardContent } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { createJunction, listJunctions } from './api'
import { JunctionChain } from './components/JunctionChain'
/**
* Стыки — общие для всех каналов, как группы. Канал только выбирает, какой стык поставить
* в слот; собирается стык здесь.
*/
export function JunctionsPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const onError = useApiError()
const [name, setName] = useState('')
const { data: junctions } = useQuery({ queryKey: qk.junctions.all, queryFn: listJunctions })
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
const { data: bumpers } = useQuery({ queryKey: qk.bumpers.all, queryFn: listBumperTemplates })
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: qk.junctions.all })
}
const create = useMutation({
mutationFn: () => createJunction(name.trim()),
onSuccess: () => {
setName('')
invalidate()
},
onError,
})
return (
<div className="flex flex-col gap-4">
<Card>
<CardContent className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">{t('admin.junctions.hint')}</p>
<div className="flex max-w-md gap-2">
<Input
placeholder={t('admin.junctions.newName')}
value={name}
maxLength={128}
onChange={(e) => setName(e.target.value)}
/>
<Button
size="sm"
variant="outline"
disabled={!name.trim() || create.isPending}
onClick={() => create.mutate()}
>
<Plus className="h-4 w-4" /> {t('common.create')}
</Button>
</div>
</CardContent>
</Card>
<div className="flex flex-col gap-3">
{(junctions ?? []).map((junction) => (
<JunctionChain
key={junction.id}
junction={junction}
groups={groups}
bumpers={bumpers}
onChanged={invalidate}
onError={onError}
/>
))}
{junctions?.length === 0 && (
<p className="text-sm text-muted-foreground">{t('admin.junctions.empty0')}</p>
)}
</div>
</div>
)
}
@@ -0,0 +1,76 @@
import { apiRequest } from '@/shared/api/client'
import type {
CreatedIdResponse,
JunctionAmountMode,
JunctionConditions,
JunctionElementKind,
JunctionTemplateDto,
} from '@/shared/api/types'
/** Стыки общие для всех каналов — свой раздел, а не подраздел канала. */
export function listJunctions() {
return apiRequest<JunctionTemplateDto[]>('/admin/junctions')
}
export function createJunction(name: string) {
return apiRequest<CreatedIdResponse>('/admin/junctions', { method: 'POST', body: { name } })
}
export function updateJunction(junctionId: string, name: string, maxTotalSeconds: number | null) {
return apiRequest<void>(`/admin/junctions/${junctionId}`, {
method: 'PUT',
body: { name, maxTotalSeconds },
})
}
export function deleteJunction(junctionId: string) {
return apiRequest<void>(`/admin/junctions/${junctionId}`, { method: 'DELETE' })
}
export function addJunctionElement(junctionId: string, kind: JunctionElementKind) {
return apiRequest<CreatedIdResponse>(`/admin/junctions/${junctionId}/elements`, {
method: 'POST',
body: { kind },
})
}
/** Тело врезки: то же для любого типа — лишние поля сервер обнуляет сам (см. JunctionElement.Update). */
export type JunctionElementBody = {
kind: JunctionElementKind
groupId: string | null
bumperTemplateId: string | null
bumperVariantId: string | null
amountMode: JunctionAmountMode
amountValue: number
isRequired: boolean
choiceKey: string | null
choiceWeight: number
conditions: JunctionConditions | null
}
export function updateJunctionElement(
junctionId: string,
elementId: string,
body: JunctionElementBody,
) {
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
method: 'PUT',
body,
})
}
export function removeJunctionElement(junctionId: string, elementId: string) {
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
method: 'DELETE',
})
}
/** Порядок и развилки едут вместе: перетаскивание в цепочке меняет и то, и другое. */
export type JunctionElementOrder = { elementId: string; choiceKey: string | null }
export function reorderJunction(junctionId: string, order: JunctionElementOrder[]) {
return apiRequest<void>(`/admin/junctions/${junctionId}/order`, {
method: 'PUT',
body: { order },
})
}
@@ -0,0 +1,326 @@
import { useMutation } from '@tanstack/react-query'
import { ChevronRight, Merge, Split, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { formatClock } from '@/features/admin/interstitials/format'
import type {
BumperTemplateDto,
GroupSummaryDto,
JunctionElementDto,
JunctionElementKind,
JunctionTemplateDto,
} from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import {
addJunctionElement,
deleteJunction,
reorderJunction,
updateJunction,
type JunctionElementOrder,
} from '../api'
import { choicePercent, KIND_COLORS, stepSeconds, toSteps, type ChainStep } from '../lib'
import { JunctionElementDialog } from './JunctionElementDialog'
const ADDABLE: JunctionElementKind[] = ['Ad', 'Promo', 'Bumper', 'Filler']
type Translate = ReturnType<typeof useTranslation>['t']
/** Что уточняет подпись врезки: имя блока заставки либо объём («×3» / «×5 мин»). */
function elementSuffix(element: JunctionElementDto, t: Translate) {
if (element.kind === 'Bumper') {
const name = element.bumperVariantName ?? element.bumperTemplateName
return name ? ` · ${name}` : ''
}
const units = element.amountMode === 'Duration' ? t('admin.junctions.minutesShort') : ''
return ` ×${element.amountValue}${units}`
}
/** Условия врезки одной строкой — иначе их не видно, не открыв каждую. */
function conditionsHint(element: JunctionElementDto, t: Translate) {
const parts: string[] = []
const c = element.conditions
if (c?.onlyOnElementChange) parts.push(t('admin.junctions.badgeOnChange'))
if (c && c.chance < 100) parts.push(`${c.chance}%`)
if (c && c.minMinutesBetween > 0)
parts.push(t('admin.junctions.badgeInterval', { minutes: c.minMinutesBetween }))
if (c?.timeWindow) parts.push(`${c.timeWindow.from.slice(0, 5)}${c.timeWindow.to.slice(0, 5)}`)
if (c?.dayparts?.length) parts.push(c.dayparts.map((d) => t(`admin.channels.dayparts.${d}`)).join('/'))
return parts.join(' · ')
}
export function JunctionChain({
junction,
groups,
bumpers,
onChanged,
onError,
}: Readonly<{
junction: JunctionTemplateDto
groups: GroupSummaryDto[] | undefined
bumpers: BumperTemplateDto[] | undefined
onChanged: () => void
onError: (error: unknown) => void
}>) {
const { t } = useTranslation()
const [name, setName] = useState<string | null>(null)
const [maxTotal, setMaxTotal] = useState<string | null>(null)
const [dragged, setDragged] = useState<string | null>(null)
const [editing, setEditing] = useState<JunctionElementDto | null>(null)
const steps = toSteps(junction.elements)
const estimates = steps.map((step) => stepSeconds(step, groups, bumpers))
const total = estimates.reduce((sum, e) => sum + e.seconds, 0)
const exact = estimates.every((e) => e.exact)
const overCap = junction.maxTotalSeconds != null && total > junction.maxTotalSeconds
const saveHeader = useMutation({
mutationFn: (next: { name: string; maxTotalSeconds: number | null }) =>
updateJunction(junction.id, next.name, next.maxTotalSeconds),
onSuccess: () => {
setName(null)
setMaxTotal(null)
onChanged()
},
onError,
})
const removeJunction = useMutation({
mutationFn: () => deleteJunction(junction.id),
onSuccess: onChanged,
onError,
})
const addElement = useMutation({
mutationFn: (kind: JunctionElementKind) => addJunctionElement(junction.id, kind),
onSuccess: onChanged,
onError,
})
const reorder = useMutation({
mutationFn: (order: JunctionElementOrder[]) => reorderJunction(junction.id, order),
onSuccess: onChanged,
onError,
})
const flat = (): JunctionElementOrder[] =>
steps.flatMap((step) => step.elements.map((e) => ({ elementId: e.id, choiceKey: e.choiceKey })))
/** Перетаскивание: врезка встаёт перед целевым звеном, метка развилки сохраняется. */
const dropOn = (targetStep: ChainStep) => {
if (!dragged) return
const order = flat().filter((o) => o.elementId !== dragged)
const draggedItem = flat().find((o) => o.elementId === dragged)
const at = order.findIndex((o) => o.elementId === targetStep.elements[0].id)
if (draggedItem) order.splice(at < 0 ? order.length : at, 0, draggedItem)
setDragged(null)
reorder.mutate(order)
}
/** Объединить с предыдущим звеном в развилку — одна кнопка вместо отдельного редактора групп. */
const mergeWithPrevious = (index: number) => {
const previous = steps[index - 1]
const key = previous.choiceKey ?? `fork-${previous.elements[0].id.slice(0, 8)}`
const order = flat().map((o) =>
previous.elements.some((e) => e.id === o.elementId) ||
steps[index].elements.some((e) => e.id === o.elementId)
? { ...o, choiceKey: key }
: o,
)
reorder.mutate(order)
}
const splitOut = (element: JunctionElementDto) => {
const order = flat().map((o) => (o.elementId === element.id ? { ...o, choiceKey: null } : o))
reorder.mutate(order)
}
return (
<div className="crt-panel flex flex-col gap-2 rounded-md p-3">
<div className="flex flex-wrap items-center gap-2">
<Input
className="h-8 max-w-56"
value={name ?? junction.name}
maxLength={128}
onChange={(e) => setName(e.target.value)}
onBlur={() =>
name !== null && name.trim() && name !== junction.name
? saveHeader.mutate({
name: name.trim(),
maxTotalSeconds: junction.maxTotalSeconds,
})
: setName(null)
}
/>
<label className="flex items-center gap-1 text-xs text-muted-foreground">
{t('admin.junctions.maxTotal')}
<Input
className="h-8 w-20"
type="number"
min={0}
placeholder="—"
value={maxTotal ?? junction.maxTotalSeconds ?? ''}
onChange={(e) => setMaxTotal(e.target.value)}
onBlur={() => {
if (maxTotal === null) return
const parsed = Number(maxTotal)
saveHeader.mutate({
name: junction.name,
maxTotalSeconds: maxTotal.trim() === '' || parsed <= 0 ? null : Math.round(parsed),
})
}}
/>
</label>
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
value=""
onChange={(e) =>
e.target.value && addElement.mutate(e.target.value as JunctionElementKind)
}
>
<option value="">{t('admin.junctions.addElement')}</option>
{ADDABLE.map((kind) => (
<option key={kind} value={kind}>
{t(`admin.junctions.kinds.${kind}`)}
</option>
))}
</select>
{junction.channelUsageCount > 0 && (
<span className="text-xs text-muted-foreground">
{t('admin.junctions.usedInChannels', { count: junction.channelUsageCount })}
</span>
)}
<span
className={cn('ml-auto text-xs', overCap ? 'text-destructive' : 'text-muted-foreground')}
>
{exact ? '' : '≈ '}
{formatClock(total)}
{junction.maxTotalSeconds != null && ` / ${formatClock(junction.maxTotalSeconds)}`}
</span>
<Button
size="sm"
variant="ghost"
disabled={junction.channelUsageCount > 0}
title={junction.channelUsageCount > 0 ? t('admin.junctions.cannotDeleteUsed') : undefined}
onClick={() => removeJunction.mutate()}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Цепочка: что играет между концом одной программы и началом следующей. */}
<div className="flex flex-wrap items-stretch gap-1 text-xs">
<span className="self-center rounded border border-border px-2 py-1 uppercase text-muted-foreground">
{t('admin.junctions.from')}
</span>
{steps.length === 0 && (
<>
<ChevronRight className="h-3 w-3 self-center text-muted-foreground" />
<span className="self-center text-muted-foreground">{t('admin.junctions.empty')}</span>
</>
)}
{steps.map((step, index) => (
<div key={step.key} className="flex items-stretch gap-1">
<ChevronRight className="h-3 w-3 shrink-0 self-center text-muted-foreground" />
<div
onDragOver={(e) => e.preventDefault()}
onDrop={() => dropOn(step)}
className={cn(
'flex flex-col gap-1 rounded',
step.elements.length > 1 && 'border border-dashed border-primary/50 p-1',
)}
>
{step.elements.length > 1 && (
<span className="px-1 text-[10px] uppercase tracking-wide text-primary/80">
{t('admin.junctions.fork')}
</span>
)}
{step.elements.map((element) => (
<div key={element.id} className="flex items-center gap-1">
<button
type="button"
draggable
onDragStart={() => setDragged(element.id)}
onClick={() => setEditing(element)}
className={cn(
'flex cursor-grab flex-col items-start rounded border border-border px-2 py-1 text-left hover:border-primary',
element.isRequired && 'border-primary/70',
)}
>
<span>
{step.elements.length > 1 && (
<span className="mr-1 text-primary/80">
{choicePercent(step, element)}%
</span>
)}
{t(`admin.junctions.kinds.${element.kind}`)}
{elementSuffix(element, t)}
{element.isRequired && ' *'}
</span>
{conditionsHint(element, t) && (
<span className="text-[10px] text-muted-foreground">
{conditionsHint(element, t)}
</span>
)}
</button>
{/* Развилка собирается тут же: отдельный редактор групп ради двух кнопок не нужен. */}
{step.elements.length > 1 ? (
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
title={t('admin.junctions.splitOut')}
onClick={() => splitOut(element)}
>
<Split className="h-3 w-3" />
</Button>
) : (
index > 0 && (
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
title={t('admin.junctions.mergeIntoFork')}
onClick={() => mergeWithPrevious(index)}
>
<Merge className="h-3 w-3" />
</Button>
)
)}
</div>
))}
</div>
</div>
))}
<ChevronRight className="h-3 w-3 self-center text-muted-foreground" />
<span className="self-center rounded border border-border px-2 py-1 uppercase text-muted-foreground">
{t('admin.junctions.to')}
</span>
</div>
{/* Линейка: доля каждого звена в стыке. Пустые (без источника) в неё не попадают. */}
{total > 0 && (
<div className="flex h-2 overflow-hidden rounded-full bg-muted/40">
{steps.map((step, index) => (
<div
key={step.key}
className={KIND_COLORS[step.elements[0].kind]}
style={{ width: `${(estimates[index].seconds / total) * 100}%` }}
title={`${t(`admin.junctions.kinds.${step.elements[0].kind}`)} · ${formatClock(estimates[index].seconds)}`}
/>
))}
</div>
)}
{editing && (
<JunctionElementDialog
junctionId={junction.id}
element={editing}
groups={groups}
bumpers={bumpers}
onClose={() => setEditing(null)}
onChanged={onChanged}
onError={onError}
/>
)}
</div>
)
}
@@ -0,0 +1,358 @@
import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
BumperTemplateDto,
Daypart,
GroupSummaryDto,
JunctionAmountMode,
JunctionConditions,
JunctionElementDto,
JunctionElementKind,
} from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { removeJunctionElement, updateJunctionElement, type JunctionElementBody } from '../api'
const KINDS: JunctionElementKind[] = ['Ad', 'Promo', 'Bumper', 'Filler']
const AMOUNT_MODES: JunctionAmountMode[] = ['Count', 'Duration']
const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night']
const DEFAULT_CONDITIONS: JunctionConditions = {
onlyOnElementChange: false,
minMinutesBetween: 0,
dayparts: null,
timeWindow: null,
chance: 100,
}
function toBody(element: JunctionElementDto): JunctionElementBody {
return {
kind: element.kind,
groupId: element.groupId,
bumperTemplateId: element.bumperTemplateId,
bumperVariantId: element.bumperVariantId,
amountMode: element.amountMode,
amountValue: element.amountValue,
isRequired: element.isRequired,
choiceKey: element.choiceKey,
choiceWeight: element.choiceWeight,
conditions: element.conditions ?? DEFAULT_CONDITIONS,
}
}
/** Параметры одной врезки: тип, источник, сколько её и при каких условиях ставить. */
export function JunctionElementDialog({
junctionId,
element,
groups,
bumpers,
onClose,
onChanged,
onError,
}: Readonly<{
junctionId: string
element: JunctionElementDto
groups: GroupSummaryDto[] | undefined
bumpers: BumperTemplateDto[] | undefined
onClose: () => void
onChanged: () => void
onError: (error: unknown) => void
}>) {
const { t } = useTranslation()
const [body, setBody] = useState<JunctionElementBody>(() => toBody(element))
const patch = (part: Partial<JunctionElementBody>) => setBody((prev) => ({ ...prev, ...part }))
const conditions = body.conditions ?? DEFAULT_CONDITIONS
const setConditions = (part: Partial<JunctionConditions>) =>
patch({ conditions: { ...conditions, ...part } })
const save = useMutation({
mutationFn: () => updateJunctionElement(junctionId, element.id, body),
onSuccess: () => {
onChanged()
onClose()
},
onError,
})
const remove = useMutation({
mutationFn: () => removeJunctionElement(junctionId, element.id),
onSuccess: () => {
onChanged()
onClose()
},
onError,
})
const isBumper = body.kind === 'Bumper'
const template = bumpers?.find((b) => b.id === body.bumperTemplateId)
const window = conditions.timeWindow
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.junctions.element')}</DialogTitle>
</DialogHeader>
<div className="flex max-h-[70vh] flex-col gap-3 overflow-y-auto text-sm">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.junctions.kind')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.kind}
onChange={(e) => patch({ kind: e.target.value as JunctionElementKind })}
>
{KINDS.map((kind) => (
<option key={kind} value={kind}>
{t(`admin.junctions.kinds.${kind}`)}
</option>
))}
</select>
</div>
{isBumper ? (
<>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.junctions.bumperTemplate')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.bumperTemplateId ?? ''}
onChange={(e) =>
patch({ bumperTemplateId: e.target.value || null, bumperVariantId: null })
}
>
<option value="">{t('admin.junctions.pickBumper')}</option>
{(bumpers ?? []).map((bumper) => (
<option key={bumper.id} value={bumper.id}>
{bumper.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.junctions.bumperVariant')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.bumperVariantId ?? ''}
onChange={(e) => patch({ bumperVariantId: e.target.value || null })}
>
<option value="">{t('admin.junctions.bumperVariantAuto')}</option>
{(template?.variants ?? []).map((variant) => (
<option key={variant.id} value={variant.id}>
{variant.name}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
{t('admin.junctions.bumperVariantHint')}
</p>
</div>
</>
) : (
<div className="flex flex-col gap-1.5">
<Label>{t('admin.junctions.group')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.groupId ?? ''}
onChange={(e) => patch({ groupId: e.target.value || null })}
>
<option value="">{t('admin.junctions.pickGroup')}</option>
{(groups ?? []).map((group) => (
<option key={group.id} value={group.id}>
{group.name} · {group.itemCount}
</option>
))}
</select>
</div>
)}
{!isBumper && (
<>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.junctions.amountMode')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.amountMode}
onChange={(e) => patch({ amountMode: e.target.value as JunctionAmountMode })}
>
{AMOUNT_MODES.map((mode) => (
<option key={mode} value={mode}>
{t(`admin.junctions.amountModes.${mode}`)}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label>
{body.amountMode === 'Count'
? t('admin.junctions.count')
: t('admin.junctions.minutes')}
</Label>
<Input
type="number"
min={1}
value={body.amountValue}
onChange={(e) => patch({ amountValue: Number(e.target.value) })}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('admin.junctions.amountHint')}</p>
</>
)}
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={body.isRequired}
onChange={(e) => patch({ isRequired: e.target.checked })}
/>
{t('admin.junctions.required')}
</label>
<p className="-mt-2 text-xs text-muted-foreground">{t('admin.junctions.requiredHint')}</p>
{/* Вес внутри развилки виден только когда врезка в развилке — иначе это лишнее поле. */}
{body.choiceKey && (
<div className="flex flex-col gap-1.5">
<Label>{t('admin.junctions.choiceWeight')}</Label>
<Input
type="number"
min={0}
max={1000}
className="w-32"
value={body.choiceWeight}
onChange={(e) =>
patch({ choiceWeight: Math.max(0, Math.round(Number(e.target.value)) || 0) })
}
/>
<p className="text-xs text-muted-foreground">
{t('admin.junctions.choiceWeightHint')}
</p>
</div>
)}
<div className="border-t border-border pt-3 text-xs uppercase tracking-wide text-muted-foreground">
{t('admin.junctions.conditions')}
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={conditions.onlyOnElementChange}
onChange={(e) => setConditions({ onlyOnElementChange: e.target.checked })}
/>
{t('admin.junctions.onlyOnChange')}
</label>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.junctions.chance')}</Label>
<Input
type="number"
min={0}
max={100}
value={conditions.chance}
onChange={(e) =>
setConditions({
chance: Math.min(100, Math.max(0, Math.round(Number(e.target.value)) || 0)),
})
}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.junctions.minInterval')}</Label>
<Input
type="number"
min={0}
value={conditions.minMinutesBetween}
onChange={(e) => setConditions({ minMinutesBetween: Number(e.target.value) })}
/>
</div>
</div>
<p className="-mt-1 text-xs text-muted-foreground">{t('admin.junctions.chanceHint')}</p>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.junctions.dayparts')}</Label>
<div className="flex flex-wrap gap-2">
{DAYPARTS.map((daypart) => {
const active = conditions.dayparts?.includes(daypart) ?? false
return (
<button
key={daypart}
type="button"
onClick={() => {
const current = conditions.dayparts ?? []
const next = active
? current.filter((d) => d !== daypart)
: [...current, daypart]
setConditions({ dayparts: next.length === 0 ? null : next })
}}
className={`rounded border px-2 py-1 text-xs ${
active ? 'border-primary text-primary' : 'border-border text-muted-foreground'
}`}
>
{t(`admin.channels.dayparts.${daypart}`)}
</button>
)
})}
</div>
<p className="text-xs text-muted-foreground">{t('admin.junctions.daypartsHint')}</p>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.junctions.timeWindow')}</Label>
<div className="flex items-center gap-2">
<Input
type="time"
className="w-32"
value={window?.from.slice(0, 5) ?? ''}
onChange={(e) =>
setConditions({
timeWindow: e.target.value
? { from: e.target.value, to: window?.to ?? '23:59' }
: null,
})
}
/>
<span className="text-muted-foreground"></span>
<Input
type="time"
className="w-32"
value={window?.to.slice(0, 5) ?? ''}
onChange={(e) =>
setConditions({
timeWindow: e.target.value
? { from: window?.from ?? '00:00', to: e.target.value }
: null,
})
}
/>
{window && (
<Button
size="sm"
variant="ghost"
onClick={() => setConditions({ timeWindow: null })}
>
{t('admin.junctions.clearWindow')}
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">{t('admin.junctions.timeWindowHint')}</p>
</div>
</div>
<DialogFooter>
<Button size="sm" variant="destructive" onClick={() => remove.mutate()}>
{t('common.delete')}
</Button>
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,96 @@
import type {
BumperTemplateDto,
GroupSummaryDto,
JunctionElementDto,
JunctionElementKind,
} from '@/shared/api/types'
/** Заставка без загруженного звука синтезируется фиксированной длины — та же цифра, что на сервере. */
export const DEFAULT_BUMPER_SECONDS = 8
export const KIND_COLORS: Record<JunctionElementKind, string> = {
Ad: 'bg-amber-500/70',
Promo: 'bg-sky-500/70',
Bumper: 'bg-violet-500/70',
Filler: 'bg-muted-foreground/40',
}
/**
* Звено цепочки: либо одиночная врезка, либо развилка — несколько врезок с одной меткой, из
* которых в эфир идёт одна. Развилка занимает одно место в цепочке, поэтому и рисуется одним.
*/
export type ChainStep = {
key: string
choiceKey: string | null
elements: JunctionElementDto[]
}
/** Группирует врезки в звенья по метке развилки; порядок сохраняется (сервер держит их подряд). */
export function toSteps(elements: JunctionElementDto[]): ChainStep[] {
const steps: ChainStep[] = []
for (const element of [...elements].sort((a, b) => a.position - b.position)) {
const last = steps.at(-1)
if (element.choiceKey && last?.choiceKey === element.choiceKey) {
last.elements.push(element)
continue
}
steps.push({
key: element.choiceKey ?? element.id,
choiceKey: element.choiceKey,
elements: [element],
})
}
return steps
}
/**
* Прикидка длины врезки для линейки. По длительности — точная, по количеству — среднее единицы
* группы: в смешанной группе «одна единица» это то ли ролик, то ли блок, поэтому число заведомо
* приблизительное и помечается как оценка.
*/
export function estimateSeconds(
element: JunctionElementDto,
groups: GroupSummaryDto[] | undefined,
bumpers: BumperTemplateDto[] | undefined,
): { seconds: number; exact: boolean } {
if (element.kind === 'Bumper') {
const template = bumpers?.find((b) => b.id === element.bumperTemplateId)
return { seconds: template?.audioDurationSeconds ?? DEFAULT_BUMPER_SECONDS, exact: true }
}
if (element.amountMode === 'Duration') return { seconds: element.amountValue * 60, exact: true }
const group = groups?.find((g) => g.id === element.groupId)
if (!group || group.unitCount === 0) return { seconds: 0, exact: false }
return {
seconds: (element.amountValue * group.totalDurationSeconds) / group.unitCount,
exact: false,
}
}
/** Длина звена: у развилки — средняя по весам, потому что в эфир пойдёт одна из врезок. */
export function stepSeconds(
step: ChainStep,
groups: GroupSummaryDto[] | undefined,
bumpers: BumperTemplateDto[] | undefined,
): { seconds: number; exact: boolean } {
const parts = step.elements.map((e) => ({
...estimateSeconds(e, groups, bumpers),
weight: Math.max(0, e.choiceWeight),
}))
if (parts.length === 1) return { seconds: parts[0].seconds, exact: parts[0].exact }
const total = parts.reduce((sum, p) => sum + p.weight, 0)
const seconds =
total > 0
? parts.reduce((sum, p) => sum + (p.seconds * p.weight) / total, 0)
: parts.reduce((sum, p) => sum + p.seconds, 0) / parts.length
// У развилки точной длины нет по определению: жребий решает в момент генерации.
return { seconds, exact: false }
}
/** Доля врезки в развилке в процентах — иначе «иногда так, иногда эдак» невозможно прочитать. */
export function choicePercent(step: ChainStep, element: JunctionElementDto) {
const total = step.elements.reduce((sum, e) => sum + Math.max(0, e.choiceWeight), 0)
if (total <= 0) return Math.round(100 / step.elements.length)
return Math.round((Math.max(0, element.choiceWeight) / total) * 100)
}