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

Added new BumperEndpoints to the API for managing bumper templates and variants, enhancing the channel management capabilities. Removed outdated bumper-related commands and handlers from the application, streamlining the codebase and improving maintainability. Updated ChannelEndpoints to reflect these changes and ensure proper routing for the new endpoints.
This commit is contained in:
Leonid Pershin
2026-07-27 22:01:56 +03:00
parent ee0b4d2d01
commit ba3721eb92
140 changed files with 7326 additions and 3609 deletions
@@ -19,7 +19,6 @@ import {
restoreChannelTemplate,
} from './api'
import { ApplyDialog } from './components/ApplyDialog'
import { BumperCard } from './components/BumperCard'
import { EntryTraceDialog } from './components/EntryTraceDialog'
import { GridTab } from './components/GridTab'
import { JunctionsCard } from './components/JunctionsCard'
@@ -29,7 +28,7 @@ import { SettingsCard } from './components/SettingsCard'
import { ViewerCard } from './components/ViewerCard'
/** Вкладки экрана канала: настройки первыми — с них канал и начинается. */
const TABS = ['settings', 'grid', 'rules', 'junctions', 'bumpers', 'viewer', 'air'] as const
const TABS = ['settings', 'grid', 'rules', 'junctions', 'viewer', 'air'] as const
type ChannelTab = (typeof TABS)[number]
export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) {
@@ -180,17 +179,7 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) {
))}
{tab === 'junctions' && (
<JunctionsCard
channel={channel}
template={template}
bare
onChanged={invalidate}
onError={onError}
/>
)}
{tab === 'bumpers' && (
<BumperCard channel={channel} bare onSaved={invalidate} onError={onError} />
<JunctionsCard template={template} bare onChanged={invalidate} onError={onError} />
)}
{tab === 'viewer' && (
+1 -211
View File
@@ -1,9 +1,6 @@
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
import { apiRequest } from '@/shared/api/client'
import type {
ApplyResultDto,
BumperSettings,
BumperTextKind,
BumperTrigger,
ChannelDto,
ChannelSummaryDto,
CopyTemplateResultDto,
@@ -14,10 +11,6 @@ import type {
GridPlanDto,
GridProfileDto,
GridProfileKind,
JunctionAmountMode,
JunctionConditions,
JunctionElementKind,
JunctionTemplateDto,
LayerApplicability,
PlanningRules,
RestoreTemplateResultDto,
@@ -45,8 +38,6 @@ export function createChannel(body: { name: string; slug: string }) {
type ChannelSettingsBody = {
name: string
isEnabled: boolean
bumpersEnabled: boolean
bumper: BumperSettings
fillerAssetId: string | null
}
@@ -209,207 +200,6 @@ export function deleteSlot(slotId: string) {
// ── Стыки канала ──────────────────────────────────────────────────────────
export function listJunctions(channelId: string) {
return apiRequest<JunctionTemplateDto[]>(`/admin/channels/${channelId}/junctions`)
}
export function createJunction(channelId: string, name: string) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${channelId}/junctions`, {
method: 'POST',
body: { name },
})
}
export function renameJunction(junctionId: string, name: string) {
return apiRequest<void>(`/admin/junctions/${junctionId}`, { method: 'PUT', body: { name } })
}
export function deleteJunction(junctionId: string) {
return apiRequest<void>(`/admin/junctions/${junctionId}`, { method: 'DELETE' })
}
export function addJunctionElement(junctionId: string, kind: JunctionElementKind) {
return apiRequest<CreatedIdResponse>(`/admin/junctions/${junctionId}/elements`, {
method: 'POST',
body: { kind },
})
}
/** Тело врезки: то же для любого типа — лишние поля сервер обнуляет сам (см. JunctionElement.Update). */
export type JunctionElementBody = {
kind: JunctionElementKind
groupId: string | null
bumperTemplateId: string | null
amountMode: JunctionAmountMode
amountValue: number
isRequired: boolean
conditions: JunctionConditions | null
}
export function updateJunctionElement(
junctionId: string,
elementId: string,
body: JunctionElementBody,
) {
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
method: 'PUT',
body,
})
}
export function removeJunctionElement(junctionId: string, elementId: string) {
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
method: 'DELETE',
})
}
/** Порядок врезок: не упомянутые остаются после перечисленных. */
export function reorderJunction(junctionId: string, elementIdsInOrder: string[]) {
return apiRequest<void>(`/admin/junctions/${junctionId}/order`, {
method: 'PUT',
body: { elementIdsInOrder },
})
}
type BumperTemplateStyleBody = {
name: string
backgroundColor: string
backgroundColor2: string
accentColor: string
textColor: string
}
export function addBumperTemplate(id: string, name: string) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/bumper/templates`, {
method: 'POST',
body: { name },
})
}
export function updateBumperTemplate(
id: string,
templateId: string,
body: BumperTemplateStyleBody,
) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
method: 'PUT',
body,
})
}
export function removeBumperTemplate(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
method: 'DELETE',
})
}
/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */
function uploadBumperTemplateFile(
id: string,
templateId: string,
kind: 'audio' | 'background',
file: File,
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const query = new URLSearchParams({ fileName: file.name })
xhr.open(
'PUT',
`/api/admin/channels/${id}/bumper/templates/${templateId}/${kind}?${query.toString()}`,
)
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
let detail = `HTTP ${xhr.status}`
try {
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
detail = problem.detail ?? problem.title ?? detail
} catch {
/* пусто */
}
reject(new HttpError({ detail }, xhr.status))
}
}
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
xhr.send(file)
})
}
export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) {
return uploadBumperTemplateFile(id, templateId, 'audio', file)
}
type BumperVariantBody = {
name: string
kind: BumperTextKind
nowLabel: string
nextLabel: string
line1: string
line2: string
trigger: BumperTrigger
weight: number
}
export function addBumperVariant(id: string, templateId: string, name: string) {
return apiRequest<CreatedIdResponse>(
`/admin/channels/${id}/bumper/templates/${templateId}/variants`,
{ method: 'POST', body: { name } },
)
}
export function updateBumperVariant(
id: string,
templateId: string,
variantId: string,
body: BumperVariantBody,
) {
return apiRequest<void>(
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
{ method: 'PUT', body },
)
}
export function removeBumperVariant(id: string, templateId: string, variantId: string) {
return apiRequest<void>(
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
{ method: 'DELETE' },
)
}
/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */
export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
method: 'PUT',
body: { imageId },
})
}
export function clearBumperTemplateAudio(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, {
method: 'DELETE',
})
}
export function clearBumperTemplateBackground(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
method: 'DELETE',
})
}
/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */
export function renderBumperPreviews(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
method: 'POST',
})
}
export function bumperPreviewPlaylistUrl(id: string, templateId: string, variantId: string) {
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/${variantId}/index.m3u8`
}
export function getSchedule(id: string, from: Date, to: Date) {
const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() })
return apiRequest<ScheduleEntryDto[]>(`/admin/channels/${id}/schedule?${query.toString()}`)
@@ -1,80 +0,0 @@
import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { imageUrl } from '@/features/admin/images/api'
import { ImageGallery } from '@/features/admin/images/ImageGallery'
import { Button } from '@/shared/ui/button'
import { Label } from '@/shared/ui/label'
import { clearBumperTemplateBackground, setBumperTemplateBackground } from '../api'
export function BumperBackgroundField({
channelId,
templateId,
backgroundImageId,
onChanged,
onError,
}: Readonly<{
channelId: string
templateId: string
backgroundImageId: string | null
onChanged: () => void
onError: (e: unknown) => void
}>) {
const { t } = useTranslation()
const [galleryOpen, setGalleryOpen] = useState(false)
const setBg = useMutation({
mutationFn: (imageId: string) => setBumperTemplateBackground(channelId, templateId, imageId),
onSuccess: onChanged,
onError,
})
const clearBg = useMutation({
mutationFn: () => clearBumperTemplateBackground(channelId, templateId),
onSuccess: onChanged,
onError,
})
return (
<div className="flex flex-col gap-1.5">
<Label>
{t('admin.channels.bumperBackground')}{' '}
<span className={backgroundImageId ? 'text-emerald-500' : 'text-muted-foreground'}>
{backgroundImageId
? `· ${t('admin.channels.bumperFileLoaded')}`
: `· ${t('admin.channels.bumperFileDefault')}`}
</span>
</Label>
<span className="text-xs text-muted-foreground">
{t('admin.channels.bumperBackgroundHint')}
</span>
<div className="flex items-center gap-2">
{backgroundImageId && (
<img
src={imageUrl(backgroundImageId)}
alt=""
className="h-10 w-16 shrink-0 rounded border border-border object-cover"
/>
)}
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
{t('admin.channels.bumperBackgroundPick')}
</Button>
{backgroundImageId && (
<Button
size="sm"
variant="ghost"
disabled={clearBg.isPending}
onClick={() => clearBg.mutate()}
>
{t('admin.channels.bumperReset')}
</Button>
)}
</div>
<ImageGallery
open={galleryOpen}
onOpenChange={setGalleryOpen}
category="BumperBackground"
onSelect={(img) => setBg.mutate(img.id)}
/>
</div>
)
}
@@ -1,153 +0,0 @@
import { useMutation } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { addBumperTemplate, updateChannelSettings } from '../api'
import { BumperTemplateEditor } from './BumperTemplateEditor'
import { CollapsibleCard } from './CollapsibleCard'
export function BumperCard({
channel,
bare,
onSaved,
onError,
}: Readonly<{
channel: ChannelDto
bare?: boolean
onSaved: () => void
onError: (e: unknown) => void
}>) {
const { t } = useTranslation()
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
const setField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
setBumper((prev) => ({ ...prev, [key]: value }))
useEffect(() => {
setBumpersEnabled(channel.bumpersEnabled)
setBumper(channel.bumper)
}, [channel])
// Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные
// поля берём из канала без изменений (они правятся в своей карточке).
const save = useMutation({
mutationFn: () =>
updateChannelSettings(channel.id, {
name: channel.name,
isEnabled: channel.isEnabled,
bumpersEnabled,
bumper,
fillerAssetId: channel.fillerAssetId,
}),
onSuccess: () => {
toast.success(t('settings.saved'))
onSaved()
},
onError,
})
const addTemplate = useMutation({
mutationFn: () => addBumperTemplate(channel.id, ''),
onSuccess: onSaved,
onError,
})
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
return (
<CollapsibleCard
title={t('admin.channels.bumpers')}
bare={bare}
contentClassName="flex flex-col gap-4"
>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
className="mt-1"
checked={bumpersEnabled}
onChange={(e) => setBumpersEnabled(e.target.checked)}
/>
<span>
{t('admin.channels.bumpersLabel')}
<span className="block text-xs text-muted-foreground">
{t('admin.channels.bumpersHint')}
</span>
</span>
</label>
{/* Общие настройки. Как часто ставить заставку — не здесь: это условие элемента стыка. */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperSelection')}</Label>
<Select
value={bumper.selection}
onValueChange={(v) => setField('selection', v as BumperSelection)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
<SelectItem value="WeightedRandom">
{t('admin.channels.bumperSelectionWeighted')}
</SelectItem>
<SelectItem value="AlwaysFirst">
{t('admin.channels.bumperSelectionAlwaysFirst')}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperFont')}</Label>
<Select value={bumper.font} onValueChange={(v) => setField('font', v as BumperFont)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Sans">{t('admin.channels.bumperFontSans')}</SelectItem>
<SelectItem value="Serif">{t('admin.channels.bumperFontSerif')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperConditionsHint')}</p>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
{/* Блоки заставок */}
<div className="border-t border-border pt-4">
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
</div>
<div className="flex flex-col gap-3">
{templates.map((template) => (
<BumperTemplateEditor
key={template.id}
channelId={channel.id}
template={template}
onChanged={onSaved}
onError={onError}
/>
))}
</div>
<div className="flex justify-center">
<Button
size="sm"
variant="outline"
disabled={addTemplate.isPending}
onClick={() => addTemplate.mutate()}
>
{t('admin.channels.bumperAddTemplate')}
</Button>
</div>
</CollapsibleCard>
)
}
@@ -1,85 +0,0 @@
import { useMutation } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { Label } from '@/shared/ui/label'
export function BumperFileUpload({
channelId,
templateId,
kind,
label,
hint,
has,
accept,
upload,
clear,
onSaved,
onError,
}: Readonly<{
channelId: string
templateId: string
kind: string
label: string
hint: string
has: boolean
accept: string
upload: (id: string, templateId: string, file: File) => Promise<void>
clear: (id: string, templateId: string) => Promise<void>
onSaved: () => void
onError: (e: unknown) => void
}>) {
const { t } = useTranslation()
const inputId = `bumper-${kind}-${templateId}`
const uploadMutation = useMutation({
mutationFn: (file: File) => upload(channelId, templateId, file),
onSuccess: onSaved,
onError,
})
const clearMutation = useMutation({
mutationFn: () => clear(channelId, templateId),
onSuccess: onSaved,
onError,
})
const busy = uploadMutation.isPending || clearMutation.isPending
return (
<div className="flex flex-col gap-1.5">
<Label>
{label}{' '}
<span className={has ? 'text-emerald-500' : 'text-muted-foreground'}>
{has
? `· ${t('admin.channels.bumperFileLoaded')}`
: `· ${t('admin.channels.bumperFileDefault')}`}
</span>
</Label>
<span className="text-xs text-muted-foreground">{hint}</span>
<div className="flex items-center gap-2">
<input
id={inputId}
type="file"
accept={accept}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) uploadMutation.mutate(file)
e.target.value = ''
}}
/>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => document.getElementById(inputId)?.click()}
>
{t('admin.channels.bumperUpload')}
</Button>
{has && (
<Button size="sm" variant="ghost" disabled={busy} onClick={() => clearMutation.mutate()}>
{t('admin.channels.bumperReset')}
</Button>
)}
</div>
</div>
)
}
@@ -1,66 +0,0 @@
import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { BumperTextVariantDto } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { HlsVideo } from '@/shared/ui/hls-video'
import { bumperPreviewPlaylistUrl, renderBumperPreviews } from '../api'
export function BumperPreviewPlayer({
channelId,
templateId,
variants,
onError,
}: Readonly<{
channelId: string
templateId: string
variants: BumperTextVariantDto[]
onError: (e: unknown) => void
}>) {
const { t } = useTranslation()
const [ready, setReady] = useState(false)
const [bust, setBust] = useState(0)
const render = useMutation({
mutationFn: () => renderBumperPreviews(channelId, templateId),
onSuccess: () => {
setBust(Date.now())
setReady(true)
},
onError,
})
return (
<>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={render.isPending}
onClick={() => render.mutate()}
>
{render.isPending
? t('admin.channels.bumperPreviewRendering')
: t('admin.channels.bumperPreview')}
</Button>
<span className="text-xs text-muted-foreground">
{t('admin.channels.bumperPreviewHint')}
</span>
</div>
{ready && (
<div className="grid gap-3 sm:grid-cols-2">
{[...variants]
.sort((a, b) => a.position - b.position)
.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}`}
/>
</div>
))}
</div>
)}
</>
)
}
@@ -1,213 +0,0 @@
import { useMutation } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronDown } from 'lucide-react'
import type { 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 { 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 { BumperPreviewPlayer } from './BumperPreviewPlayer'
import { BumperVariantEditor } from './BumperVariantEditor'
export function BumperTemplateEditor({
channelId,
template,
onChanged,
onError,
}: Readonly<{
channelId: string
template: BumperTemplateDto
onChanged: () => void
onError: (e: unknown) => void
}>) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [name, setName] = useState(template.name)
const [colors, setColors] = useState({
backgroundColor: template.backgroundColor,
backgroundColor2: template.backgroundColor2,
accentColor: template.accentColor,
textColor: template.textColor,
})
useEffect(() => {
setName(template.name)
setColors({
backgroundColor: template.backgroundColor,
backgroundColor2: template.backgroundColor2,
accentColor: template.accentColor,
textColor: template.textColor,
})
}, [template])
const save = useMutation({
mutationFn: () =>
updateBumperTemplate(channelId, template.id, { name: name.trim(), ...colors }),
onSuccess: () => {
toast.success(t('settings.saved'))
onChanged()
},
onError,
})
const remove = useMutation({
mutationFn: () => removeBumperTemplate(channelId, template.id),
onSuccess: onChanged,
onError,
})
const addVariant = useMutation({
mutationFn: () => addBumperVariant(channelId, template.id, ''),
onSuccess: onChanged,
onError,
})
const colorFields: { key: keyof typeof colors; label: string }[] = [
{ key: 'backgroundColor', label: t('admin.channels.bumperBg') },
{ key: 'backgroundColor2', label: t('admin.channels.bumperBg2') },
{ key: 'accentColor', label: t('admin.channels.bumperAccent') },
{ key: 'textColor', label: t('admin.channels.bumperText') },
]
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"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex flex-1 cursor-pointer flex-wrap items-center gap-2 text-left"
>
<ChevronDown
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')}
</span>
</button>
{!template.isDefault && (
<Button
size="sm"
variant="destructive"
disabled={remove.isPending}
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)} />
</div>
{colorFields.map(({ key, label }) => (
<div key={key} className="flex flex-col gap-1.5">
<Label>{label}</Label>
<div className="flex items-center gap-2">
<span
className="h-8 w-8 shrink-0 rounded border border-border"
style={{ backgroundColor: cssColor(colors[key]) }}
/>
<Input
value={colors[key]}
onChange={(e) => setColors((c) => ({ ...c, [key]: e.target.value }))}
/>
</div>
</div>
))}
</div>
<div className="grid gap-3 border-t border-border pt-3 sm:grid-cols-2">
<BumperFileUpload
channelId={channelId}
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}
onError={onError}
/>
<BumperBackgroundField
channelId={channelId}
templateId={template.id}
backgroundImageId={template.backgroundImageId}
onChanged={onChanged}
onError={onError}
/>
</div>
{/* Подблоки (текст-варианты) */}
<div className="flex flex-col gap-2 border-t border-border pt-3">
<p className="text-sm font-medium">{t('admin.channels.bumperVariants')}</p>
<p className="text-xs text-muted-foreground">
{t('admin.channels.bumperVariantsHint')}
</p>
{[...template.variants]
.sort((a, b) => a.position - b.position)
.map((variant) => (
<BumperVariantEditor
key={variant.id}
channelId={channelId}
templateId={template.id}
variant={variant}
canRemove={template.variants.length > 1}
onChanged={onChanged}
onError={onError}
/>
))}
<div>
<Button
size="sm"
variant="outline"
disabled={addVariant.isPending}
onClick={() => addVariant.mutate()}
>
{t('admin.channels.bumperAddVariant')}
</Button>
</div>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<BumperPreviewPlayer
channelId={channelId}
templateId={template.id}
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>
)
}
@@ -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()