Implement BumperEndpoints and remove deprecated bumper-related functionality
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+10
-12
@@ -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>
|
||||
+82
-75
@@ -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,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)
|
||||
}
|
||||
@@ -16,12 +16,14 @@ import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as RegisterRouteImport } from './routes/register'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as AdminIndexRouteImport } from './routes/admin/index'
|
||||
import { Route as AdminBumpersRouteImport } from './routes/admin/bumpers'
|
||||
import { Route as AdminChannelsRouteImport } from './routes/admin/channels'
|
||||
import { Route as AdminCollectionsRouteImport } from './routes/admin/collections'
|
||||
import { Route as AdminGalleryRouteImport } from './routes/admin/gallery'
|
||||
import { Route as AdminGenresRouteImport } from './routes/admin/genres'
|
||||
import { Route as AdminGroupsRouteImport } from './routes/admin/groups'
|
||||
import { Route as AdminInterstitialsRouteImport } from './routes/admin/interstitials'
|
||||
import { Route as AdminJunctionsRouteImport } from './routes/admin/junctions'
|
||||
import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance'
|
||||
import { Route as AdminMediaRouteImport } from './routes/admin/media'
|
||||
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
|
||||
@@ -72,6 +74,11 @@ const AdminIndexRoute = AdminIndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminBumpersRoute = AdminBumpersRouteImport.update({
|
||||
id: '/bumpers',
|
||||
path: '/bumpers',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminChannelsRoute = AdminChannelsRouteImport.update({
|
||||
id: '/channels',
|
||||
path: '/channels',
|
||||
@@ -102,6 +109,11 @@ const AdminInterstitialsRoute = AdminInterstitialsRouteImport.update({
|
||||
path: '/interstitials',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminJunctionsRoute = AdminJunctionsRouteImport.update({
|
||||
id: '/junctions',
|
||||
path: '/junctions',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminMaintenanceRoute = AdminMaintenanceRouteImport.update({
|
||||
id: '/maintenance',
|
||||
path: '/maintenance',
|
||||
@@ -181,12 +193,14 @@ export interface FileRoutesByFullPath {
|
||||
'/login': typeof LoginRoute
|
||||
'/register': typeof RegisterRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/bumpers': typeof AdminBumpersRoute
|
||||
'/admin/channels': typeof AdminChannelsRouteWithChildren
|
||||
'/admin/collections': typeof AdminCollectionsRouteWithChildren
|
||||
'/admin/gallery': typeof AdminGalleryRoute
|
||||
'/admin/genres': typeof AdminGenresRoute
|
||||
'/admin/groups': typeof AdminGroupsRouteWithChildren
|
||||
'/admin/interstitials': typeof AdminInterstitialsRoute
|
||||
'/admin/junctions': typeof AdminJunctionsRoute
|
||||
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||
'/admin/media': typeof AdminMediaRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
@@ -209,9 +223,11 @@ export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
'/register': typeof RegisterRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/bumpers': typeof AdminBumpersRoute
|
||||
'/admin/gallery': typeof AdminGalleryRoute
|
||||
'/admin/genres': typeof AdminGenresRoute
|
||||
'/admin/interstitials': typeof AdminInterstitialsRoute
|
||||
'/admin/junctions': typeof AdminJunctionsRoute
|
||||
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||
'/admin/media': typeof AdminMediaRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
@@ -235,12 +251,14 @@ export interface FileRoutesById {
|
||||
'/login': typeof LoginRoute
|
||||
'/register': typeof RegisterRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/bumpers': typeof AdminBumpersRoute
|
||||
'/admin/channels': typeof AdminChannelsRouteWithChildren
|
||||
'/admin/collections': typeof AdminCollectionsRouteWithChildren
|
||||
'/admin/gallery': typeof AdminGalleryRoute
|
||||
'/admin/genres': typeof AdminGenresRoute
|
||||
'/admin/groups': typeof AdminGroupsRouteWithChildren
|
||||
'/admin/interstitials': typeof AdminInterstitialsRoute
|
||||
'/admin/junctions': typeof AdminJunctionsRoute
|
||||
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||
'/admin/media': typeof AdminMediaRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
@@ -266,12 +284,14 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/admin/bumpers'
|
||||
| '/admin/channels'
|
||||
| '/admin/collections'
|
||||
| '/admin/gallery'
|
||||
| '/admin/genres'
|
||||
| '/admin/groups'
|
||||
| '/admin/interstitials'
|
||||
| '/admin/junctions'
|
||||
| '/admin/maintenance'
|
||||
| '/admin/media'
|
||||
| '/admin/roles'
|
||||
@@ -294,9 +314,11 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/admin/bumpers'
|
||||
| '/admin/gallery'
|
||||
| '/admin/genres'
|
||||
| '/admin/interstitials'
|
||||
| '/admin/junctions'
|
||||
| '/admin/maintenance'
|
||||
| '/admin/media'
|
||||
| '/admin/roles'
|
||||
@@ -319,12 +341,14 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/admin/bumpers'
|
||||
| '/admin/channels'
|
||||
| '/admin/collections'
|
||||
| '/admin/gallery'
|
||||
| '/admin/genres'
|
||||
| '/admin/groups'
|
||||
| '/admin/interstitials'
|
||||
| '/admin/junctions'
|
||||
| '/admin/maintenance'
|
||||
| '/admin/media'
|
||||
| '/admin/roles'
|
||||
@@ -402,6 +426,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AdminIndexRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/bumpers': {
|
||||
id: '/admin/bumpers'
|
||||
path: '/bumpers'
|
||||
fullPath: '/admin/bumpers'
|
||||
preLoaderRoute: typeof AdminBumpersRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/channels': {
|
||||
id: '/admin/channels'
|
||||
path: '/channels'
|
||||
@@ -444,6 +475,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AdminInterstitialsRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/junctions': {
|
||||
id: '/admin/junctions'
|
||||
path: '/junctions'
|
||||
fullPath: '/admin/junctions'
|
||||
preLoaderRoute: typeof AdminJunctionsRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/maintenance': {
|
||||
id: '/admin/maintenance'
|
||||
path: '/maintenance'
|
||||
@@ -601,12 +639,14 @@ const AdminShowsRouteWithChildren = AdminShowsRoute._addFileChildren(
|
||||
)
|
||||
|
||||
interface AdminRouteChildren {
|
||||
AdminBumpersRoute: typeof AdminBumpersRoute
|
||||
AdminChannelsRoute: typeof AdminChannelsRouteWithChildren
|
||||
AdminCollectionsRoute: typeof AdminCollectionsRouteWithChildren
|
||||
AdminGalleryRoute: typeof AdminGalleryRoute
|
||||
AdminGenresRoute: typeof AdminGenresRoute
|
||||
AdminGroupsRoute: typeof AdminGroupsRouteWithChildren
|
||||
AdminInterstitialsRoute: typeof AdminInterstitialsRoute
|
||||
AdminJunctionsRoute: typeof AdminJunctionsRoute
|
||||
AdminMaintenanceRoute: typeof AdminMaintenanceRoute
|
||||
AdminMediaRoute: typeof AdminMediaRoute
|
||||
AdminRolesRoute: typeof AdminRolesRoute
|
||||
@@ -617,12 +657,14 @@ interface AdminRouteChildren {
|
||||
}
|
||||
|
||||
const AdminRouteChildren: AdminRouteChildren = {
|
||||
AdminBumpersRoute: AdminBumpersRoute,
|
||||
AdminChannelsRoute: AdminChannelsRouteWithChildren,
|
||||
AdminCollectionsRoute: AdminCollectionsRouteWithChildren,
|
||||
AdminGalleryRoute: AdminGalleryRoute,
|
||||
AdminGenresRoute: AdminGenresRoute,
|
||||
AdminGroupsRoute: AdminGroupsRouteWithChildren,
|
||||
AdminInterstitialsRoute: AdminInterstitialsRoute,
|
||||
AdminJunctionsRoute: AdminJunctionsRoute,
|
||||
AdminMaintenanceRoute: AdminMaintenanceRoute,
|
||||
AdminMediaRoute: AdminMediaRoute,
|
||||
AdminRolesRoute: AdminRolesRoute,
|
||||
|
||||
@@ -64,6 +64,20 @@ function AdminLayout() {
|
||||
>
|
||||
{t('admin.channels.title')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/junctions"
|
||||
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
|
||||
>
|
||||
{t('admin.junctions.title')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/bumpers"
|
||||
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
|
||||
>
|
||||
{t('admin.bumpers.title')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/gallery"
|
||||
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { BumpersPanel } from '@/features/admin/bumpers/BumpersPanel'
|
||||
|
||||
export const Route = createFileRoute('/admin/bumpers')({ component: BumpersPanel })
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { JunctionsPanel } from '@/features/admin/junctions/JunctionsPanel'
|
||||
|
||||
export const Route = createFileRoute('/admin/junctions')({ component: JunctionsPanel })
|
||||
@@ -20,7 +20,6 @@ export const qk = {
|
||||
detail: (id: string) => ['admin', 'channels', id] as const,
|
||||
template: (id: string) => ['admin', 'channels', id, 'template'] as const,
|
||||
schedule: (id: string) => ['admin', 'channels', id, 'schedule'] as const,
|
||||
junctions: (id: string) => ['admin', 'channels', id, 'junctions'] as const,
|
||||
issues: (id: string) => ['admin', 'channels', id, 'issues'] as const,
|
||||
diff: (id: string) => ['admin', 'channels', id, 'diff'] as const,
|
||||
preview: (id: string, days: number) => ['admin', 'channels', id, 'preview', days] as const,
|
||||
@@ -29,6 +28,14 @@ export const qk = {
|
||||
['admin', 'channels', id, 'grid-plan', profile, mode] as const,
|
||||
},
|
||||
|
||||
junctions: {
|
||||
all: ['admin', 'junctions'] as const,
|
||||
},
|
||||
|
||||
bumpers: {
|
||||
all: ['admin', 'bumpers'] as const,
|
||||
},
|
||||
|
||||
gridProfiles: {
|
||||
all: ['admin', 'grid-profiles'] as const,
|
||||
},
|
||||
|
||||
@@ -430,37 +430,36 @@ export type ShowDto = {
|
||||
// ── Каналы ────────────────────────────────────────────────────────────────
|
||||
type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper' | 'Fallback' | 'SignOff'
|
||||
export type BumperFont = 'Sans' | 'Serif'
|
||||
export type BumperSelection = 'Random' | 'AlwaysFirst' | 'WeightedRandom'
|
||||
export type BumperTextKind = 'NowNext' | 'Free'
|
||||
export type BumperTrigger = 'OnShowChange' | 'BetweenEpisodes' | 'Both'
|
||||
export type BumperLineStyle = 'Label' | 'Title' | 'Caption'
|
||||
export type BumperLineColor = 'Accent' | 'Text'
|
||||
/** Что за картинка под текстом: фон блока, постер следующего или предыдущего шоу. */
|
||||
export type BumperBackground = 'Template' | 'NextPoster' | 'NowPoster'
|
||||
|
||||
/** Общие для канала настройки заставок (стиль/звук/текст — на блоках и подблоках). */
|
||||
/** Условия показа (как часто, на смене шоу или между сериями) живут на элементе стыка, не здесь. */
|
||||
export type BumperSettings = {
|
||||
font: BumperFont
|
||||
selection: BumperSelection
|
||||
/** Строка заставки: роль, цвет из палитры блока и текст с плейсхолдерами. */
|
||||
export type BumperLineDto = {
|
||||
style: BumperLineStyle
|
||||
color: BumperLineColor
|
||||
text: string
|
||||
}
|
||||
|
||||
/** Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока. */
|
||||
/** Подблок (текст-вариант): свои строки, фон и правило показа поверх оформления блока. */
|
||||
export type BumperTextVariantDto = {
|
||||
id: string
|
||||
position: number
|
||||
name: string
|
||||
kind: BumperTextKind
|
||||
nowLabel: string
|
||||
nextLabel: string
|
||||
line1: string
|
||||
line2: string
|
||||
trigger: BumperTrigger
|
||||
/** Вес при стратегии выбора «случайно взвешенный» (0 — не выбирается). */
|
||||
background: BumperBackground
|
||||
/** Вес при выборе подблока на переходе (0 — не выбирается никогда). */
|
||||
weight: number
|
||||
lines: BumperLineDto[]
|
||||
}
|
||||
|
||||
/** Блок заставки — общий для всех каналов, как группа. */
|
||||
export type BumperTemplateDto = {
|
||||
id: string
|
||||
position: number
|
||||
isDefault: boolean
|
||||
name: string
|
||||
font: BumperFont
|
||||
backgroundColor: string
|
||||
backgroundColor2: string
|
||||
accentColor: string
|
||||
@@ -468,6 +467,8 @@ export type BumperTemplateDto = {
|
||||
backgroundImageId: string | null
|
||||
hasAudio: boolean
|
||||
audioDurationSeconds: number | null
|
||||
/** Во скольких врезках стыков используется блок — блоки общие, и это надо видеть до правки. */
|
||||
usageCount: number
|
||||
variants: BumperTextVariantDto[]
|
||||
}
|
||||
|
||||
@@ -490,9 +491,6 @@ export type ChannelDto = {
|
||||
/** Начало вещательных суток в времени канала («06:00:00»). */
|
||||
dayStartTime: string
|
||||
templateId: string | null
|
||||
bumpersEnabled: boolean
|
||||
bumper: BumperSettings
|
||||
bumperTemplates: BumperTemplateDto[]
|
||||
fillerAssetId: string | null
|
||||
viewer: ViewerSettings
|
||||
}
|
||||
@@ -547,10 +545,16 @@ export type SlotDto = {
|
||||
export type JunctionElementKind = 'Ad' | 'Promo' | 'Bumper' | 'Filler'
|
||||
export type JunctionAmountMode = 'Count' | 'Duration'
|
||||
|
||||
/** Окно суток канала; допускает переход через полночь («с 23:00 до 06:00»). */
|
||||
export type JunctionTimeWindow = { from: string; to: string }
|
||||
|
||||
/** Условия показа врезки — структурные поля, а не выражение-строка. */
|
||||
export type JunctionConditions = {
|
||||
onlyOnElementChange: boolean
|
||||
minMinutesBetween: number
|
||||
dayparts?: Daypart[] | null
|
||||
timeWindow?: JunctionTimeWindow | null
|
||||
chance: number
|
||||
}
|
||||
|
||||
export type JunctionElementDto = {
|
||||
@@ -561,15 +565,25 @@ export type JunctionElementDto = {
|
||||
groupName: string | null
|
||||
bumperTemplateId: string | null
|
||||
bumperTemplateName: string | null
|
||||
/** Конкретный подблок заставки; null — выбирается по триггеру перехода и весам. */
|
||||
bumperVariantId: string | null
|
||||
bumperVariantName: string | null
|
||||
amountMode: JunctionAmountMode
|
||||
amountValue: number
|
||||
isRequired: boolean
|
||||
/** Метка развилки: из врезок с одной меткой играет одна, выбранная по весам. */
|
||||
choiceKey: string | null
|
||||
choiceWeight: number
|
||||
conditions: JunctionConditions | null
|
||||
}
|
||||
|
||||
/** Стык — общий для всех каналов; канал только ссылается на него слотами. */
|
||||
export type JunctionTemplateDto = {
|
||||
id: string
|
||||
name: string
|
||||
maxTotalSeconds: number | null
|
||||
/** Сколько каналов ссылается на стык — он общий, и это надо видеть до правки. */
|
||||
channelUsageCount: number
|
||||
elements: JunctionElementDto[]
|
||||
}
|
||||
|
||||
|
||||
@@ -457,8 +457,6 @@ export const en = {
|
||||
everyDay: 'every day',
|
||||
daypart: 'Daypart',
|
||||
slotKind: 'Slot type',
|
||||
group: 'Group',
|
||||
pickGroup: 'pick a group',
|
||||
strategy: 'Strategy',
|
||||
cooldownDays: 'Cooldown, days',
|
||||
cooldownHint: 'Skip what already aired within this period.',
|
||||
@@ -475,8 +473,6 @@ export const en = {
|
||||
maxDrift: 'Allowance, min',
|
||||
snap: 'Snap',
|
||||
snapOff: 'off',
|
||||
bumperConditionsHint:
|
||||
'How often a bumper is inserted and on which transitions is a junction-element condition, not a channel setting.',
|
||||
resizeSlot: 'Drag the edge to change duration',
|
||||
copyDay: 'Copy day',
|
||||
copyDayFrom: 'Copy {{day}} to:',
|
||||
@@ -497,7 +493,6 @@ export const en = {
|
||||
grid: 'Grid',
|
||||
rules: 'Rules',
|
||||
junctions: 'Junctions',
|
||||
bumpers: 'Bumpers',
|
||||
viewer: 'Viewer',
|
||||
settings: 'Settings',
|
||||
air: 'On air',
|
||||
@@ -603,30 +598,12 @@ export const en = {
|
||||
'What plays between programmes: ads, promos, bumpers. A slot may pick its own junction, otherwise the default one is used.',
|
||||
defaultJunction: 'Default junction',
|
||||
noJunction: 'no junction',
|
||||
newJunctionName: 'New junction',
|
||||
addJunctionElement: '+ break',
|
||||
junctionEmpty: 'empty',
|
||||
junctionFrom: 'end',
|
||||
junctionTo: 'start',
|
||||
junctionElement: 'Break',
|
||||
junctionKind: 'Kind',
|
||||
junctionKinds: { Ad: 'Ad', Promo: 'Promo', Bumper: 'Bumper', Filler: 'Filler' },
|
||||
junctionAmountMode: 'Measured in',
|
||||
junctionAmountModes: { Count: 'Units', Duration: 'Minutes' },
|
||||
junctionCount: 'How many units',
|
||||
junctionMinutes: 'How many minutes',
|
||||
junctionAmountHint:
|
||||
'For a mixed group (clips and ready-made blocks) count in minutes: one "unit" there is either a clip or a whole block.',
|
||||
junctionRequired: 'Required — never dropped when time runs short',
|
||||
junctionOnlyOnChange: 'Only when the show changes',
|
||||
junctionMinInterval: 'No more often than once per, min',
|
||||
junctionMinIntervalHint: '0 — no limit.',
|
||||
junctionBetween: 'Junction inside the slot',
|
||||
junctionAfter: 'Junction after the slot',
|
||||
junctionDefault: 'default',
|
||||
bumperTemplate: 'Bumper block',
|
||||
pickBumperTemplate: 'pick a block',
|
||||
minutesShort: ' min',
|
||||
junctionsUsed: 'Used by this channel',
|
||||
junctionsNoneUsed: 'The channel references no junction — nothing plays between programmes.',
|
||||
openJunctionEditor: 'Junction editor',
|
||||
pendingChanges: 'Rules changed — the air still follows the old ones.',
|
||||
restore: 'Discard changes',
|
||||
restoreConfirm:
|
||||
@@ -677,64 +654,150 @@ export const en = {
|
||||
enabled: 'On air',
|
||||
enabledLabel: 'Channel on air',
|
||||
settings: 'Settings',
|
||||
bumpers: 'TV bumpers',
|
||||
bumpersLabel: 'Transition bumpers',
|
||||
bumpersHint: 'Short “Now / Next” bumper between different shows',
|
||||
bumperSelection: 'Block selection',
|
||||
bumperSelectionRandom: 'Random',
|
||||
bumperSelectionWeighted: 'Weighted random',
|
||||
bumperSelectionAlwaysFirst: 'Always first',
|
||||
bumperFont: 'Font',
|
||||
bumperFontSans: 'Sans',
|
||||
bumperFontSerif: 'Serif',
|
||||
bumperNowLabel: '“Now” label',
|
||||
bumperNextLabel: '“Next” label',
|
||||
bumperBg: 'Background (color 1)',
|
||||
bumperBg2: 'Background (color 2)',
|
||||
bumperAccent: 'Accent',
|
||||
bumperText: 'Text',
|
||||
bumperTemplates: 'Bumper blocks',
|
||||
bumperTemplatesHint:
|
||||
'Each block has its own sound and style. The first block is the default and cannot be removed. Bumper length follows the sound length.',
|
||||
bumperAddTemplate: 'Add block',
|
||||
bumperTemplateName: 'Name',
|
||||
bumperVariants: 'Sub-blocks (text)',
|
||||
bumperVariantsHint:
|
||||
'Different text over the same music and style. Each sub-block has its own show rule.',
|
||||
bumperAddVariant: 'Add text',
|
||||
bumperVariantName: 'Name',
|
||||
bumperTextKind: 'Text mode',
|
||||
bumperKindNowNext: 'Now / Next',
|
||||
bumperKindFree: 'Free text',
|
||||
bumperLine1: 'Line 1',
|
||||
bumperLine2: 'Line 2',
|
||||
bumperTrigger: 'Show on',
|
||||
bumperTriggerOnShowChange: 'Show change',
|
||||
bumperTriggerBetweenEpisodes: 'Between episodes',
|
||||
bumperTriggerBoth: 'Both',
|
||||
bumperVariantWeight: 'Weight',
|
||||
bumperVariantWeightHint:
|
||||
'For the “weighted random” strategy: higher = more often (0 — never picked)',
|
||||
bumperDefault: 'default',
|
||||
bumperSeconds: 's',
|
||||
bumperDefaultDuration: '≈8 s (jingle)',
|
||||
bumperAudio: 'Sound',
|
||||
bumperAudioHint: 'Bumper sound; otherwise a synthesized jingle',
|
||||
bumperPreview: 'Render samples',
|
||||
bumperPreviewRendering: 'Rendering…',
|
||||
bumperPreviewHint:
|
||||
'Samples of all sub-blocks with sound and animation (example show names). Uses saved settings.',
|
||||
bumperBackground: 'Background image',
|
||||
bumperBackgroundHint: 'Background image; otherwise the show poster or a gradient',
|
||||
bumperBackgroundPick: 'Pick from gallery',
|
||||
bumperFileLoaded: 'loaded',
|
||||
bumperFileDefault: 'default',
|
||||
bumperUpload: 'Upload',
|
||||
bumperReset: 'Reset',
|
||||
filler: 'Filler',
|
||||
noFiller: 'No filler',
|
||||
noSchedule: 'Schedule not built yet',
|
||||
},
|
||||
junctions: {
|
||||
title: 'Junctions',
|
||||
hint: 'What plays between programmes: ads, promos, bumpers. A junction is shared across channels — a channel only picks which one a slot uses.',
|
||||
newName: 'New junction',
|
||||
empty0: 'No junctions yet.',
|
||||
empty: 'empty',
|
||||
from: 'end',
|
||||
to: 'start',
|
||||
fork: 'fork',
|
||||
addElement: '+ break',
|
||||
maxTotal: 'Cap, s',
|
||||
usedInChannels: 'in channels: {{count}}',
|
||||
cannotDeleteUsed: 'The junction is used by channels — drop the references first.',
|
||||
splitOut: 'Take out of the fork',
|
||||
mergeIntoFork: 'Merge with the previous one into a fork',
|
||||
element: 'Break',
|
||||
kind: 'Type',
|
||||
kinds: {
|
||||
Ad: 'Ad',
|
||||
Promo: 'Promo',
|
||||
Bumper: 'Bumper',
|
||||
Filler: 'Filler',
|
||||
},
|
||||
group: 'Group',
|
||||
pickGroup: 'pick a group',
|
||||
bumperTemplate: 'Bumper block',
|
||||
pickBumper: 'pick a block',
|
||||
bumperVariant: 'Text variant',
|
||||
bumperVariantAuto: 'by trigger and weights',
|
||||
bumperVariantHint:
|
||||
'Keep "by trigger and weights" so show changes and episode breaks get different texts.',
|
||||
amountMode: 'Measured in',
|
||||
amountModes: { Count: 'Units', Duration: 'Minutes' },
|
||||
count: 'How many units',
|
||||
minutes: 'How many minutes',
|
||||
minutesShort: ' min',
|
||||
amountHint:
|
||||
'For a mixed group (spots and ready-made blocks) count in minutes: one unit there is either a spot or a whole block.',
|
||||
required: 'Required',
|
||||
requiredHint:
|
||||
'Required only decides what gets dropped when time runs short. It does not affect the order — that is the position in the chain.',
|
||||
choiceWeight: 'Weight in the fork',
|
||||
choiceWeightHint: 'The higher, the more often this break plays (0 — never picked).',
|
||||
conditions: 'Conditions',
|
||||
onlyOnChange: 'Only on show change',
|
||||
chance: 'Chance, %',
|
||||
chanceHint:
|
||||
'Chance and interval are per break; the roll comes from the generation seed, so rebuilds do not reshuffle breaks.',
|
||||
minInterval: 'No more often than once per, min',
|
||||
dayparts: 'Dayparts',
|
||||
daypartsHint: 'Nothing selected — the break plays in any daypart.',
|
||||
timeWindow: 'Channel time window',
|
||||
clearWindow: 'Clear',
|
||||
timeWindowHint: 'Empty — any time. The window may cross midnight.',
|
||||
badgeOnChange: 'on change',
|
||||
badgeInterval: 'once per {{minutes}} min',
|
||||
},
|
||||
bumpers: {
|
||||
title: 'Bumpers',
|
||||
hint: 'Bumper blocks are shared across channels: look and sound belong to the block, text to its variants. A bumper reaches air as a junction break.',
|
||||
empty: 'No bumper blocks yet.',
|
||||
newName: 'New block',
|
||||
newNamePlaceholder: 'Block name',
|
||||
newVariantName: 'New text',
|
||||
sampleChannel: 'Preview as channel',
|
||||
sampleChannelNone: 'no channel',
|
||||
sampleChannelHint: 'The block is shared, but sample values come from the selected channel.',
|
||||
name: 'Name',
|
||||
font: 'Font',
|
||||
fontSans: 'Sans',
|
||||
fontSerif: 'Serif',
|
||||
colorBg: 'Background (colour 1)',
|
||||
colorBg2: 'Background (colour 2)',
|
||||
colorAccent: 'Accent',
|
||||
colorText: 'Text',
|
||||
saveStyle: 'Save look',
|
||||
seconds: 's',
|
||||
defaultDuration: '≈8 s (jingle)',
|
||||
variantsCount: 'texts: {{count}}',
|
||||
usedInJunctions: 'in breaks: {{count}}',
|
||||
cannotDeleteUsed: 'The block is used by junction breaks — drop the references first.',
|
||||
audio: 'Sound',
|
||||
audioHint: 'The sound length sets the bumper duration; without it a jingle is synthesised.',
|
||||
background: 'Background',
|
||||
backgroundFieldHint: 'Block background image; otherwise a palette gradient or a show poster.',
|
||||
backgroundPick: 'Pick from gallery',
|
||||
backgrounds: {
|
||||
Template: 'Block background',
|
||||
NextPoster: 'Next show poster',
|
||||
NowPoster: 'Previous show poster',
|
||||
},
|
||||
fileLoaded: 'loaded',
|
||||
fileDefault: 'default',
|
||||
upload: 'Upload',
|
||||
reset: 'Reset',
|
||||
variants: 'Text variants',
|
||||
variantsHint:
|
||||
'Different text over the same music and look. Trigger and background are per variant.',
|
||||
addVariant: 'Add text',
|
||||
variantName: 'Name',
|
||||
trigger: 'Show on',
|
||||
triggers: {
|
||||
OnShowChange: 'Show change',
|
||||
BetweenEpisodes: 'Between episodes',
|
||||
Both: 'Both',
|
||||
},
|
||||
weight: 'Weight',
|
||||
lineStyles: { Label: 'Label', Title: 'Title', Caption: 'Caption' },
|
||||
lineColors: { Accent: 'Accent', Text: 'Text' },
|
||||
addLine: 'Line',
|
||||
presets: 'Presets',
|
||||
preset_nowNext: 'Now / Next',
|
||||
preset_nextAt: 'Next at …',
|
||||
preset_channel: 'Channel ident',
|
||||
presetNow: 'NOW',
|
||||
presetNext: 'NEXT',
|
||||
presetNextAt: 'NEXT AT',
|
||||
framePreview: 'Frame',
|
||||
framePreviewHint: 'Sample substitution: this is how the line looks on air.',
|
||||
unknownPlaceholder: 'Unknown placeholder: {{tokens}}',
|
||||
volatileHint: 'every airing is unique — the render cache stops working',
|
||||
render: 'Render samples',
|
||||
rendering: 'Rendering…',
|
||||
renderHint: 'A real ffmpeg render of every variant with sound — takes a few seconds.',
|
||||
tokens: {
|
||||
channel: 'Channel name',
|
||||
'channel.number': 'Channel number',
|
||||
'now.title': 'Current show',
|
||||
'next.title': 'Next show',
|
||||
'now.episode': 'Current episode',
|
||||
'next.episode': 'Next episode',
|
||||
'next.year': 'Next show year',
|
||||
'next.genre': 'Next show genre',
|
||||
'next.time': 'Next start time',
|
||||
time: 'Bumper airing time',
|
||||
date: 'Date',
|
||||
weekday: 'Weekday',
|
||||
slot: 'Slot title',
|
||||
},
|
||||
},
|
||||
maintenance: {
|
||||
title: 'Maintenance',
|
||||
warning: 'These actions are irreversible — data and files are deleted permanently.',
|
||||
|
||||
@@ -457,8 +457,6 @@ export const ru = {
|
||||
everyDay: 'каждый день',
|
||||
daypart: 'Дейпарт',
|
||||
slotKind: 'Тип слота',
|
||||
group: 'Группа',
|
||||
pickGroup: 'выберите группу',
|
||||
strategy: 'Стратегия',
|
||||
cooldownDays: 'Остывание, дней',
|
||||
cooldownHint: 'Не брать то, что уже выходило за этот срок.',
|
||||
@@ -475,8 +473,6 @@ export const ru = {
|
||||
maxDrift: 'Допуск, мин',
|
||||
snap: 'Округление',
|
||||
snapOff: 'нет',
|
||||
bumperConditionsHint:
|
||||
'Как часто ставить заставку и на каких переходах — условия элемента стыка, а не настройка канала.',
|
||||
resizeSlot: 'Потянуть за край — длительность',
|
||||
copyDay: 'Копировать день',
|
||||
copyDayFrom: 'Копировать {{day}} в:',
|
||||
@@ -497,7 +493,6 @@ export const ru = {
|
||||
grid: 'Сетка',
|
||||
rules: 'Правила',
|
||||
junctions: 'Стыки',
|
||||
bumpers: 'Заставки',
|
||||
viewer: 'Зритель',
|
||||
settings: 'Настройки',
|
||||
air: 'Эфир',
|
||||
@@ -603,35 +598,12 @@ export const ru = {
|
||||
'Что играет между программами: реклама, анонсы, заставки. Слот может взять свой стык, иначе берётся стык по умолчанию.',
|
||||
defaultJunction: 'Стык по умолчанию',
|
||||
noJunction: 'без стыка',
|
||||
newJunctionName: 'Новый стык',
|
||||
addJunctionElement: '+ врезка',
|
||||
junctionEmpty: 'пусто',
|
||||
junctionFrom: 'конец',
|
||||
junctionTo: 'начало',
|
||||
junctionElement: 'Врезка',
|
||||
junctionKind: 'Тип',
|
||||
junctionKinds: {
|
||||
Ad: 'Реклама',
|
||||
Promo: 'Анонс',
|
||||
Bumper: 'Заставка',
|
||||
Filler: 'Заполнитель',
|
||||
},
|
||||
junctionAmountMode: 'Чем меряется',
|
||||
junctionAmountModes: { Count: 'Единиц', Duration: 'Минут' },
|
||||
junctionCount: 'Сколько единиц',
|
||||
junctionMinutes: 'Сколько минут',
|
||||
junctionAmountHint:
|
||||
'В смешанной группе (ролики и готовые блоки) считайте минутами: одна «единица» там — то ли ролик, то ли блок.',
|
||||
junctionRequired: 'Обязательная — не выбрасывать при нехватке времени',
|
||||
junctionOnlyOnChange: 'Только при смене шоу',
|
||||
junctionMinInterval: 'Не чаще, чем раз в, мин',
|
||||
junctionMinIntervalHint: '0 — без ограничения.',
|
||||
junctionBetween: 'Стык внутри слота',
|
||||
junctionAfter: 'Стык после слота',
|
||||
junctionDefault: 'по умолчанию',
|
||||
bumperTemplate: 'Блок заставки',
|
||||
pickBumperTemplate: 'выберите блок',
|
||||
minutesShort: ' мин',
|
||||
junctionsUsed: 'Используются в этом канале',
|
||||
junctionsNoneUsed: 'Канал не ссылается ни на один стык — между программами ничего не играет.',
|
||||
openJunctionEditor: 'Редактор стыков',
|
||||
pendingChanges: 'Правила изменены — эфир идёт по старым.',
|
||||
restore: 'Сбросить изменения',
|
||||
restoreConfirm:
|
||||
@@ -682,64 +654,150 @@ export const ru = {
|
||||
enabled: 'В эфире',
|
||||
enabledLabel: 'Канал в эфире',
|
||||
settings: 'Настройки',
|
||||
bumpers: 'ТВ-заставки',
|
||||
bumpersLabel: 'Заставки на переходах',
|
||||
bumpersHint: 'Короткая заставка «Сейчас / Далее» между разными шоу',
|
||||
bumperSelection: 'Выбор блока',
|
||||
bumperSelectionRandom: 'Случайно',
|
||||
bumperSelectionWeighted: 'Случайно взвешенный',
|
||||
bumperSelectionAlwaysFirst: 'Всегда первый',
|
||||
bumperFont: 'Шрифт',
|
||||
bumperFontSans: 'Гротеск',
|
||||
bumperFontSerif: 'Антиква',
|
||||
bumperNowLabel: 'Подпись «Сейчас»',
|
||||
bumperNextLabel: 'Подпись «Далее»',
|
||||
bumperBg: 'Фон (цвет 1)',
|
||||
bumperBg2: 'Фон (цвет 2)',
|
||||
bumperAccent: 'Акцент',
|
||||
bumperText: 'Текст',
|
||||
bumperTemplates: 'Блоки заставок',
|
||||
bumperTemplatesHint:
|
||||
'Каждый блок — свой звук и оформление. Первый блок дефолтный, его нельзя удалить. Длительность заставки — по длине звука.',
|
||||
bumperAddTemplate: 'Добавить блок',
|
||||
bumperTemplateName: 'Название',
|
||||
bumperVariants: 'Подблоки (текст)',
|
||||
bumperVariantsHint:
|
||||
'Разный текст на одной музыке и оформлении блока. Правило показа — у каждого подблока своё.',
|
||||
bumperAddVariant: 'Добавить текст',
|
||||
bumperVariantName: 'Название',
|
||||
bumperTextKind: 'Режим текста',
|
||||
bumperKindNowNext: 'Сейчас / Далее',
|
||||
bumperKindFree: 'Свободный текст',
|
||||
bumperLine1: 'Строка 1',
|
||||
bumperLine2: 'Строка 2',
|
||||
bumperTrigger: 'Показывать',
|
||||
bumperTriggerOnShowChange: 'При смене шоу',
|
||||
bumperTriggerBetweenEpisodes: 'Между сериями',
|
||||
bumperTriggerBoth: 'Оба',
|
||||
bumperVariantWeight: 'Вес',
|
||||
bumperVariantWeightHint:
|
||||
'Для стратегии «случайно взвешенный»: чем больше — тем чаще (0 — не выбирается)',
|
||||
bumperDefault: 'по умолчанию',
|
||||
bumperSeconds: 'с',
|
||||
bumperDefaultDuration: '≈8 с (джингл)',
|
||||
bumperAudio: 'Звук',
|
||||
bumperAudioHint: 'Звук заставки; иначе — синтезированный джингл',
|
||||
bumperPreview: 'Отрендерить примеры',
|
||||
bumperPreviewRendering: 'Рендерим…',
|
||||
bumperPreviewHint:
|
||||
'Примеры всех подблоков со звуком и анимацией (примерные названия шоу). Использует сохранённые настройки.',
|
||||
bumperBackground: 'Фон-картинка',
|
||||
bumperBackgroundHint: 'Картинка фона; иначе — постер шоу или градиент',
|
||||
bumperBackgroundPick: 'Выбрать из галереи',
|
||||
bumperFileLoaded: 'загружено',
|
||||
bumperFileDefault: 'по умолчанию',
|
||||
bumperUpload: 'Загрузить',
|
||||
bumperReset: 'Сбросить',
|
||||
filler: 'Заглушка',
|
||||
noFiller: 'Без заглушки',
|
||||
noSchedule: 'Расписание ещё не построено',
|
||||
},
|
||||
junctions: {
|
||||
title: 'Стыки',
|
||||
hint: 'Что играет между программами: реклама, анонсы, заставки. Стык общий для всех каналов — канал только выбирает, какой поставить в слот.',
|
||||
newName: 'Новый стык',
|
||||
empty0: 'Стыков пока нет.',
|
||||
empty: 'пусто',
|
||||
from: 'конец',
|
||||
to: 'начало',
|
||||
fork: 'развилка',
|
||||
addElement: '+ врезка',
|
||||
maxTotal: 'Потолок, с',
|
||||
usedInChannels: 'в каналах: {{count}}',
|
||||
cannotDeleteUsed: 'Стык используется каналами — сначала снимите ссылки.',
|
||||
splitOut: 'Вынести из развилки',
|
||||
mergeIntoFork: 'Объединить с предыдущей в развилку',
|
||||
element: 'Врезка',
|
||||
kind: 'Тип',
|
||||
kinds: {
|
||||
Ad: 'Реклама',
|
||||
Promo: 'Анонс',
|
||||
Bumper: 'Заставка',
|
||||
Filler: 'Заполнитель',
|
||||
},
|
||||
group: 'Группа',
|
||||
pickGroup: 'выберите группу',
|
||||
bumperTemplate: 'Блок заставки',
|
||||
pickBumper: 'выберите блок',
|
||||
bumperVariant: 'Подблок',
|
||||
bumperVariantAuto: 'по триггеру и весам',
|
||||
bumperVariantHint:
|
||||
'Оставьте «по триггеру и весам», чтобы на смене шоу и между сериями играли разные тексты.',
|
||||
amountMode: 'Чем меряется',
|
||||
amountModes: { Count: 'Единиц', Duration: 'Минут' },
|
||||
count: 'Сколько единиц',
|
||||
minutes: 'Сколько минут',
|
||||
minutesShort: ' мин',
|
||||
amountHint:
|
||||
'В смешанной группе (ролики и готовые блоки) считайте минутами: одна «единица» там — то ли ролик, то ли блок.',
|
||||
required: 'Обязательная',
|
||||
requiredHint:
|
||||
'Обязательность решает, кого выбросить при нехватке времени. На порядок показа она не влияет — он задаётся местом в цепочке.',
|
||||
choiceWeight: 'Вес в развилке',
|
||||
choiceWeightHint: 'Чем больше — тем чаще играет именно эта врезка (0 — не выбирается).',
|
||||
conditions: 'Условия показа',
|
||||
onlyOnChange: 'Только при смене шоу',
|
||||
chance: 'Вероятность, %',
|
||||
chanceHint:
|
||||
'Вероятность и интервал считаются по этой врезке отдельно; жребий берётся из seed генерации, поэтому пересборка не тасует врезки.',
|
||||
minInterval: 'Не чаще, чем раз в, мин',
|
||||
dayparts: 'Дейпарты',
|
||||
daypartsHint: 'Ничего не выбрано — врезка идёт в любых дейпартах.',
|
||||
timeWindow: 'Окно суток канала',
|
||||
clearWindow: 'Сбросить',
|
||||
timeWindowHint: 'Пусто — в любое время. Окно может переходить через полночь.',
|
||||
badgeOnChange: 'на смене',
|
||||
badgeInterval: 'раз в {{minutes}} мин',
|
||||
},
|
||||
bumpers: {
|
||||
title: 'Заставки',
|
||||
hint: 'Блоки заставок общие для всех каналов: оформление и звук — у блока, текст — у подблоков. В эфир заставка попадает врезкой стыка.',
|
||||
empty: 'Блоков заставок пока нет.',
|
||||
newName: 'Новый блок',
|
||||
newNamePlaceholder: 'Название блока',
|
||||
newVariantName: 'Новый текст',
|
||||
sampleChannel: 'Смотреть глазами канала',
|
||||
sampleChannelNone: 'без канала',
|
||||
sampleChannelHint: 'Блок общий, но образцы подстановки берутся у выбранного канала.',
|
||||
name: 'Название',
|
||||
font: 'Шрифт',
|
||||
fontSans: 'Гротеск',
|
||||
fontSerif: 'Антиква',
|
||||
colorBg: 'Фон (цвет 1)',
|
||||
colorBg2: 'Фон (цвет 2)',
|
||||
colorAccent: 'Акцент',
|
||||
colorText: 'Текст',
|
||||
saveStyle: 'Сохранить оформление',
|
||||
seconds: 'с',
|
||||
defaultDuration: '≈8 с (джингл)',
|
||||
variantsCount: 'текстов: {{count}}',
|
||||
usedInJunctions: 'во врезках: {{count}}',
|
||||
cannotDeleteUsed: 'Блок используется во врезках стыков — сначала снимите ссылки.',
|
||||
audio: 'Звук',
|
||||
audioHint: 'Длина звука задаёт длительность заставки; без него — синтезированный джингл.',
|
||||
background: 'Фон',
|
||||
backgroundFieldHint: 'Картинка фона блока; иначе — градиент палитры или постер шоу.',
|
||||
backgroundPick: 'Выбрать из галереи',
|
||||
backgrounds: {
|
||||
Template: 'Фон блока',
|
||||
NextPoster: 'Постер следующего шоу',
|
||||
NowPoster: 'Постер предыдущего шоу',
|
||||
},
|
||||
fileLoaded: 'загружено',
|
||||
fileDefault: 'по умолчанию',
|
||||
upload: 'Загрузить',
|
||||
reset: 'Сбросить',
|
||||
variants: 'Подблоки (тексты)',
|
||||
variantsHint:
|
||||
'Разный текст на одной музыке и оформлении. Правило показа и фон — у каждого подблока свои.',
|
||||
addVariant: 'Добавить текст',
|
||||
variantName: 'Название',
|
||||
trigger: 'Показывать',
|
||||
triggers: {
|
||||
OnShowChange: 'При смене шоу',
|
||||
BetweenEpisodes: 'Между сериями',
|
||||
Both: 'Оба',
|
||||
},
|
||||
weight: 'Вес',
|
||||
lineStyles: { Label: 'Подпись', Title: 'Название', Caption: 'Мелкая' },
|
||||
lineColors: { Accent: 'Акцент', Text: 'Текст' },
|
||||
addLine: 'Строка',
|
||||
presets: 'Пресеты',
|
||||
preset_nowNext: 'Сейчас / Далее',
|
||||
preset_nextAt: 'Далее в …',
|
||||
preset_channel: 'Логотип канала',
|
||||
presetNow: 'СЕЙЧАС',
|
||||
presetNext: 'ДАЛЕЕ',
|
||||
presetNextAt: 'ДАЛЕЕ В',
|
||||
framePreview: 'Кадр',
|
||||
framePreviewHint: 'Подстановка образцами: так строка будет выглядеть в эфире.',
|
||||
unknownPlaceholder: 'Неизвестный плейсхолдер: {{tokens}}',
|
||||
volatileHint: 'каждый показ уникален — кэш рендера не работает',
|
||||
render: 'Отрендерить примеры',
|
||||
rendering: 'Рендерим…',
|
||||
renderHint: 'Настоящий ffmpeg-рендер всех подблоков со звуком — несколько секунд.',
|
||||
tokens: {
|
||||
channel: 'Название канала',
|
||||
'channel.number': 'Номер канала',
|
||||
'now.title': 'Текущее шоу',
|
||||
'next.title': 'Следующее шоу',
|
||||
'now.episode': 'Серия текущего',
|
||||
'next.episode': 'Серия следующего',
|
||||
'next.year': 'Год следующего',
|
||||
'next.genre': 'Жанр следующего',
|
||||
'next.time': 'Время старта следующего',
|
||||
time: 'Время показа заставки',
|
||||
date: 'Дата',
|
||||
weekday: 'День недели',
|
||||
slot: 'Название слота',
|
||||
},
|
||||
},
|
||||
maintenance: {
|
||||
title: 'Обслуживание',
|
||||
warning: 'Операции необратимы — удаляют данные и файлы навсегда.',
|
||||
|
||||
Reference in New Issue
Block a user