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)
|
||||
}
|
||||
Reference in New Issue
Block a user