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