211 lines
7.7 KiB
TypeScript
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>
|
|
)
|
|
}
|