Add interstitial endpoints and enhance media and template functionalities: implement MapInterstitialEndpoints in Program.cs, add preview functionality for media assets in MediaEndpoints, and introduce template preview capabilities in TemplateEndpoints. Remove obsolete bumper settings from Channel and related classes to streamline configuration.
This commit is contained in:
@@ -3,12 +3,10 @@ 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 { 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 { addBumperTemplate, updateChannelSettings } from '../api'
|
||||
import { clampChance } from '../lib/format'
|
||||
import { BumperTemplateEditor } from './BumperTemplateEditor'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
@@ -76,8 +74,8 @@ export function BumperCard({
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Общие настройки */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Общие настройки. Как часто ставить заставку — не здесь: это условие элемента стыка. */}
|
||||
<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)}>
|
||||
@@ -85,7 +83,6 @@ export function BumperCard({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Rotation">{t('admin.channels.bumperSelectionRotation')}</SelectItem>
|
||||
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
|
||||
<SelectItem value="WeightedRandom">
|
||||
{t('admin.channels.bumperSelectionWeighted')}
|
||||
@@ -108,45 +105,8 @@ export function BumperCard({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperMinInterval')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1440}
|
||||
value={bumper.minIntervalMinutes}
|
||||
onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperShowChangeChance')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={bumper.showChangeChance}
|
||||
onChange={(e) => setField('showChangeChance', clampChance(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperShowChangeChanceHint')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperEpisodeChangeChance')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={bumper.episodeChangeChance}
|
||||
onChange={(e) => setField('episodeChangeChance', clampChance(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperEpisodeChangeChanceHint')}
|
||||
</span>
|
||||
</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')}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import Hls from 'hls.js'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getAccessToken } from '@/shared/api/client'
|
||||
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({
|
||||
@@ -48,7 +47,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>
|
||||
<PreviewVideo src={`${bumperPreviewPlaylistUrl(channelId, templateId, v.id)}?t=${bust}`} />
|
||||
<HlsVideo src={`${bumperPreviewPlaylistUrl(channelId, templateId, v.id)}?t=${bust}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -56,36 +55,3 @@ export function BumperPreviewPlayer({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Мини-плеер одного превью: грузит HLS через hls.js с Bearer-токеном (admin-роут под JWT). */
|
||||
function PreviewVideo({ src }: { src: string }) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
let hls: Hls | null = null
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({
|
||||
xhrSetup: (xhr) => {
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
},
|
||||
})
|
||||
hls.loadSource(src)
|
||||
hls.attachMedia(video)
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = src
|
||||
}
|
||||
return () => {
|
||||
hls?.destroy()
|
||||
}
|
||||
}, [src])
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
playsInline
|
||||
className="aspect-video w-full rounded-md border border-border bg-black"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
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 { 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,
|
||||
}: {
|
||||
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: ['admin', 'groups'], 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { ChevronRight, Plus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listGroups } from '@/features/admin/groups/api'
|
||||
import { formatClock } from '@/features/admin/interstitials/format'
|
||||
import type {
|
||||
ChannelDto,
|
||||
GroupSummaryDto,
|
||||
JunctionElementDto,
|
||||
JunctionElementKind,
|
||||
JunctionTemplateDto,
|
||||
ScheduleTemplateDto,
|
||||
} from '@/shared/api/types'
|
||||
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 { 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',
|
||||
}
|
||||
|
||||
/**
|
||||
* Прикидка длины врезки для линейки. По длительности — точная, по количеству — среднее единицы
|
||||
* группы: в смешанной группе «одна единица» это то ли ролик, то ли блок, поэтому число заведомо
|
||||
* приблизительное и помечается как оценка.
|
||||
*/
|
||||
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,
|
||||
onChanged,
|
||||
onError,
|
||||
}: {
|
||||
channel: ChannelDto
|
||||
template: ScheduleTemplateDto | undefined
|
||||
onChanged: () => void
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [newName, setNewName] = useState('')
|
||||
|
||||
const { data: junctions } = useQuery({
|
||||
queryKey: ['admin', 'channels', channel.id, 'junctions'],
|
||||
queryFn: () => listJunctions(channel.id),
|
||||
})
|
||||
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createJunction(channel.id, newName.trim()),
|
||||
onSuccess: () => {
|
||||
setNewName('')
|
||||
onChanged()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const defaultMutation = useMutation({
|
||||
mutationFn: (junctionId: string | null) =>
|
||||
updateTemplate(template!.id, {
|
||||
name: template!.name,
|
||||
fallbackGroupId: template!.fallbackGroupId,
|
||||
defaultJunctionId: junctionId,
|
||||
}),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<CollapsibleCard title={t('admin.channels.junctions')}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.junctionsHint')}</p>
|
||||
|
||||
{template && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.defaultJunction')}</Label>
|
||||
<select
|
||||
className="h-9 max-w-xs rounded-md border border-border bg-transparent px-2 text-sm"
|
||||
value={template.defaultJunctionId ?? ''}
|
||||
onChange={(e) => defaultMutation.mutate(e.target.value || null)}
|
||||
>
|
||||
<option value="">{t('admin.channels.noJunction')}</option>
|
||||
{(junctions ?? []).map((junction) => (
|
||||
<option key={junction.id} value={junction.id}>
|
||||
{junction.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(junctions ?? []).map((junction) => (
|
||||
<JunctionChain
|
||||
key={junction.id}
|
||||
junction={junction}
|
||||
channel={channel}
|
||||
groups={groups}
|
||||
onChanged={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
|
||||
<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')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
|
||||
const ADDABLE: JunctionElementKind[] = ['Ad', 'Promo', 'Bumper', 'Filler']
|
||||
|
||||
function JunctionChain({
|
||||
junction,
|
||||
channel,
|
||||
groups,
|
||||
onChanged,
|
||||
onError,
|
||||
}: {
|
||||
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}`)}
|
||||
{element.kind === 'Bumper'
|
||||
? element.bumperTemplateName
|
||||
? ` · ${element.bumperTemplateName}`
|
||||
: ''
|
||||
: ` ×${element.amountValue}${element.amountMode === 'Duration' ? t('admin.channels.minutesShort') : ''}`}
|
||||
{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={`${t(`admin.channels.junctionKinds.${element.kind}`)} · ${formatClock(estimates[index].seconds)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<JunctionElementDialog
|
||||
junctionId={junction.id}
|
||||
element={editing}
|
||||
bumperTemplates={channel.bumperTemplates}
|
||||
onClose={() => setEditing(null)}
|
||||
onChanged={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ 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 { createSlot, deleteSlot, updateSlot, type SlotBody } from '../api'
|
||||
import { createSlot, deleteSlot, listJunctions, updateSlot, type SlotBody } from '../api'
|
||||
|
||||
const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night']
|
||||
const SLOT_KINDS: SlotKind[] = ['Content', 'Repeat', 'SignOff']
|
||||
@@ -55,15 +55,19 @@ function emptyBody(defaults?: Partial<SlotBody>): SlotBody {
|
||||
isAnchor: false,
|
||||
maxDriftMinutes: 5,
|
||||
snapToMinutes: null,
|
||||
junctionBetweenId: null,
|
||||
junctionAfterId: null,
|
||||
...defaults,
|
||||
}
|
||||
}
|
||||
|
||||
export function SlotInspector({
|
||||
channelId,
|
||||
draft,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
channelId: string
|
||||
draft: SlotDraft
|
||||
onClose: () => void
|
||||
onChanged: () => void
|
||||
@@ -78,6 +82,10 @@ export function SlotInspector({
|
||||
}, [draft])
|
||||
|
||||
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
||||
const { data: junctions } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'junctions'],
|
||||
queryFn: () => listJunctions(channelId),
|
||||
})
|
||||
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
@@ -391,6 +399,40 @@ export function SlotInspector({
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.anchorHint')}</p>
|
||||
|
||||
{/* Стыки: внутри слота — между единицами, после слота — на переходе к следующему. */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.junctionBetween')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.junctionBetweenId ?? ''}
|
||||
onChange={(e) => patch({ junctionBetweenId: e.target.value || null })}
|
||||
>
|
||||
<option value="">{t('admin.channels.junctionDefault')}</option>
|
||||
{(junctions ?? []).map((junction) => (
|
||||
<option key={junction.id} value={junction.id}>
|
||||
{junction.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.junctionAfter')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.junctionAfterId ?? ''}
|
||||
onChange={(e) => patch({ junctionAfterId: e.target.value || null })}
|
||||
>
|
||||
<option value="">{t('admin.channels.junctionDefault')}</option>
|
||||
{(junctions ?? []).map((junction) => (
|
||||
<option key={junction.id} value={junction.id}>
|
||||
{junction.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{draft.slot ? (
|
||||
<Button size="sm" variant="destructive" onClick={() => remove.mutate()}>
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Eye } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { PlannedItemKind, PreviewItemDto, SchedulePreviewDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { previewTemplate } from '../api'
|
||||
import { channelTime, formatChannelTime } from '../lib/format'
|
||||
|
||||
const KIND_COLORS: Record<PlannedItemKind, string> = {
|
||||
Program: 'bg-primary/70',
|
||||
Fallback: 'bg-muted-foreground/40',
|
||||
SignOff: 'bg-slate-500/60',
|
||||
Ad: 'bg-amber-500/70',
|
||||
Promo: 'bg-sky-500/70',
|
||||
Bumper: 'bg-violet-500/70',
|
||||
}
|
||||
|
||||
/** Что видит зритель как программу — врезки в программу передач не попадают. */
|
||||
const PROGRAMME_KINDS: PlannedItemKind[] = ['Program', 'Fallback', 'SignOff']
|
||||
|
||||
/**
|
||||
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
|
||||
* курсоров. Заставки приходят резервом известной длины — реальный рендер только при применении.
|
||||
*/
|
||||
export function TemplatePreview({ channelId }: { channelId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [days, setDays] = useState(1)
|
||||
const [tab, setTab] = useState<'programme' | 'tape'>('programme')
|
||||
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'preview', days],
|
||||
queryFn: () => previewTemplate(channelId, days),
|
||||
enabled: open,
|
||||
// Черновик правил может меняться между открытиями — кэшировать прогон смысла нет.
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen((v) => !v)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
{open ? t('admin.channels.previewHide') : t('admin.channels.preview')}
|
||||
</Button>
|
||||
{open && (
|
||||
<>
|
||||
<select
|
||||
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
>
|
||||
{[1, 3, 7].map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t('admin.channels.previewDays', { count: value })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isFetching ? t('common.loading') : t('admin.channels.previewHint')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && data && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2 border-b border-border text-xs uppercase tracking-wide">
|
||||
{(['programme', 'tape'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTab(value)}
|
||||
className={cn(
|
||||
'pb-1 text-muted-foreground hover:text-foreground',
|
||||
tab === value && 'border-b-2 border-primary text-primary',
|
||||
)}
|
||||
>
|
||||
{t(`admin.channels.previewTabs.${value}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'programme' ? <Programme preview={data} /> : <Tape preview={data} />}
|
||||
|
||||
{data.warnings.length > 0 && (
|
||||
<ul className="flex flex-col gap-1 text-xs text-amber-500">
|
||||
{data.warnings.map((warning, index) => (
|
||||
<li key={`${warning.kind}-${index}`}>
|
||||
{t(`admin.channels.warnings.${warning.kind}`)}: {warning.details}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Programme({ preview }: { preview: SchedulePreviewDto }) {
|
||||
const { t } = useTranslation()
|
||||
const items = preview.items.filter((i) => PROGRAMME_KINDS.includes(i.kind))
|
||||
|
||||
if (items.length === 0)
|
||||
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col divide-y divide-border text-sm">
|
||||
{items.map((item, index) => (
|
||||
<li key={`${item.startsAtUtc}-${index}`} className="flex items-center gap-3 py-1.5">
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{item.title ?? t(`admin.channels.previewKinds.${item.kind}`)}
|
||||
</span>
|
||||
{item.slotTitle && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{item.slotTitle}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
/** Час вещания → сколько в нём минут врезок. По ним же строится гистограмма нагрузки. */
|
||||
function loadByHour(preview: SchedulePreviewDto): { hour: Date; minutes: number }[] {
|
||||
const buckets = new Map<number, number>()
|
||||
for (const item of preview.items) {
|
||||
if (item.kind !== 'Ad' && item.kind !== 'Promo' && item.kind !== 'Bumper') continue
|
||||
const start = channelTime(item.startsAtUtc, preview.utcOffsetMinutes)
|
||||
const hour = Date.UTC(
|
||||
start.getUTCFullYear(),
|
||||
start.getUTCMonth(),
|
||||
start.getUTCDate(),
|
||||
start.getUTCHours(),
|
||||
)
|
||||
const minutes = (new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
|
||||
buckets.set(hour, (buckets.get(hour) ?? 0) + minutes)
|
||||
}
|
||||
return [...buckets.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([hour, minutes]) => ({ hour: new Date(hour), minutes }))
|
||||
}
|
||||
|
||||
function Tape({ preview }: { preview: SchedulePreviewDto }) {
|
||||
const { t } = useTranslation()
|
||||
const load = useMemo(() => loadByHour(preview), [preview])
|
||||
const peak = Math.max(1, ...load.map((l) => l.minutes))
|
||||
|
||||
if (preview.items.length === 0)
|
||||
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{load.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.previewLoad', { peak: Math.round(peak) })}
|
||||
</span>
|
||||
<div className="flex h-16 items-end gap-px">
|
||||
{load.map((bucket) => (
|
||||
<div
|
||||
key={bucket.hour.toISOString()}
|
||||
className="flex-1 bg-amber-500/70"
|
||||
style={{ height: `${(bucket.minutes / peak) * 100}%` }}
|
||||
title={`${String(bucket.hour.getUTCHours()).padStart(2, '0')}:00 · ${Math.round(bucket.minutes)} ${t('admin.groups.minutesShort')}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="flex flex-col gap-0.5 text-xs">
|
||||
{preview.items.map((item, index) => (
|
||||
<TapeRow key={`${item.startsAtUtc}-${index}`} item={item} preview={preview} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TapeRow({ item, preview }: { item: PreviewItemDto; preview: SchedulePreviewDto }) {
|
||||
const { t } = useTranslation()
|
||||
const minutes =
|
||||
(new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-10 shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
|
||||
</span>
|
||||
<span className={cn('h-2 shrink-0 rounded-sm', KIND_COLORS[item.kind])} style={{ width: `${Math.max(4, minutes * 2)}px` }} />
|
||||
<Badge variant="muted" className="shrink-0">
|
||||
{t(`admin.channels.previewKinds.${item.kind}`)}
|
||||
</Badge>
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">{item.title ?? ''}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user