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

146 lines
5.2 KiB
TypeScript

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