282 lines
12 KiB
TypeScript
282 lines
12 KiB
TypeScript
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 { qk } from '@/shared/api/query-keys'
|
|
import type { InterstitialDto } from '@/shared/api/types'
|
|
import { useApiError } from '@/shared/lib/use-api-error'
|
|
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 { 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: qk.interstitials.all,
|
|
queryFn: listInterstitials,
|
|
})
|
|
const { data: blocks } = useQuery({
|
|
queryKey: qk.interstitials.blocks,
|
|
queryFn: listInterstitialBlocks,
|
|
})
|
|
|
|
const invalidate = () => {
|
|
void queryClient.invalidateQueries({ queryKey: qk.interstitials.all })
|
|
}
|
|
const onError = useApiError()
|
|
|
|
const renameMutation = useMutation({
|
|
mutationFn: ({ id, name }: Readonly<{ 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>
|
|
)
|
|
}
|