Files
TeleWave/frontend/src/features/admin/channels/components/BumperTemplateEditor.tsx
T

211 lines
7.7 KiB
TypeScript

import { useMutation } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronDown } from 'lucide-react'
import type { BumperTemplateDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { toast } from '@/shared/ui/toast-store'
import {
addBumperVariant,
clearBumperTemplateAudio,
removeBumperTemplate,
updateBumperTemplate,
uploadBumperTemplateAudio,
} from '../api'
import { cssColor } from '../lib/format'
import { BumperBackgroundField } from './BumperBackgroundField'
import { BumperFileUpload } from './BumperFileUpload'
import { BumperPreviewPlayer } from './BumperPreviewPlayer'
import { BumperVariantEditor } from './BumperVariantEditor'
export function BumperTemplateEditor({
channelId,
template,
onChanged,
onError,
}: Readonly<{
channelId: string
template: BumperTemplateDto
onChanged: () => void
onError: (e: unknown) => void
}>) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [name, setName] = useState(template.name)
const [colors, setColors] = useState({
backgroundColor: template.backgroundColor,
backgroundColor2: template.backgroundColor2,
accentColor: template.accentColor,
textColor: template.textColor,
})
useEffect(() => {
setName(template.name)
setColors({
backgroundColor: template.backgroundColor,
backgroundColor2: template.backgroundColor2,
accentColor: template.accentColor,
textColor: template.textColor,
})
}, [template])
const save = useMutation({
mutationFn: () => updateBumperTemplate(channelId, template.id, { name: name.trim(), ...colors }),
onSuccess: () => {
toast.success(t('settings.saved'))
onChanged()
},
onError,
})
const remove = useMutation({
mutationFn: () => removeBumperTemplate(channelId, template.id),
onSuccess: onChanged,
onError,
})
const addVariant = useMutation({
mutationFn: () => addBumperVariant(channelId, template.id, ''),
onSuccess: onChanged,
onError,
})
const colorFields: { key: keyof typeof colors; label: string }[] = [
{ key: 'backgroundColor', label: t('admin.channels.bumperBg') },
{ key: 'backgroundColor2', label: t('admin.channels.bumperBg2') },
{ key: 'accentColor', label: t('admin.channels.bumperAccent') },
{ key: 'textColor', label: t('admin.channels.bumperText') },
]
return (
<div className="flex flex-col gap-3 rounded-md border border-border bg-muted/30 p-4">
{/* Сворачивание висит на кнопке, а не на всей строке: кликабельный div недоступен с клавиатуры.
Кнопка удаления при этом вынесена наружу — вложенная кнопка внутри кнопки недопустима,
и заодно ей больше не нужен stopPropagation. */}
<div className="flex items-center justify-between gap-2">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex flex-1 cursor-pointer flex-wrap items-center gap-2 text-left"
>
<ChevronDown
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`}
/>
<span className="text-sm font-medium">{template.name}</span>
{template.isDefault && <Badge variant="muted">{t('admin.channels.bumperDefault')}</Badge>}
<span className="text-xs text-muted-foreground">
{template.hasAudio && template.audioDurationSeconds != null
? `≈${Math.round(template.audioDurationSeconds)} ${t('admin.channels.bumperSeconds')}`
: t('admin.channels.bumperDefaultDuration')}
</span>
</button>
{!template.isDefault && (
<Button
size="sm"
variant="destructive"
disabled={remove.isPending}
onClick={() => remove.mutate()}
>
{t('common.delete')}
</Button>
)}
</div>
{open && (
<>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperTemplateName')}</Label>
<Input value={name} maxLength={64} onChange={(e) => setName(e.target.value)} />
</div>
{colorFields.map(({ key, label }) => (
<div key={key} className="flex flex-col gap-1.5">
<Label>{label}</Label>
<div className="flex items-center gap-2">
<span
className="h-8 w-8 shrink-0 rounded border border-border"
style={{ backgroundColor: cssColor(colors[key]) }}
/>
<Input
value={colors[key]}
onChange={(e) => setColors((c) => ({ ...c, [key]: e.target.value }))}
/>
</div>
</div>
))}
</div>
<div className="grid gap-3 border-t border-border pt-3 sm:grid-cols-2">
<BumperFileUpload
channelId={channelId}
templateId={template.id}
kind="audio"
label={t('admin.channels.bumperAudio')}
hint={t('admin.channels.bumperAudioHint')}
has={template.hasAudio}
accept="audio/*"
upload={uploadBumperTemplateAudio}
clear={clearBumperTemplateAudio}
onSaved={onChanged}
onError={onError}
/>
<BumperBackgroundField
channelId={channelId}
templateId={template.id}
backgroundImageId={template.backgroundImageId}
onChanged={onChanged}
onError={onError}
/>
</div>
{/* Подблоки (текст-варианты) */}
<div className="flex flex-col gap-2 border-t border-border pt-3">
<p className="text-sm font-medium">{t('admin.channels.bumperVariants')}</p>
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperVariantsHint')}</p>
{[...template.variants]
.sort((a, b) => a.position - b.position)
.map((variant) => (
<BumperVariantEditor
key={variant.id}
channelId={channelId}
templateId={template.id}
variant={variant}
canRemove={template.variants.length > 1}
onChanged={onChanged}
onError={onError}
/>
))}
<div>
<Button
size="sm"
variant="outline"
disabled={addVariant.isPending}
onClick={() => addVariant.mutate()}
>
{t('admin.channels.bumperAddVariant')}
</Button>
</div>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<BumperPreviewPlayer
channelId={channelId}
templateId={template.id}
variants={template.variants}
onError={onError}
/>
</div>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</>
)}
</div>
)
}