Files
TeleWave/frontend/src/features/admin/interstitials/InterstitialsPanel.tsx
T

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>
)
}