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:
@@ -19,9 +19,11 @@ import {
|
||||
} from './api'
|
||||
import { BumperCard } from './components/BumperCard'
|
||||
import { CollapsibleCard } from './components/CollapsibleCard'
|
||||
import { JunctionsCard } from './components/JunctionsCard'
|
||||
import { LayerList, ScheduleGrid } from './components/ScheduleGrid'
|
||||
import { SchedulePreview } from './components/SchedulePreview'
|
||||
import { SettingsCard } from './components/SettingsCard'
|
||||
import { TemplatePreview } from './components/TemplatePreview'
|
||||
import { SlotInspector, type SlotDraft } from './components/SlotInspector'
|
||||
|
||||
export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
@@ -164,6 +166,7 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<TemplatePreview channelId={channelId} />
|
||||
<ScheduleGrid
|
||||
template={template}
|
||||
selectedSlotId={draft?.slot?.id ?? null}
|
||||
@@ -171,13 +174,25 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
onAddSlot={openNewSlot}
|
||||
/>
|
||||
{draft && (
|
||||
<SlotInspector draft={draft} onClose={() => setDraft(null)} onChanged={invalidate} />
|
||||
<SlotInspector
|
||||
channelId={channelId}
|
||||
draft={draft}
|
||||
onClose={() => setDraft(null)}
|
||||
onChanged={invalidate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)}
|
||||
|
||||
<JunctionsCard
|
||||
channel={channel}
|
||||
template={template}
|
||||
onChanged={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
|
||||
|
||||
<SchedulePreview entries={schedule ?? []} />
|
||||
|
||||
@@ -7,8 +7,13 @@ import type {
|
||||
ChannelDto,
|
||||
ChannelSummaryDto,
|
||||
CreatedIdResponse,
|
||||
JunctionAmountMode,
|
||||
JunctionConditions,
|
||||
JunctionElementKind,
|
||||
JunctionTemplateDto,
|
||||
LayerApplicability,
|
||||
ScheduleEntryDto,
|
||||
SchedulePreviewDto,
|
||||
ScheduleTemplateDto,
|
||||
SlotDto,
|
||||
} from '@/shared/api/types'
|
||||
@@ -58,9 +63,17 @@ export function applyChannelTemplate(channelId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Сухой прогон по текущим правилам: ничего не пишет и не двигает курсоры слотов. */
|
||||
export function previewTemplate(channelId: string, days: number) {
|
||||
const query = new URLSearchParams({ days: String(days) })
|
||||
return apiRequest<SchedulePreviewDto>(
|
||||
`/admin/channels/${channelId}/template/preview?${query.toString()}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function updateTemplate(
|
||||
templateId: string,
|
||||
body: { name: string; fallbackGroupId: string | null },
|
||||
body: { name: string; fallbackGroupId: string | null; defaultJunctionId: string | null },
|
||||
) {
|
||||
return apiRequest<void>(`/admin/templates/${templateId}`, { method: 'PUT', body })
|
||||
}
|
||||
@@ -103,6 +116,70 @@ export function deleteSlot(slotId: string) {
|
||||
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ── Стыки канала ──────────────────────────────────────────────────────────
|
||||
|
||||
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 },
|
||||
})
|
||||
}
|
||||
|
||||
export type BumperTemplateStyleBody = {
|
||||
name: string
|
||||
backgroundColor: string
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -18,16 +18,23 @@ export function formatMinute(minute: number | null) {
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Момент UTC во времени канала. Сетка задаётся в нём, поэтому предпросмотр показывается так же:
|
||||
* локальное время админа тут только запутало бы.
|
||||
*/
|
||||
export function channelTime(iso: string, utcOffsetMinutes: number): Date {
|
||||
return new Date(new Date(iso).getTime() + utcOffsetMinutes * 60_000)
|
||||
}
|
||||
|
||||
/** «HH:MM» во времени канала. */
|
||||
export function formatChannelTime(iso: string, utcOffsetMinutes: number): string {
|
||||
const d = channelTime(iso, utcOffsetMinutes)
|
||||
return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** ffmpeg-цвет (0xRRGGBB / имя) → CSS для превью-плашки. */
|
||||
export function cssColor(value: string): string {
|
||||
const v = value.trim()
|
||||
if (v.startsWith('0x')) return `#${v.slice(2)}`
|
||||
return v
|
||||
}
|
||||
|
||||
/** Ограничивает вероятность появления заставки диапазоном 0..1 (пустой ввод → 0). */
|
||||
export function clampChance(value: string): number {
|
||||
const n = Number(value)
|
||||
if (Number.isNaN(n)) return 0
|
||||
return Math.min(1, Math.max(0, n))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { GripVertical, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { addCollectionShow, createCollection } from '@/features/admin/collections/api'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { type ClipDragItem, readDragItem } from './dnd'
|
||||
import { formatClock } from './format'
|
||||
|
||||
/**
|
||||
* Сборка рекламного блока: ролики перетаскиваются в упорядоченный список, под ним — суммарная
|
||||
* длительность. Сохраняется обычной коллекцией — отдельной сущности «блок» в модели нет (см. 3.7).
|
||||
*/
|
||||
export function BlockBuilder({
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
onSaved: () => void
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState('')
|
||||
const [items, setItems] = useState<ClipDragItem[]>([])
|
||||
const [over, setOver] = useState(false)
|
||||
const [dragged, setDragged] = useState<number | null>(null)
|
||||
|
||||
const total = items.reduce((sum, item) => sum + item.seconds, 0)
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { id } = await createCollection({ name: name.trim() })
|
||||
// Порядок задаётся порядком добавления: коллекция ставит позицию в конец.
|
||||
for (const item of items) await addCollectionShow(id, item.id)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setName('')
|
||||
setItems([])
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const drop = (event: React.DragEvent) => {
|
||||
event.preventDefault()
|
||||
setOver(false)
|
||||
const item = readDragItem(event)
|
||||
// Блок из блоков собрать нельзя: коллекция хранит шоу, а не вложенные коллекции.
|
||||
if (!item || item.kind !== 'clip') return
|
||||
setItems((current) => [...current, item])
|
||||
}
|
||||
|
||||
/** Перестановка внутри черновика — до сохранения всё живёт в состоянии, запросов нет. */
|
||||
const reorder = (target: number) => {
|
||||
if (dragged === null || dragged === target) return
|
||||
setItems((current) => {
|
||||
const next = [...current]
|
||||
const [moved] = next.splice(dragged, 1)
|
||||
next.splice(target, 0, moved)
|
||||
return next
|
||||
})
|
||||
setDragged(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('admin.interstitials.blockBuilder')}
|
||||
</h3>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setOver(true)
|
||||
}}
|
||||
onDragLeave={() => setOver(false)}
|
||||
onDrop={drop}
|
||||
className={cn(
|
||||
'crt-panel flex min-h-32 flex-col rounded-md border border-dashed border-border',
|
||||
over && 'border-primary bg-primary/5',
|
||||
)}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{t('admin.interstitials.dropHint')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border text-sm">
|
||||
{items.map((item, index) => (
|
||||
<li
|
||||
key={`${item.id}-${index}`}
|
||||
draggable
|
||||
onDragStart={() => setDragged(index)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.stopPropagation()
|
||||
reorder(index)
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-1.5"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
||||
<span className="w-5 shrink-0 text-muted-foreground">{index + 1}</span>
|
||||
<span className="min-w-0 flex-1 truncate" title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatClock(item.seconds)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setItems((c) => c.filter((_, i) => i !== index))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{t('admin.interstitials.blockTotal')}</span>
|
||||
<span className="tabular-nums">{formatClock(total)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('admin.interstitials.blockName')}
|
||||
value={name}
|
||||
maxLength={256}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!name.trim() || items.length === 0 || saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { addGroupElements, createGroup, listGroups } from '@/features/admin/groups/api'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { readDragItem } from './dnd'
|
||||
import { formatClock } from './format'
|
||||
|
||||
/**
|
||||
* Группы роликов собираются здесь же, а не в общем редакторе групп (см. 6.7): в выбранную группу
|
||||
* перетаскиваются и отдельные ролики, и готовые блоки — стык умеет и то и другое.
|
||||
*/
|
||||
export function ClipGroupPanel({ onError }: { onError: (error: unknown) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [selected, setSelected] = useState('')
|
||||
const [newName, setNewName] = useState('')
|
||||
const [over, setOver] = useState(false)
|
||||
|
||||
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'groups'] })
|
||||
}
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createGroup({ name: newName.trim() }),
|
||||
onSuccess: ({ id }) => {
|
||||
setNewName('')
|
||||
setSelected(id)
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (element: { elementKind: 'Show' | 'Collection'; elementId: string }) =>
|
||||
addGroupElements(selected, [element]),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
const group = groups?.find((g) => g.id === selected)
|
||||
|
||||
const drop = (event: React.DragEvent) => {
|
||||
event.preventDefault()
|
||||
setOver(false)
|
||||
const item = readDragItem(event)
|
||||
if (!item || !selected) return
|
||||
addMutation.mutate({
|
||||
elementKind: item.kind === 'block' ? 'Collection' : 'Show',
|
||||
elementId: item.id,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('admin.interstitials.groups')}
|
||||
</h3>
|
||||
|
||||
<Select value={selected} onValueChange={setSelected}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('admin.interstitials.pickGroup')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(groups ?? []).map((g) => (
|
||||
<SelectItem key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setOver(true)
|
||||
}}
|
||||
onDragLeave={() => setOver(false)}
|
||||
onDrop={drop}
|
||||
className={cn(
|
||||
'crt-panel flex min-h-20 flex-col items-center justify-center gap-1 rounded-md border border-dashed border-border px-3 py-4 text-center text-xs',
|
||||
over && selected && 'border-primary bg-primary/5',
|
||||
)}
|
||||
>
|
||||
{selected ? (
|
||||
<>
|
||||
<span className="text-muted-foreground">{t('admin.interstitials.dropToGroup')}</span>
|
||||
{group && (
|
||||
<span className="text-muted-foreground">
|
||||
{t('admin.groups.items')}: {group.itemCount} ·{' '}
|
||||
{formatClock(group.totalDurationSeconds)}
|
||||
</span>
|
||||
)}
|
||||
{group && (
|
||||
<Link
|
||||
to="/admin/groups/$groupId"
|
||||
params={{ groupId: group.id }}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t('admin.interstitials.openGroup')}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t('admin.interstitials.pickGroupFirst')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('admin.interstitials.newGroupName')}
|
||||
value={newName}
|
||||
maxLength={256}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!newName.trim() || createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { GripVertical, Play, Trash2, Upload } from 'lucide-react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { deleteCollection } from '@/features/admin/collections/api'
|
||||
import { useUploadStore } from '@/features/admin/media/upload-store'
|
||||
import { deleteShow, renameShow } from '@/features/admin/shows/api'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { InterstitialDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { HlsVideo } from '@/shared/ui/hls-video'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { BlockBuilder } from './BlockBuilder'
|
||||
import { ClipGroupPanel } from './ClipGroupPanel'
|
||||
import { listInterstitialBlocks, listInterstitials, mediaPreviewUrl } from './api'
|
||||
import { setDragItem } from './dnd'
|
||||
import { formatClock } from './format'
|
||||
|
||||
/**
|
||||
* Экран роликов (см. 6.7). Под капотом это `Show(Kind = Interstitial)` и коллекции, но сценарий
|
||||
* другой: массовая загрузка, длительности вместо метаданных и сборка блока перетаскиванием.
|
||||
*/
|
||||
export function InterstitialsPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const enqueue = useUploadStore((s) => s.enqueue)
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [preview, setPreview] = useState<InterstitialDto | null>(null)
|
||||
const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null)
|
||||
|
||||
const { data: clips, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'interstitials'],
|
||||
queryFn: listInterstitials,
|
||||
})
|
||||
const { data: blocks } = useQuery({
|
||||
queryKey: ['admin', 'interstitials', 'blocks'],
|
||||
queryFn: listInterstitialBlocks,
|
||||
})
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'interstitials'] })
|
||||
}
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) => renameShow(id, name),
|
||||
onSuccess: () => {
|
||||
setRenaming(null)
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
const deleteClipMutation = useMutation({ mutationFn: deleteShow, onSuccess: invalidate, onError })
|
||||
const deleteBlockMutation = useMutation({
|
||||
mutationFn: deleteCollection,
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const all = clips ?? []
|
||||
return q ? all.filter((c) => c.name.toLowerCase().includes(q)) : all
|
||||
}, [clips, query])
|
||||
|
||||
const pickFiles = (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return
|
||||
void enqueue(Array.from(files), { interstitial: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.interstitials.title')}</h2>
|
||||
<Button size="sm" onClick={() => fileInput.current?.click()}>
|
||||
<Upload className="h-4 w-4" /> {t('admin.interstitials.upload')}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
pickFiles(e.target.files)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">{t('admin.interstitials.hint')}</p>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_340px]">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('common.search')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="crt-panel overflow-x-auto rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">{t('admin.interstitials.name')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('admin.interstitials.duration')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('admin.media.status')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={4}>
|
||||
{t('common.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={4}>
|
||||
{t('admin.interstitials.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{filtered.map((clip) => (
|
||||
<tr
|
||||
key={clip.id}
|
||||
draggable
|
||||
onDragStart={(e) =>
|
||||
setDragItem(e, {
|
||||
kind: 'clip',
|
||||
id: clip.id,
|
||||
name: clip.name,
|
||||
seconds: clip.durationSeconds ?? 0,
|
||||
})
|
||||
}
|
||||
className="border-b border-border last:border-0"
|
||||
>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
||||
{renaming?.id === clip.id ? (
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-8"
|
||||
value={renaming.name}
|
||||
maxLength={256}
|
||||
onChange={(e) => setRenaming({ id: clip.id, name: e.target.value })}
|
||||
onBlur={() =>
|
||||
renaming.name.trim() && renaming.name !== clip.name
|
||||
? renameMutation.mutate({ id: clip.id, name: renaming.name.trim() })
|
||||
: setRenaming(null)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur()
|
||||
if (e.key === 'Escape') setRenaming(null)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 truncate text-left hover:underline"
|
||||
onClick={() => setRenaming({ id: clip.id, name: clip.name })}
|
||||
>
|
||||
{clip.name}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 tabular-nums text-muted-foreground">
|
||||
{formatClock(clip.durationSeconds)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge variant="muted">
|
||||
{clip.assetStatus
|
||||
? t(`admin.media.statuses.${clip.assetStatus}`)
|
||||
: t('admin.interstitials.noAsset')}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={clip.assetStatus !== 'Ready'}
|
||||
onClick={() => setPreview(clip)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteClipMutation.mutate(clip.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('admin.interstitials.blocks')}
|
||||
</h3>
|
||||
<div className="crt-panel rounded-md">
|
||||
{(blocks ?? []).length === 0 ? (
|
||||
<p className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{t('admin.interstitials.noBlocks')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border text-sm">
|
||||
{(blocks ?? []).map((block) => (
|
||||
<li
|
||||
key={block.id}
|
||||
draggable
|
||||
onDragStart={(e) =>
|
||||
setDragItem(e, {
|
||||
kind: 'block',
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
seconds: block.durationSeconds,
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-2 px-4 py-2"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
||||
<Link
|
||||
to="/admin/collections/$collectionId"
|
||||
params={{ collectionId: block.id }}
|
||||
className="min-w-0 flex-1 truncate text-primary hover:underline"
|
||||
>
|
||||
{block.name}
|
||||
</Link>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{t('admin.interstitials.clipsCount', { count: block.itemCount })}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatClock(block.durationSeconds)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteBlockMutation.mutate(block.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<BlockBuilder onSaved={invalidate} onError={onError} />
|
||||
<ClipGroupPanel onError={onError} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={preview !== null} onOpenChange={(open) => !open && setPreview(null)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{preview?.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{preview?.mediaAssetId && <HlsVideo src={mediaPreviewUrl(preview.mediaAssetId)} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { InterstitialBlockDto, InterstitialDto } from '@/shared/api/types'
|
||||
|
||||
export function listInterstitials() {
|
||||
return apiRequest<InterstitialDto[]>('/admin/interstitials')
|
||||
}
|
||||
|
||||
export function listInterstitialBlocks() {
|
||||
return apiRequest<InterstitialBlockDto[]>('/admin/interstitials/blocks')
|
||||
}
|
||||
|
||||
/** Превращает загруженные файлы в ролики. Уже привязанные к шоу ассеты сервер пропускает. */
|
||||
export function importInterstitials(mediaAssetIds: string[]) {
|
||||
return apiRequest<{ imported: number }>('/admin/interstitials/import', {
|
||||
method: 'POST',
|
||||
body: { mediaAssetIds },
|
||||
})
|
||||
}
|
||||
|
||||
/** Плейлист обработанного ассета под admin-роутом (JWT) — грузится через hls.js. */
|
||||
export function mediaPreviewUrl(assetId: string) {
|
||||
return `/api/admin/media/${assetId}/preview/index.m3u8`
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Перетаскивание на экране роликов. В сборку блока едут только ролики, в группу — и ролики,
|
||||
* и готовые блоки, поэтому в переносимых данных лежит вид элемента, а не только идентификатор.
|
||||
*/
|
||||
export type ClipDragItem = {
|
||||
kind: 'clip' | 'block'
|
||||
id: string
|
||||
name: string
|
||||
seconds: number
|
||||
}
|
||||
|
||||
const MIME = 'application/x-telewave-clip'
|
||||
|
||||
export function setDragItem(event: React.DragEvent, item: ClipDragItem) {
|
||||
event.dataTransfer.setData(MIME, JSON.stringify(item))
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
}
|
||||
|
||||
export function readDragItem(event: React.DragEvent): ClipDragItem | null {
|
||||
const raw = event.dataTransfer.getData(MIME)
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw) as ClipDragItem
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Длительность ролика — часами тут мерить нечего: «0:20», «2:40», «1:02:03». Прочерк вместо нуля,
|
||||
* потому что «0:00» читается как ролик нулевой длины, а не как «ещё не обработан».
|
||||
*/
|
||||
export function formatClock(seconds: number | null | undefined): string {
|
||||
if (seconds === null || seconds === undefined || seconds <= 0) return '—'
|
||||
const total = Math.round(seconds)
|
||||
const s = total % 60
|
||||
const m = Math.floor(total / 60) % 60
|
||||
const h = Math.floor(total / 3600)
|
||||
const mm = h > 0 ? String(m).padStart(2, '0') : String(m)
|
||||
return `${h > 0 ? `${h}:` : ''}${mm}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand'
|
||||
import { HttpError, refreshAccessToken } from '@/shared/api/client'
|
||||
import { queryClient } from '@/shared/api/query-client'
|
||||
import { importInterstitials } from '@/features/admin/interstitials/api'
|
||||
import { addEpisode } from '@/features/admin/shows/api'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { listMedia, uploadMedia } from './api'
|
||||
@@ -33,11 +34,13 @@ type UploadStore = {
|
||||
export type EnqueueOptions = {
|
||||
showId?: string
|
||||
resolveShowId?: (file: File) => string | undefined
|
||||
/** Загрузка с экрана «Ролики»: каждый файл после аплоада становится роликом (Show + серия). */
|
||||
interstitial?: boolean
|
||||
}
|
||||
|
||||
// Очередь и флаг живут вне React — загрузка продолжается при любой навигации.
|
||||
let counter = 0
|
||||
type Job = { id: string; file: File; showId?: string }
|
||||
type Job = { id: string; file: File; showId?: string; interstitial?: boolean }
|
||||
const queue: Job[] = []
|
||||
const controllers = new Map<string, AbortController>()
|
||||
const failed = new Map<string, Job>() // упавшие — для ручного повтора
|
||||
@@ -112,6 +115,14 @@ async function pump() {
|
||||
} catch {
|
||||
toast.error(`${job.file.name}: не удалось добавить в шоу`)
|
||||
}
|
||||
} else if (job.interstitial) {
|
||||
// Ролик заводится сразу после аплоада: длительность подтянется, когда ассет обработается.
|
||||
try {
|
||||
await importInterstitials([created.id])
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'interstitials'] })
|
||||
} catch {
|
||||
toast.error(`${job.file.name}: не удалось завести ролик`)
|
||||
}
|
||||
}
|
||||
} else if (isAbort(lastError) || controller.signal.aborted) {
|
||||
// Отмена — тихо: элемент уже убран из списка.
|
||||
@@ -157,7 +168,7 @@ export const useUploadStore = create<UploadStore>((set) => ({
|
||||
const newItems: UploadItem[] = toAdd.map((file) => {
|
||||
const id = `u${++counter}`
|
||||
const showId = options?.resolveShowId?.(file) ?? options?.showId
|
||||
queue.push({ id, file, showId })
|
||||
queue.push({ id, file, showId, interstitial: options?.interstitial })
|
||||
return { id, name: file.name, percent: 0, status: 'queued' }
|
||||
})
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Route as AdminCollectionsRouteImport } from './routes/admin/collections
|
||||
import { Route as AdminGalleryRouteImport } from './routes/admin/gallery'
|
||||
import { Route as AdminGenresRouteImport } from './routes/admin/genres'
|
||||
import { Route as AdminGroupsRouteImport } from './routes/admin/groups'
|
||||
import { Route as AdminInterstitialsRouteImport } from './routes/admin/interstitials'
|
||||
import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance'
|
||||
import { Route as AdminMediaRouteImport } from './routes/admin/media'
|
||||
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
|
||||
@@ -96,6 +97,11 @@ const AdminGroupsRoute = AdminGroupsRouteImport.update({
|
||||
path: '/groups',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminInterstitialsRoute = AdminInterstitialsRouteImport.update({
|
||||
id: '/interstitials',
|
||||
path: '/interstitials',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminMaintenanceRoute = AdminMaintenanceRouteImport.update({
|
||||
id: '/maintenance',
|
||||
path: '/maintenance',
|
||||
@@ -180,6 +186,7 @@ export interface FileRoutesByFullPath {
|
||||
'/admin/gallery': typeof AdminGalleryRoute
|
||||
'/admin/genres': typeof AdminGenresRoute
|
||||
'/admin/groups': typeof AdminGroupsRouteWithChildren
|
||||
'/admin/interstitials': typeof AdminInterstitialsRoute
|
||||
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||
'/admin/media': typeof AdminMediaRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
@@ -204,6 +211,7 @@ export interface FileRoutesByTo {
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/gallery': typeof AdminGalleryRoute
|
||||
'/admin/genres': typeof AdminGenresRoute
|
||||
'/admin/interstitials': typeof AdminInterstitialsRoute
|
||||
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||
'/admin/media': typeof AdminMediaRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
@@ -232,6 +240,7 @@ export interface FileRoutesById {
|
||||
'/admin/gallery': typeof AdminGalleryRoute
|
||||
'/admin/genres': typeof AdminGenresRoute
|
||||
'/admin/groups': typeof AdminGroupsRouteWithChildren
|
||||
'/admin/interstitials': typeof AdminInterstitialsRoute
|
||||
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||
'/admin/media': typeof AdminMediaRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
@@ -262,6 +271,7 @@ export interface FileRouteTypes {
|
||||
| '/admin/gallery'
|
||||
| '/admin/genres'
|
||||
| '/admin/groups'
|
||||
| '/admin/interstitials'
|
||||
| '/admin/maintenance'
|
||||
| '/admin/media'
|
||||
| '/admin/roles'
|
||||
@@ -286,6 +296,7 @@ export interface FileRouteTypes {
|
||||
| '/settings'
|
||||
| '/admin/gallery'
|
||||
| '/admin/genres'
|
||||
| '/admin/interstitials'
|
||||
| '/admin/maintenance'
|
||||
| '/admin/media'
|
||||
| '/admin/roles'
|
||||
@@ -313,6 +324,7 @@ export interface FileRouteTypes {
|
||||
| '/admin/gallery'
|
||||
| '/admin/genres'
|
||||
| '/admin/groups'
|
||||
| '/admin/interstitials'
|
||||
| '/admin/maintenance'
|
||||
| '/admin/media'
|
||||
| '/admin/roles'
|
||||
@@ -425,6 +437,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AdminGroupsRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/interstitials': {
|
||||
id: '/admin/interstitials'
|
||||
path: '/interstitials'
|
||||
fullPath: '/admin/interstitials'
|
||||
preLoaderRoute: typeof AdminInterstitialsRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/maintenance': {
|
||||
id: '/admin/maintenance'
|
||||
path: '/maintenance'
|
||||
@@ -587,6 +606,7 @@ interface AdminRouteChildren {
|
||||
AdminGalleryRoute: typeof AdminGalleryRoute
|
||||
AdminGenresRoute: typeof AdminGenresRoute
|
||||
AdminGroupsRoute: typeof AdminGroupsRouteWithChildren
|
||||
AdminInterstitialsRoute: typeof AdminInterstitialsRoute
|
||||
AdminMaintenanceRoute: typeof AdminMaintenanceRoute
|
||||
AdminMediaRoute: typeof AdminMediaRoute
|
||||
AdminRolesRoute: typeof AdminRolesRoute
|
||||
@@ -602,6 +622,7 @@ const AdminRouteChildren: AdminRouteChildren = {
|
||||
AdminGalleryRoute: AdminGalleryRoute,
|
||||
AdminGenresRoute: AdminGenresRoute,
|
||||
AdminGroupsRoute: AdminGroupsRouteWithChildren,
|
||||
AdminInterstitialsRoute: AdminInterstitialsRoute,
|
||||
AdminMaintenanceRoute: AdminMaintenanceRoute,
|
||||
AdminMediaRoute: AdminMediaRoute,
|
||||
AdminRolesRoute: AdminRolesRoute,
|
||||
|
||||
@@ -29,6 +29,13 @@ function AdminLayout() {
|
||||
>
|
||||
{t('admin.shows.title')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/interstitials"
|
||||
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
|
||||
>
|
||||
{t('admin.interstitials.title')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/groups"
|
||||
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { InterstitialsPanel } from '@/features/admin/interstitials/InterstitialsPanel'
|
||||
|
||||
export const Route = createFileRoute('/admin/interstitials')({ component: InterstitialsPanel })
|
||||
@@ -80,7 +80,8 @@ export type GenreDto = {
|
||||
}
|
||||
|
||||
// ── Библиотека (шоу) ───────────────────────────────────────────────────────
|
||||
export type ShowKind = 'Series' | 'Single'
|
||||
/** `Interstitial` — ролик-врезка: то же шоу, но со своим экраном и вне общей библиотеки. */
|
||||
export type ShowKind = 'Series' | 'Single' | 'Interstitial'
|
||||
|
||||
/** Возрастная категория, по возрастанию строгости — порядок значим для правил планировщика. */
|
||||
export type ShowAudience = 'Kids' | 'Family' | 'Teen' | 'General' | 'Adult'
|
||||
@@ -109,6 +110,25 @@ export type ShowGenreDto = {
|
||||
isPrimary: boolean
|
||||
}
|
||||
|
||||
// ── Ролики-врезки ──────────────────────────────────────────────────────────
|
||||
/** Ролик: важна длительность, поэтому она приходит сразу, а не вторым запросом за ассетом. */
|
||||
export type InterstitialDto = {
|
||||
id: string
|
||||
name: string
|
||||
mediaAssetId: string | null
|
||||
assetStatus: MediaAssetStatus | null
|
||||
durationSeconds: number | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** Рекламный блок — коллекция, целиком собранная из роликов. */
|
||||
export type InterstitialBlockDto = {
|
||||
id: string
|
||||
name: string
|
||||
itemCount: number
|
||||
durationSeconds: number
|
||||
}
|
||||
|
||||
// ── Библиотека (коллекции) ─────────────────────────────────────────────────
|
||||
export type CollectionSummaryDto = {
|
||||
id: string
|
||||
@@ -276,19 +296,15 @@ export type ShowDto = {
|
||||
// ── Каналы ────────────────────────────────────────────────────────────────
|
||||
export type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper' | 'Fallback' | 'SignOff'
|
||||
export type BumperFont = 'Sans' | 'Serif'
|
||||
export type BumperSelection = 'Rotation' | 'Random' | 'AlwaysFirst' | 'WeightedRandom'
|
||||
export type BumperSelection = 'Random' | 'AlwaysFirst' | 'WeightedRandom'
|
||||
export type BumperTextKind = 'NowNext' | 'Free'
|
||||
export type BumperTrigger = 'OnShowChange' | 'BetweenEpisodes' | 'Both'
|
||||
|
||||
/** Общие для канала настройки заставок (стиль/звук/текст — на блоках и подблоках). */
|
||||
/** Условия показа (как часто, на смене шоу или между сериями) живут на элементе стыка, не здесь. */
|
||||
export type BumperSettings = {
|
||||
font: BumperFont
|
||||
minIntervalMinutes: number
|
||||
selection: BumperSelection
|
||||
/** Вероятность заставки на смене шоу (0..1). */
|
||||
showChangeChance: number
|
||||
/** Вероятность заставки между блоками одного шоу (0..1). */
|
||||
episodeChangeChance: number
|
||||
}
|
||||
|
||||
/** Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока. */
|
||||
@@ -386,6 +402,40 @@ export type SlotDto = {
|
||||
maxDriftMinutes: number
|
||||
/** Округление старта до кратного N минут (5/10/15/30) или null. */
|
||||
snapToMinutes: number | null
|
||||
/** Стык между единицами внутри слота (null — шаблон канала по умолчанию). */
|
||||
junctionBetweenId: string | null
|
||||
/** Стык после слота (null — шаблон канала по умолчанию). */
|
||||
junctionAfterId: string | null
|
||||
}
|
||||
|
||||
// ── Стыки (что играет между программами) ──────────────────────────────────
|
||||
export type JunctionElementKind = 'Ad' | 'Promo' | 'Bumper' | 'Filler'
|
||||
export type JunctionAmountMode = 'Count' | 'Duration'
|
||||
|
||||
/** Условия показа врезки — структурные поля, а не выражение-строка. */
|
||||
export type JunctionConditions = {
|
||||
onlyOnElementChange: boolean
|
||||
minMinutesBetween: number
|
||||
}
|
||||
|
||||
export type JunctionElementDto = {
|
||||
id: string
|
||||
position: number
|
||||
kind: JunctionElementKind
|
||||
groupId: string | null
|
||||
groupName: string | null
|
||||
bumperTemplateId: string | null
|
||||
bumperTemplateName: string | null
|
||||
amountMode: JunctionAmountMode
|
||||
amountValue: number
|
||||
isRequired: boolean
|
||||
conditions: JunctionConditions | null
|
||||
}
|
||||
|
||||
export type JunctionTemplateDto = {
|
||||
id: string
|
||||
name: string
|
||||
elements: JunctionElementDto[]
|
||||
}
|
||||
|
||||
export type DateRange = { from: string; to: string }
|
||||
@@ -414,6 +464,8 @@ export type ScheduleTemplateDto = {
|
||||
channelId: string
|
||||
name: string
|
||||
fallbackGroupId: string | null
|
||||
/** Стык, который берётся, когда слот своего не задал. */
|
||||
defaultJunctionId: string | null
|
||||
revision: number
|
||||
appliedRevision: number
|
||||
/** Есть ли правки правил, не применённые к эфиру. */
|
||||
@@ -438,6 +490,27 @@ export type PlanningWarningDto = {
|
||||
|
||||
export type ApplyResultDto = { added: number; warnings: PlanningWarningDto[] }
|
||||
|
||||
/** Что попало в ленту предпросмотра. `Bumper` приходит без ассета — он рендерится при применении. */
|
||||
export type PlannedItemKind = 'Program' | 'Fallback' | 'SignOff' | 'Ad' | 'Promo' | 'Bumper'
|
||||
|
||||
export type PreviewItemDto = {
|
||||
kind: PlannedItemKind
|
||||
startsAtUtc: string
|
||||
endsAtUtc: string
|
||||
showId: string | null
|
||||
title: string | null
|
||||
slotId: string | null
|
||||
slotTitle: string | null
|
||||
}
|
||||
|
||||
export type SchedulePreviewDto = {
|
||||
fromUtc: string
|
||||
toUtc: string
|
||||
utcOffsetMinutes: number
|
||||
items: PreviewItemDto[]
|
||||
warnings: PlanningWarningDto[]
|
||||
}
|
||||
|
||||
export type ScheduleEntryDto = {
|
||||
id: string
|
||||
kind: ScheduleEntryKind
|
||||
|
||||
+144
-14
@@ -132,6 +132,28 @@ const resources = {
|
||||
empty: 'Коллекция пуста',
|
||||
orderHint: 'Порядок частей задаётся перетаскиванием — в нём они и пойдут в эфир.',
|
||||
},
|
||||
interstitials: {
|
||||
title: 'Ролики',
|
||||
hint: 'Реклама, промо и джинглы. Перетащите ролики в сборку блока или в группу справа — блок сохранится коллекцией и пойдёт в эфир целиком.',
|
||||
upload: 'Загрузить ролики',
|
||||
name: 'Название',
|
||||
duration: 'Длительность',
|
||||
empty: 'Роликов пока нет',
|
||||
noAsset: 'Без файла',
|
||||
blocks: 'Блоки',
|
||||
noBlocks: 'Блоков пока нет',
|
||||
clipsCount: '{{count}} рол.',
|
||||
blockBuilder: 'Сборка блока',
|
||||
blockName: 'Название блока',
|
||||
blockTotal: 'Длительность блока',
|
||||
dropHint: 'Перетащите сюда ролики',
|
||||
groups: 'Группы роликов',
|
||||
pickGroup: 'Выберите группу',
|
||||
pickGroupFirst: 'Сначала выберите группу',
|
||||
dropToGroup: 'Перетащите сюда ролик или блок',
|
||||
openGroup: 'Открыть группу',
|
||||
newGroupName: 'Новая группа',
|
||||
},
|
||||
genres: {
|
||||
title: 'Жанры',
|
||||
hint: 'Справочник жанров: по нему собираются группы контента, в него сводятся жанры из метаданных.',
|
||||
@@ -245,7 +267,7 @@ const resources = {
|
||||
name: 'Название',
|
||||
originalName: 'Оригинальное название (eng)',
|
||||
kind: 'Тип',
|
||||
kinds: { Series: 'Сериал', Single: 'Полнометражка' },
|
||||
kinds: { Series: 'Сериал', Single: 'Полнометражка', Interstitial: 'Ролик' },
|
||||
audience: 'Категория',
|
||||
audiences: {
|
||||
Kids: 'Детское',
|
||||
@@ -317,6 +339,58 @@ const resources = {
|
||||
maxDrift: 'Допуск, мин',
|
||||
snap: 'Округление',
|
||||
snapOff: 'нет',
|
||||
bumperConditionsHint:
|
||||
'Как часто ставить заставку и на каких переходах — условия элемента стыка, а не настройка канала.',
|
||||
preview: 'Предпросмотр',
|
||||
previewHide: 'Свернуть предпросмотр',
|
||||
previewHint: 'Прогон по текущим правилам: ничего не пишется, курсоры слотов не двигаются.',
|
||||
previewDays_one: '{{count}} сутки',
|
||||
previewDays_few: '{{count}} суток',
|
||||
previewDays_many: '{{count}} суток',
|
||||
previewTabs: { programme: 'Программа', tape: 'Лента' },
|
||||
previewKinds: {
|
||||
Program: 'Программа',
|
||||
Fallback: 'Фон',
|
||||
SignOff: 'Конец вещания',
|
||||
Ad: 'Реклама',
|
||||
Promo: 'Анонс',
|
||||
Bumper: 'Заставка',
|
||||
},
|
||||
previewLoad: 'Врезки по часам, пик — {{peak}} мин',
|
||||
junctions: 'Стыки',
|
||||
junctionsHint:
|
||||
'Что играет между программами: реклама, анонсы, заставки. Слот может взять свой стык, иначе берётся стык по умолчанию.',
|
||||
defaultJunction: 'Стык по умолчанию',
|
||||
noJunction: 'без стыка',
|
||||
newJunctionName: 'Новый стык',
|
||||
addJunctionElement: '+ врезка',
|
||||
junctionEmpty: 'пусто',
|
||||
junctionFrom: 'конец',
|
||||
junctionTo: 'начало',
|
||||
junctionElement: 'Врезка',
|
||||
junctionKind: 'Тип',
|
||||
junctionKinds: {
|
||||
Ad: 'Реклама',
|
||||
Promo: 'Анонс',
|
||||
Bumper: 'Заставка',
|
||||
Filler: 'Заполнитель',
|
||||
},
|
||||
junctionAmountMode: 'Чем меряется',
|
||||
junctionAmountModes: { Count: 'Единиц', Duration: 'Минут' },
|
||||
junctionCount: 'Сколько единиц',
|
||||
junctionMinutes: 'Сколько минут',
|
||||
junctionAmountHint:
|
||||
'В смешанной группе (ролики и готовые блоки) считайте минутами: одна «единица» там — то ли ролик, то ли блок.',
|
||||
junctionRequired: 'Обязательная — не выбрасывать при нехватке времени',
|
||||
junctionOnlyOnChange: 'Только при смене шоу',
|
||||
junctionMinInterval: 'Не чаще, чем раз в, мин',
|
||||
junctionMinIntervalHint: '0 — без ограничения.',
|
||||
junctionBetween: 'Стык внутри слота',
|
||||
junctionAfter: 'Стык после слота',
|
||||
junctionDefault: 'по умолчанию',
|
||||
bumperTemplate: 'Блок заставки',
|
||||
pickBumperTemplate: 'выберите блок',
|
||||
minutesShort: ' мин',
|
||||
pendingChanges: 'Правила изменены — эфир идёт по старым.',
|
||||
apply: 'Применить',
|
||||
applied: 'Эфир пересобран, записей: {{count}}',
|
||||
@@ -367,15 +441,9 @@ const resources = {
|
||||
bumpersLabel: 'Заставки на переходах',
|
||||
bumpersHint: 'Короткая заставка «Сейчас / Далее» между разными шоу',
|
||||
bumperSelection: 'Выбор блока',
|
||||
bumperSelectionRotation: 'По кругу',
|
||||
bumperSelectionRandom: 'Случайно',
|
||||
bumperSelectionWeighted: 'Случайно взвешенный',
|
||||
bumperSelectionAlwaysFirst: 'Всегда первый',
|
||||
bumperMinInterval: 'Мин. интервал, мин',
|
||||
bumperShowChangeChance: 'Вероятность на смене шоу',
|
||||
bumperShowChangeChanceHint: '0..1: 1 — на каждой смене, 0 — никогда',
|
||||
bumperEpisodeChangeChance: 'Вероятность между сериями',
|
||||
bumperEpisodeChangeChanceHint: '0..1: напр. 0.3 — примерно в 30% переходов между сериями',
|
||||
bumperFont: 'Шрифт',
|
||||
bumperFontSans: 'Гротеск',
|
||||
bumperFontSerif: 'Антиква',
|
||||
@@ -641,6 +709,28 @@ const resources = {
|
||||
empty: 'Collection is empty',
|
||||
orderHint: 'Drag to set the order of the parts — that is the order they air in.',
|
||||
},
|
||||
interstitials: {
|
||||
title: 'Clips',
|
||||
hint: 'Ads, promos and jingles. Drag clips into the block builder or a group on the right — a block is saved as a collection and airs as a whole.',
|
||||
upload: 'Upload clips',
|
||||
name: 'Name',
|
||||
duration: 'Duration',
|
||||
empty: 'No clips yet',
|
||||
noAsset: 'No file',
|
||||
blocks: 'Blocks',
|
||||
noBlocks: 'No blocks yet',
|
||||
clipsCount: '{{count}} clips',
|
||||
blockBuilder: 'Block builder',
|
||||
blockName: 'Block name',
|
||||
blockTotal: 'Block duration',
|
||||
dropHint: 'Drop clips here',
|
||||
groups: 'Clip groups',
|
||||
pickGroup: 'Pick a group',
|
||||
pickGroupFirst: 'Pick a group first',
|
||||
dropToGroup: 'Drop a clip or a block here',
|
||||
openGroup: 'Open group',
|
||||
newGroupName: 'New group',
|
||||
},
|
||||
genres: {
|
||||
title: 'Genres',
|
||||
hint: 'Genre reference: content groups are built from it, provider metadata is mapped into it.',
|
||||
@@ -754,7 +844,7 @@ const resources = {
|
||||
name: 'Name',
|
||||
originalName: 'Original name (eng)',
|
||||
kind: 'Kind',
|
||||
kinds: { Series: 'Series', Single: 'Movie' },
|
||||
kinds: { Series: 'Series', Single: 'Movie', Interstitial: 'Clip' },
|
||||
audience: 'Category',
|
||||
audiences: {
|
||||
Kids: 'Kids',
|
||||
@@ -826,6 +916,52 @@ const resources = {
|
||||
maxDrift: 'Allowance, min',
|
||||
snap: 'Snap',
|
||||
snapOff: 'off',
|
||||
bumperConditionsHint:
|
||||
'How often a bumper is inserted and on which transitions is a junction-element condition, not a channel setting.',
|
||||
preview: 'Preview',
|
||||
previewHide: 'Hide preview',
|
||||
previewHint: 'A run against the current rules: nothing is written, slot cursors do not move.',
|
||||
previewDays_one: '{{count}} day',
|
||||
previewDays_other: '{{count}} days',
|
||||
previewTabs: { programme: 'Programme', tape: 'Tape' },
|
||||
previewKinds: {
|
||||
Program: 'Programme',
|
||||
Fallback: 'Background',
|
||||
SignOff: 'Sign-off',
|
||||
Ad: 'Ad',
|
||||
Promo: 'Promo',
|
||||
Bumper: 'Bumper',
|
||||
},
|
||||
previewLoad: 'Breaks per hour, peak — {{peak}} min',
|
||||
junctions: 'Junctions',
|
||||
junctionsHint:
|
||||
'What plays between programmes: ads, promos, bumpers. A slot may pick its own junction, otherwise the default one is used.',
|
||||
defaultJunction: 'Default junction',
|
||||
noJunction: 'no junction',
|
||||
newJunctionName: 'New junction',
|
||||
addJunctionElement: '+ break',
|
||||
junctionEmpty: 'empty',
|
||||
junctionFrom: 'end',
|
||||
junctionTo: 'start',
|
||||
junctionElement: 'Break',
|
||||
junctionKind: 'Kind',
|
||||
junctionKinds: { Ad: 'Ad', Promo: 'Promo', Bumper: 'Bumper', Filler: 'Filler' },
|
||||
junctionAmountMode: 'Measured in',
|
||||
junctionAmountModes: { Count: 'Units', Duration: 'Minutes' },
|
||||
junctionCount: 'How many units',
|
||||
junctionMinutes: 'How many minutes',
|
||||
junctionAmountHint:
|
||||
'For a mixed group (clips and ready-made blocks) count in minutes: one "unit" there is either a clip or a whole block.',
|
||||
junctionRequired: 'Required — never dropped when time runs short',
|
||||
junctionOnlyOnChange: 'Only when the show changes',
|
||||
junctionMinInterval: 'No more often than once per, min',
|
||||
junctionMinIntervalHint: '0 — no limit.',
|
||||
junctionBetween: 'Junction inside the slot',
|
||||
junctionAfter: 'Junction after the slot',
|
||||
junctionDefault: 'default',
|
||||
bumperTemplate: 'Bumper block',
|
||||
pickBumperTemplate: 'pick a block',
|
||||
minutesShort: ' min',
|
||||
pendingChanges: 'Rules changed — the air still follows the old ones.',
|
||||
apply: 'Apply',
|
||||
applied: 'Air rebuilt, entries: {{count}}',
|
||||
@@ -876,15 +1012,9 @@ const resources = {
|
||||
bumpersLabel: 'Transition bumpers',
|
||||
bumpersHint: 'Short “Now / Next” bumper between different shows',
|
||||
bumperSelection: 'Block selection',
|
||||
bumperSelectionRotation: 'Rotation',
|
||||
bumperSelectionRandom: 'Random',
|
||||
bumperSelectionWeighted: 'Weighted random',
|
||||
bumperSelectionAlwaysFirst: 'Always first',
|
||||
bumperMinInterval: 'Min interval, min',
|
||||
bumperShowChangeChance: 'Chance on show change',
|
||||
bumperShowChangeChanceHint: '0..1: 1 — every change, 0 — never',
|
||||
bumperEpisodeChangeChance: 'Chance between episodes',
|
||||
bumperEpisodeChangeChanceHint: '0..1: e.g. 0.3 — about 30% of same-show transitions',
|
||||
bumperFont: 'Font',
|
||||
bumperFontSans: 'Sans',
|
||||
bumperFontSerif: 'Serif',
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import Hls from 'hls.js'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { getAccessToken } from '@/shared/api/client'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
/**
|
||||
* Мини-плеер HLS для админки: плейлист и сегменты лежат под admin-роутами (JWT), поэтому запросы
|
||||
* идут через hls.js с Bearer-заголовком. Нативный путь (Safari) — только там, где hls.js не нужен.
|
||||
*/
|
||||
export function HlsVideo({ src, className }: { src: string; className?: 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={cn('aspect-video w-full rounded-md border border-border bg-black', className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user