Files
TeleWave/frontend/src/features/admin/interstitials/BlockBuilder.tsx
T
Leonid Pershin b80163b6b4
ci / build-backend (push) Successful in 1m45s
ci / build-frontend (push) Successful in 52s
ci / tests (push) Successful in 2m3s
ci / sonar (push) Successful in 4m41s
Refactor regex handling and improve null checks across various components
Updated regex patterns in episode parsing and metadata handling to use String.raw for better readability and maintainability. Enhanced null checks in BlockBuilder, MediaPanel, and ShowMetadataCard components to prevent potential runtime errors. Consolidated imports in UsersPanel for cleaner code structure.
2026-07-27 02:09:47 +03:00

144 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,
}: Readonly<{
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?.kind !== 'clip') return
setItems((current) => [...current, item])
}
const removeAt = (index: number) => setItems((current) => current.filter((_, i) => i !== index))
/** Перестановка внутри черновика — до сохранения всё живёт в состоянии, запросов нет. */
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={() => removeAt(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>
)
}