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