Refactor ChannelEndpoints and ScheduleGenerator: consolidate endpoint logic into partial files, remove unused methods, and enhance dependency injection for scheduling. Update ChannelDetail component to streamline imports and improve UI structure.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { addChannelAd } from '../api'
|
||||
|
||||
export function AddAdForm({
|
||||
channelId,
|
||||
options,
|
||||
onAdded,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
options: { id: string; name: string }[]
|
||||
onAdded: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [assetId, setAssetId] = useState('')
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: () => addChannelAd(channelId, assetId),
|
||||
onSuccess: () => {
|
||||
setAssetId('')
|
||||
onAdded()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Select value={assetId} onValueChange={setAssetId}>
|
||||
<SelectTrigger className="max-w-md">
|
||||
<SelectValue placeholder={t('admin.channels.pickAd')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" disabled={!assetId || add.isPending} onClick={() => add.mutate()}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BlockMode } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { addChannelShow } from '../api'
|
||||
import { NumberField } from './fields'
|
||||
|
||||
export function AddShowForm({
|
||||
channelId,
|
||||
options,
|
||||
onAdded,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
options: { id: string; name: string }[]
|
||||
onAdded: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [showId, setShowId] = useState('')
|
||||
const [weight, setWeight] = useState(1)
|
||||
const [blockMode, setBlockMode] = useState<BlockMode>('Count')
|
||||
const [blockValue, setBlockValue] = useState(1)
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: () => addChannelShow(channelId, { showId, weight, blockMode, blockValue }),
|
||||
onSuccess: () => {
|
||||
setShowId('')
|
||||
onAdded()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Select value={showId} onValueChange={setShowId}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder={t('admin.channels.pickShow')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<NumberField label={t('admin.channels.weight')} value={weight} onChange={setWeight} min={1} />
|
||||
<Select value={blockMode} onValueChange={(v) => setBlockMode(v as BlockMode)}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Count">{t('admin.channels.blockCount')}</SelectItem>
|
||||
<SelectItem value="Duration">{t('admin.channels.blockDuration')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<NumberField
|
||||
label={blockMode === 'Count' ? t('admin.channels.episodes') : t('admin.channels.minutes')}
|
||||
value={blockValue}
|
||||
onChange={setBlockValue}
|
||||
min={1}
|
||||
/>
|
||||
<Button size="sm" disabled={!showId || add.isPending} onClick={() => add.mutate()}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { imageUrl } from '@/features/admin/images/api'
|
||||
import { ImageGallery } from '@/features/admin/images/ImageGallery'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { clearBumperTemplateBackground, setBumperTemplateBackground } from '../api'
|
||||
|
||||
export function BumperBackgroundField({
|
||||
channelId,
|
||||
templateId,
|
||||
backgroundImageId,
|
||||
onChanged,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
templateId: string
|
||||
backgroundImageId: string | null
|
||||
onChanged: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [galleryOpen, setGalleryOpen] = useState(false)
|
||||
|
||||
const setBg = useMutation({
|
||||
mutationFn: (imageId: string) => setBumperTemplateBackground(channelId, templateId, imageId),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
const clearBg = useMutation({
|
||||
mutationFn: () => clearBumperTemplateBackground(channelId, templateId),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>
|
||||
{t('admin.channels.bumperBackground')}{' '}
|
||||
<span className={backgroundImageId ? 'text-emerald-500' : 'text-muted-foreground'}>
|
||||
{backgroundImageId
|
||||
? `· ${t('admin.channels.bumperFileLoaded')}`
|
||||
: `· ${t('admin.channels.bumperFileDefault')}`}
|
||||
</span>
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">{t('admin.channels.bumperBackgroundHint')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{backgroundImageId && (
|
||||
<img
|
||||
src={imageUrl(backgroundImageId)}
|
||||
alt=""
|
||||
className="h-10 w-16 shrink-0 rounded border border-border object-cover"
|
||||
/>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
|
||||
{t('admin.channels.bumperBackgroundPick')}
|
||||
</Button>
|
||||
{backgroundImageId && (
|
||||
<Button size="sm" variant="ghost" disabled={clearBg.isPending} onClick={() => clearBg.mutate()}>
|
||||
{t('admin.channels.bumperReset')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ImageGallery
|
||||
open={galleryOpen}
|
||||
onOpenChange={setGalleryOpen}
|
||||
category="BumperBackground"
|
||||
onSelect={(img) => setBg.mutate(img.id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { addBumperTemplate, updateChannelSettings } from '../api'
|
||||
import { clampChance } from '../lib/format'
|
||||
import { BumperTemplateEditor } from './BumperTemplateEditor'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
export function BumperCard({
|
||||
channel,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channel: ChannelDto
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
|
||||
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
|
||||
|
||||
const setField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
|
||||
setBumper((prev) => ({ ...prev, [key]: value }))
|
||||
|
||||
useEffect(() => {
|
||||
setBumpersEnabled(channel.bumpersEnabled)
|
||||
setBumper(channel.bumper)
|
||||
}, [channel])
|
||||
|
||||
// Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные
|
||||
// поля берём из канала без изменений (они правятся в своей карточке).
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelSettings(channel.id, {
|
||||
name: channel.name,
|
||||
isEnabled: channel.isEnabled,
|
||||
adInsertion: channel.adInsertion,
|
||||
adsPerBreak: channel.adsPerBreak,
|
||||
bumpersEnabled,
|
||||
bumper,
|
||||
fillerAssetId: channel.fillerAssetId,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const addTemplate = useMutation({
|
||||
mutationFn: () => addBumperTemplate(channel.id, ''),
|
||||
onSuccess: onSaved,
|
||||
onError,
|
||||
})
|
||||
|
||||
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
|
||||
|
||||
return (
|
||||
<CollapsibleCard title={t('admin.channels.bumpers')} contentClassName="flex flex-col gap-4">
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={bumpersEnabled}
|
||||
onChange={(e) => setBumpersEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t('admin.channels.bumpersLabel')}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumpersHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Общие настройки */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperSelection')}</Label>
|
||||
<Select value={bumper.selection} onValueChange={(v) => setField('selection', v as BumperSelection)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Rotation">{t('admin.channels.bumperSelectionRotation')}</SelectItem>
|
||||
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
|
||||
<SelectItem value="WeightedRandom">
|
||||
{t('admin.channels.bumperSelectionWeighted')}
|
||||
</SelectItem>
|
||||
<SelectItem value="AlwaysFirst">
|
||||
{t('admin.channels.bumperSelectionAlwaysFirst')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperFont')}</Label>
|
||||
<Select value={bumper.font} onValueChange={(v) => setField('font', v as BumperFont)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Sans">{t('admin.channels.bumperFontSans')}</SelectItem>
|
||||
<SelectItem value="Serif">{t('admin.channels.bumperFontSerif')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperMinInterval')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1440}
|
||||
value={bumper.minIntervalMinutes}
|
||||
onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperShowChangeChance')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={bumper.showChangeChance}
|
||||
onChange={(e) => setField('showChangeChance', clampChance(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperShowChangeChanceHint')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperEpisodeChangeChance')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={bumper.episodeChangeChance}
|
||||
onChange={(e) => setField('episodeChangeChance', clampChance(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperEpisodeChangeChanceHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Блоки заставок */}
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{templates.map((template) => (
|
||||
<BumperTemplateEditor
|
||||
key={template.id}
|
||||
channelId={channel.id}
|
||||
template={template}
|
||||
onChanged={onSaved}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button size="sm" variant="outline" disabled={addTemplate.isPending} onClick={() => addTemplate.mutate()}>
|
||||
{t('admin.channels.bumperAddTemplate')}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
|
||||
export function BumperFileUpload({
|
||||
channelId,
|
||||
templateId,
|
||||
kind,
|
||||
label,
|
||||
hint,
|
||||
has,
|
||||
accept,
|
||||
upload,
|
||||
clear,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
templateId: string
|
||||
kind: string
|
||||
label: string
|
||||
hint: string
|
||||
has: boolean
|
||||
accept: string
|
||||
upload: (id: string, templateId: string, file: File) => Promise<void>
|
||||
clear: (id: string, templateId: string) => Promise<void>
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const inputId = `bumper-${kind}-${templateId}`
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => upload(channelId, templateId, file),
|
||||
onSuccess: onSaved,
|
||||
onError,
|
||||
})
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: () => clear(channelId, templateId),
|
||||
onSuccess: onSaved,
|
||||
onError,
|
||||
})
|
||||
const busy = uploadMutation.isPending || clearMutation.isPending
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>
|
||||
{label}{' '}
|
||||
<span className={has ? 'text-emerald-500' : 'text-muted-foreground'}>
|
||||
{has
|
||||
? `· ${t('admin.channels.bumperFileLoaded')}`
|
||||
: `· ${t('admin.channels.bumperFileDefault')}`}
|
||||
</span>
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">{hint}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={inputId}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) uploadMutation.mutate(file)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => document.getElementById(inputId)?.click()}
|
||||
>
|
||||
{t('admin.channels.bumperUpload')}
|
||||
</Button>
|
||||
{has && (
|
||||
<Button size="sm" variant="ghost" disabled={busy} onClick={() => clearMutation.mutate()}>
|
||||
{t('admin.channels.bumperReset')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import Hls from 'hls.js'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getAccessToken } from '@/shared/api/client'
|
||||
import type { BumperTextVariantDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { bumperPreviewPlaylistUrl, renderBumperPreviews } from '../api'
|
||||
|
||||
export function BumperPreviewPlayer({
|
||||
channelId,
|
||||
templateId,
|
||||
variants,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
templateId: string
|
||||
variants: BumperTextVariantDto[]
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [ready, setReady] = useState(false)
|
||||
const [bust, setBust] = useState(0)
|
||||
|
||||
const render = useMutation({
|
||||
mutationFn: () => renderBumperPreviews(channelId, templateId),
|
||||
onSuccess: () => {
|
||||
setBust(Date.now())
|
||||
setReady(true)
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="outline" disabled={render.isPending} onClick={() => render.mutate()}>
|
||||
{render.isPending
|
||||
? t('admin.channels.bumperPreviewRendering')
|
||||
: t('admin.channels.bumperPreview')}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">{t('admin.channels.bumperPreviewHint')}</span>
|
||||
</div>
|
||||
{ready && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{[...variants]
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((v) => (
|
||||
<div key={v.id} className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">{v.name}</span>
|
||||
<PreviewVideo src={`${bumperPreviewPlaylistUrl(channelId, templateId, v.id)}?t=${bust}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Мини-плеер одного превью: грузит HLS через hls.js с Bearer-токеном (admin-роут под JWT). */
|
||||
function PreviewVideo({ src }: { src: string }) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
let hls: Hls | null = null
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({
|
||||
xhrSetup: (xhr) => {
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
},
|
||||
})
|
||||
hls.loadSource(src)
|
||||
hls.attachMedia(video)
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = src
|
||||
}
|
||||
return () => {
|
||||
hls?.destroy()
|
||||
}
|
||||
}, [src])
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
playsInline
|
||||
className="aspect-video w-full rounded-md border border-border bg-black"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
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,
|
||||
}: {
|
||||
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
|
||||
className="flex cursor-pointer items-center justify-between gap-2"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
{!template.isDefault && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={remove.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BumperTextKind, BumperTextVariantDto, BumperTrigger } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { removeBumperVariant, updateBumperVariant } from '../api'
|
||||
|
||||
export function BumperVariantEditor({
|
||||
channelId,
|
||||
templateId,
|
||||
variant,
|
||||
canRemove,
|
||||
onChanged,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
templateId: string
|
||||
variant: BumperTextVariantDto
|
||||
canRemove: boolean
|
||||
onChanged: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [form, setForm] = useState({
|
||||
name: variant.name,
|
||||
kind: variant.kind,
|
||||
nowLabel: variant.nowLabel,
|
||||
nextLabel: variant.nextLabel,
|
||||
line1: variant.line1,
|
||||
line2: variant.line2,
|
||||
trigger: variant.trigger,
|
||||
weight: variant.weight,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setForm({
|
||||
name: variant.name,
|
||||
kind: variant.kind,
|
||||
nowLabel: variant.nowLabel,
|
||||
nextLabel: variant.nextLabel,
|
||||
line1: variant.line1,
|
||||
line2: variant.line2,
|
||||
trigger: variant.trigger,
|
||||
weight: variant.weight,
|
||||
})
|
||||
}, [variant])
|
||||
|
||||
const set = <K extends keyof typeof form>(key: K, value: (typeof form)[K]) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateBumperVariant(channelId, templateId, variant.id, { ...form, name: form.name.trim() }),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onChanged()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
const remove = useMutation({
|
||||
mutationFn: () => removeBumperVariant(channelId, templateId, variant.id),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-border bg-background/40 p-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperVariantName')}</Label>
|
||||
<Input value={form.name} maxLength={64} onChange={(e) => set('name', e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperTextKind')}</Label>
|
||||
<Select value={form.kind} onValueChange={(v) => set('kind', v as BumperTextKind)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="NowNext">{t('admin.channels.bumperKindNowNext')}</SelectItem>
|
||||
<SelectItem value="Free">{t('admin.channels.bumperKindFree')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperTrigger')}</Label>
|
||||
<Select value={form.trigger} onValueChange={(v) => set('trigger', v as BumperTrigger)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="OnShowChange">
|
||||
{t('admin.channels.bumperTriggerOnShowChange')}
|
||||
</SelectItem>
|
||||
<SelectItem value="BetweenEpisodes">
|
||||
{t('admin.channels.bumperTriggerBetweenEpisodes')}
|
||||
</SelectItem>
|
||||
<SelectItem value="Both">{t('admin.channels.bumperTriggerBoth')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperVariantWeight')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1000}
|
||||
value={form.weight}
|
||||
onChange={(e) => set('weight', Math.max(0, Math.round(Number(e.target.value)) || 0))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperVariantWeightHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{form.kind === 'NowNext' ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperNowLabel')}</Label>
|
||||
<Input
|
||||
value={form.nowLabel}
|
||||
maxLength={64}
|
||||
onChange={(e) => set('nowLabel', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperNextLabel')}</Label>
|
||||
<Input
|
||||
value={form.nextLabel}
|
||||
maxLength={64}
|
||||
onChange={(e) => set('nextLabel', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperLine1')}</Label>
|
||||
<Input value={form.line1} maxLength={120} onChange={(e) => set('line1', e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperLine2')}</Label>
|
||||
<Input value={form.line2} maxLength={120} onChange={(e) => set('line2', e.target.value)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{canRemove && (
|
||||
<Button size="sm" variant="ghost" disabled={remove.isPending} onClick={() => remove.mutate()}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={save.isPending || !form.name.trim()}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BlockMode, ChannelShowDto, HourWindow } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { removeChannelShow, updateChannelShow } from '../api'
|
||||
import { RemoveButton } from './fields'
|
||||
|
||||
export function ChannelShowRow({
|
||||
channelId,
|
||||
row,
|
||||
onChanged,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
row: ChannelShowDto
|
||||
onChanged: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [weight, setWeight] = useState(row.weight)
|
||||
const [blockMode, setBlockMode] = useState<BlockMode>(row.blockMode)
|
||||
const [blockValue, setBlockValue] = useState(row.blockValue)
|
||||
const [isEnabled, setIsEnabled] = useState(row.isEnabled)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [multiplier, setMultiplier] = useState(row.preferredWeightMultiplier)
|
||||
const [hours, setHours] = useState<HourWindow[]>(row.preferredHours)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelShow(channelId, row.id, {
|
||||
weight,
|
||||
blockMode,
|
||||
blockValue,
|
||||
isEnabled,
|
||||
preferredWeightMultiplier: multiplier,
|
||||
preferredHours: hours.filter((h) => h.startHour < h.endHour),
|
||||
}),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
const addHour = () => setHours((h) => [...h, { startHour: 18, endHour: 23 }])
|
||||
const setHour = (i: number, patch: Partial<HourWindow>) =>
|
||||
setHours((h) => h.map((w, idx) => (idx === i ? { ...w, ...patch } : w)))
|
||||
const removeHour = (i: number) => setHours((h) => h.filter((_, idx) => idx !== i))
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className="border-b border-border last:border-0">
|
||||
<td className="py-2">{row.showName}</td>
|
||||
<td className="py-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(Number(e.target.value))}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Select value={blockMode} onValueChange={(v) => setBlockMode(v as BlockMode)}>
|
||||
<SelectTrigger className="h-8 w-36 whitespace-nowrap">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Count">{t('admin.channels.blockCount')}</SelectItem>
|
||||
<SelectItem value="Duration">{t('admin.channels.blockDuration')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={blockValue}
|
||||
onChange={(e) => setBlockValue(Number(e.target.value))}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{t('admin.channels.preferredHours')}
|
||||
{hours.length > 0 ? ` (${hours.length})` : ''}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<RemoveButton
|
||||
onClick={() => removeChannelShow(channelId, row.id).then(onChanged).catch(onError)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="border-b border-border last:border-0">
|
||||
<td colSpan={5} className="bg-muted/30 py-3">
|
||||
<div className="flex flex-col gap-3 pl-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">{t('admin.channels.preferredMultiplier')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={multiplier}
|
||||
onChange={(e) => setMultiplier(Math.max(1, Math.round(Number(e.target.value)) || 1))}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.preferredHoursHint')}
|
||||
</span>
|
||||
</div>
|
||||
{hours.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.preferredNone')}</p>
|
||||
)}
|
||||
{hours.map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<HourSelect value={w.startHour} from={0} to={23} onChange={(v) => setHour(i, { startHour: v })} />
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<HourSelect value={w.endHour} from={1} to={24} onChange={(v) => setHour(i, { endHour: v })} />
|
||||
{w.startHour >= w.endHour && (
|
||||
<span className="text-xs text-destructive">
|
||||
{t('admin.channels.preferredBadRange')}
|
||||
</span>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={() => removeHour(i)}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<Button size="sm" variant="outline" onClick={addHour}>
|
||||
{t('admin.channels.preferredAddWindow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Выпадающий выбор часа суток (значения from..to включительно), формат «HH:00». */
|
||||
function HourSelect({
|
||||
value,
|
||||
from,
|
||||
to,
|
||||
onChange,
|
||||
}: {
|
||||
value: number
|
||||
from: number
|
||||
to: number
|
||||
onChange: (v: number) => void
|
||||
}) {
|
||||
const options = Array.from({ length: to - from + 1 }, (_, i) => from + i)
|
||||
return (
|
||||
<Select value={String(value)} onValueChange={(v) => onChange(Number(v))}>
|
||||
<SelectTrigger className="h-8 w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((h) => (
|
||||
<SelectItem key={h} value={String(h)}>
|
||||
{String(h).padStart(2, '0')}:00
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { Card, CardContent, CardTitle } from '@/shared/ui/card'
|
||||
|
||||
/** Карточка со сворачиваемым содержимым: клик по заголовку скрывает/раскрывает блок. */
|
||||
export function CollapsibleCard({
|
||||
title,
|
||||
defaultOpen = false,
|
||||
contentClassName,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
defaultOpen?: boolean
|
||||
contentClassName?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
return (
|
||||
<Card>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center justify-between gap-2 p-6 text-left"
|
||||
>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<ChevronDown
|
||||
className={`h-5 w-5 shrink-0 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{open && <CardContent className={contentClassName}>{children}</CardContent>}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { OverrideMode, OverrideRecurrence } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { createOverride } from '../api'
|
||||
import { NumberField } from './fields'
|
||||
|
||||
export function OverrideForm({
|
||||
channelId,
|
||||
options,
|
||||
onCreated,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
options: { id: string; name: string }[]
|
||||
onCreated: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [mode, setMode] = useState<OverrideMode>('Exclusive')
|
||||
const [recurrence, setRecurrence] = useState<OverrideRecurrence>('OneTime')
|
||||
const [showId, setShowId] = useState('')
|
||||
const [weight, setWeight] = useState(1)
|
||||
const [start, setStart] = useState('')
|
||||
const [end, setEnd] = useState('')
|
||||
// Weekly: день недели (0=Вс..6=Сб) + окна времени суток «HH:MM».
|
||||
const [dayOfWeek, setDayOfWeek] = useState(6)
|
||||
const [startTime, setStartTime] = useState('')
|
||||
const [endTime, setEndTime] = useState('')
|
||||
|
||||
const toMinutes = (hhmm: string) => {
|
||||
const [h, m] = hhmm.split(':').map(Number)
|
||||
return h * 60 + m
|
||||
}
|
||||
const weekly = recurrence === 'Weekly'
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
createOverride(
|
||||
channelId,
|
||||
weekly
|
||||
? {
|
||||
mode,
|
||||
recurrence,
|
||||
dayOfWeek,
|
||||
startMinute: toMinutes(startTime),
|
||||
endMinute: toMinutes(endTime),
|
||||
shows: [{ showId, weight }],
|
||||
}
|
||||
: {
|
||||
mode,
|
||||
recurrence,
|
||||
startsAtUtc: new Date(start).toISOString(),
|
||||
endsAtUtc: new Date(end).toISOString(),
|
||||
shows: [{ showId, weight }],
|
||||
},
|
||||
),
|
||||
onSuccess: () => {
|
||||
setShowId('')
|
||||
setStart('')
|
||||
setEnd('')
|
||||
setStartTime('')
|
||||
setEndTime('')
|
||||
onCreated()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const valid = weekly
|
||||
? showId && startTime && endTime && toMinutes(endTime) > toMinutes(startTime)
|
||||
: showId && start && end && new Date(end) > new Date(start)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.overrideRecurrence')}</Label>
|
||||
<Select value={recurrence} onValueChange={(v) => setRecurrence(v as OverrideRecurrence)}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="OneTime">{t('admin.channels.recurrenceOneTime')}</SelectItem>
|
||||
<SelectItem value="Weekly">{t('admin.channels.recurrenceWeekly')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select value={mode} onValueChange={(v) => setMode(v as OverrideMode)}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Exclusive">{t('admin.channels.modes.Exclusive')}</SelectItem>
|
||||
<SelectItem value="Boost">{t('admin.channels.modes.Boost')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={showId} onValueChange={setShowId}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue placeholder={t('admin.channels.pickShow')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{mode === 'Boost' && (
|
||||
<NumberField label={t('admin.channels.weight')} value={weight} onChange={setWeight} min={1} />
|
||||
)}
|
||||
{weekly ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.weekday')}</Label>
|
||||
<Select value={String(dayOfWeek)} onValueChange={(v) => setDayOfWeek(Number(v))}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
||||
<SelectItem key={d} value={String(d)}>
|
||||
{t(`admin.channels.weekdays.${d}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.from')}</Label>
|
||||
<Input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} className="w-32" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.to')}</Label>
|
||||
<Input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} className="w-32" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.from')}</Label>
|
||||
<Input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} className="w-60" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.to')}</Label>
|
||||
<Input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} className="w-60" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button size="sm" disabled={!valid || create.isPending} onClick={() => create.mutate()}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ScheduleEntryDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { formatTime } from '../lib/format'
|
||||
|
||||
export function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
|
||||
const { t } = useTranslation()
|
||||
if (entries.length === 0)
|
||||
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col divide-y divide-border text-sm">
|
||||
{entries.slice(0, 40).map((e) => (
|
||||
<li key={e.id} className="flex items-center gap-3 py-1.5">
|
||||
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
||||
{formatTime(e.startsAtUtc)}
|
||||
</span>
|
||||
{e.kind === 'Ad' ? (
|
||||
<Badge variant="muted">{t('air.ad')}</Badge>
|
||||
) : e.kind === 'Bumper' ? (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="muted" className="shrink-0">
|
||||
{t('air.bumper')}
|
||||
</Badge>
|
||||
{(e.bumperName || e.bumperText) && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">
|
||||
{e.bumperName}
|
||||
{e.bumperName && e.bumperText ? ' · ' : ''}
|
||||
{e.bumperText}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{e.showName ?? '—'}
|
||||
{e.seasonEpisode ? (
|
||||
<span className="text-muted-foreground"> · {e.seasonEpisode}</span>
|
||||
) : (
|
||||
e.episodeIndex != null && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
· {t('air.episode')} {e.episodeIndex + 1}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AdInsertion, ChannelDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { updateChannelSettings } from '../api'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
export function SettingsCard({
|
||||
channel,
|
||||
readyAssets,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channel: ChannelDto
|
||||
readyAssets: { id: string; originalFileName: string }[]
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState(channel.name)
|
||||
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
|
||||
const [adInsertion, setAdInsertion] = useState<AdInsertion>(channel.adInsertion)
|
||||
const [adsPerBreak, setAdsPerBreak] = useState(channel.adsPerBreak)
|
||||
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
setName(channel.name)
|
||||
setIsEnabled(channel.isEnabled)
|
||||
setAdInsertion(channel.adInsertion)
|
||||
setAdsPerBreak(channel.adsPerBreak)
|
||||
setFillerAssetId(channel.fillerAssetId ?? '')
|
||||
}, [channel])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelSettings(channel.id, {
|
||||
name: name.trim(),
|
||||
isEnabled,
|
||||
adInsertion,
|
||||
adsPerBreak,
|
||||
// Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений.
|
||||
bumpersEnabled: channel.bumpersEnabled,
|
||||
bumper: channel.bumper,
|
||||
fillerAssetId: fillerAssetId || null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<CollapsibleCard
|
||||
title={t('admin.channels.settings')}
|
||||
defaultOpen
|
||||
contentClassName="grid gap-4 sm:grid-cols-2"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.name')}</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.adPolicy')}</Label>
|
||||
<Select value={adInsertion} onValueChange={(v) => setAdInsertion(v as AdInsertion)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="BetweenBlocks">{t('admin.channels.betweenBlocks')}</SelectItem>
|
||||
<SelectItem value="BetweenEpisodes">{t('admin.channels.betweenEpisodes')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.adsPerBreak')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={adsPerBreak}
|
||||
onChange={(e) => setAdsPerBreak(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.filler')}</Label>
|
||||
<Select
|
||||
value={fillerAssetId || 'none'}
|
||||
onValueChange={(v) => setFillerAssetId(v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('admin.channels.noFiller')}</SelectItem>
|
||||
{readyAssets.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.originalFileName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
||||
{t('admin.channels.enabledLabel')}
|
||||
</label>
|
||||
<div className="flex items-end justify-end sm:col-span-2">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
|
||||
export function NumberField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
onChange: (v: number) => void
|
||||
min?: number
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={min}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RemoveButton({ onClick }: { onClick: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Button size="sm" variant="destructive" onClick={onClick}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/** Утилиты форматирования для карточек канала (без React). */
|
||||
|
||||
export function formatTime(iso: string | null) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleString([], {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/** Минуты суток → «HH:MM». */
|
||||
export function formatMinute(minute: number | null) {
|
||||
if (minute == null) return '—'
|
||||
const h = Math.floor(minute / 60)
|
||||
const m = minute % 60
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** ffmpeg-цвет (0xRRGGBB / имя) → CSS для превью-плашки. */
|
||||
export function cssColor(value: string): string {
|
||||
const v = value.trim()
|
||||
if (v.startsWith('0x')) return `#${v.slice(2)}`
|
||||
return v
|
||||
}
|
||||
|
||||
/** Ограничивает вероятность появления заставки диапазоном 0..1 (пустой ввод → 0). */
|
||||
export function clampChance(value: string): number {
|
||||
const n = Number(value)
|
||||
if (Number.isNaN(n)) return 0
|
||||
return Math.min(1, Math.max(0, n))
|
||||
}
|
||||
Reference in New Issue
Block a user