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.
build / backend (push) Successful in 5m58s
build / frontend (push) Successful in 47s
tests / backend-tests (push) Successful in 5m57s

This commit is contained in:
Leonid Pershin
2026-07-26 14:03:53 +03:00
parent 66040a8841
commit 7b12a06d1b
53 changed files with 4076 additions and 469 deletions
@@ -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>
)
}