Updated the BumpersPanel, ChannelsPanel, CollectionsPanel, GroupsPanel, ClipGroupPanel, and ShowsPanel components to replace direct input fields with a CreateNameDialog for creating new items. This change enhances user experience by centralizing the creation process and improving UI consistency. Localization strings were also updated to reflect new dialog titles and labels in both English and Russian.
290 lines
12 KiB
TypeScript
290 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: (id: string) => deleteShow(id),
|
|
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)} autoPlay />
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|