Refactor ChannelDetail component to implement tabbed navigation for channel settings, enhancing user experience by organizing content into distinct sections. Introduce a new TABS constant for better maintainability and update related components (BumperCard, RulesCard, ViewerCard, etc.) to support a 'bare' prop for streamlined rendering. Improve error handling for template loading and update translations for better clarity.
build / backend (push) Successful in 1m10s
build / frontend (push) Successful in 35s
tests / backend-tests (push) Successful in 1m31s

This commit is contained in:
Leonid Pershin
2026-07-26 20:03:14 +03:00
parent 2387223f0b
commit 69c236d8cc
8 changed files with 593 additions and 494 deletions
@@ -8,7 +8,9 @@ import { HttpError } from '@/shared/api/client'
import type { GridLayerDto, SlotDto } from '@/shared/api/types' import type { GridLayerDto, SlotDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Card, CardContent } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { cn } from '@/shared/lib/cn'
import { toast } from '@/shared/ui/toast-store' import { toast } from '@/shared/ui/toast-store'
import { import {
applyChannelTemplate, applyChannelTemplate,
@@ -26,7 +28,6 @@ import {
} from './api' } from './api'
import { ApplyDialog } from './components/ApplyDialog' import { ApplyDialog } from './components/ApplyDialog'
import { BumperCard } from './components/BumperCard' import { BumperCard } from './components/BumperCard'
import { CollapsibleCard } from './components/CollapsibleCard'
import { EntryTraceDialog } from './components/EntryTraceDialog' import { EntryTraceDialog } from './components/EntryTraceDialog'
import { JunctionsCard } from './components/JunctionsCard' import { JunctionsCard } from './components/JunctionsCard'
import { LayerApplicabilityDialog } from './components/LayerApplicabilityDialog' import { LayerApplicabilityDialog } from './components/LayerApplicabilityDialog'
@@ -40,6 +41,10 @@ import { ViewerCard } from './components/ViewerCard'
import { SlotInspector, type SlotDraft } from './components/SlotInspector' import { SlotInspector, type SlotDraft } from './components/SlotInspector'
import { toTime } from './lib/format' import { toTime } from './lib/format'
/** Вкладки экрана канала — в порядке частоты правки. */
const TABS = ['grid', 'rules', 'junctions', 'bumpers', 'viewer', 'settings', 'air'] as const
type ChannelTab = (typeof TABS)[number]
export function ChannelDetail({ channelId }: { channelId: string }) { export function ChannelDetail({ channelId }: { channelId: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -53,12 +58,13 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
const [applyOpen, setApplyOpen] = useState(false) const [applyOpen, setApplyOpen] = useState(false)
const [traceEntryId, setTraceEntryId] = useState<string | null>(null) const [traceEntryId, setTraceEntryId] = useState<string | null>(null)
const [copyToChannel, setCopyToChannel] = useState('') const [copyToChannel, setCopyToChannel] = useState('')
const [tab, setTab] = useState<ChannelTab>('grid')
const { data: channel, isLoading } = useQuery({ const { data: channel, isLoading } = useQuery({
queryKey: ['admin', 'channels', channelId], queryKey: ['admin', 'channels', channelId],
queryFn: () => getChannel(channelId), queryFn: () => getChannel(channelId),
}) })
const { data: template } = useQuery({ const { data: template, error: templateError } = useQuery({
queryKey: ['admin', 'channels', channelId, 'template'], queryKey: ['admin', 'channels', channelId, 'template'],
queryFn: () => getChannelTemplate(channelId), queryFn: () => getChannelTemplate(channelId),
}) })
@@ -248,16 +254,46 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
</div> </div>
)} )}
<SettingsCard {/* Вкладки вместо колонки карточек: экран канала перестал помещаться в один свиток. */}
channel={channel} <nav className="flex flex-wrap gap-4 border-b border-border text-xs uppercase tracking-wide">
readyAssets={ready?.items ?? []} {TABS.map((value) => (
onSaved={invalidate} <button
onError={onError} key={value}
/> type="button"
onClick={() => setTab(value)}
className={cn(
'pb-2 text-muted-foreground hover:text-foreground',
tab === value && 'border-b-2 border-primary text-primary',
)}
>
{t(`admin.channels.tabs.${value}`)}
</button>
))}
</nav>
{template && ( {tab === 'settings' && (
<CollapsibleCard title={t('admin.channels.grid')} defaultOpen> <SettingsCard
<div className="grid gap-4 lg:grid-cols-[220px_1fr]"> channel={channel}
readyAssets={ready?.items ?? []}
bare
onSaved={invalidate}
onError={onError}
/>
)}
{/* Шаблон мог не загрузиться — раньше вкладка сетки просто оказывалась пустой. */}
{tab === 'grid' && !template && (
<p className="text-sm text-muted-foreground">
{templateError instanceof HttpError
? templateError.detail
: t('admin.channels.noTemplate')}
</p>
)}
{tab === 'grid' && template && (
<Card>
<CardContent>
<div className="grid gap-4 lg:grid-cols-[220px_1fr]">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
@@ -408,24 +444,43 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
/> />
)} )}
</div> </div>
</div> </div>
</CollapsibleCard> </CardContent>
</Card>
)} )}
{template && ( {tab === 'rules' &&
<RulesCard template={template} onChanged={invalidate} onError={onError} /> (template ? (
<RulesCard template={template} bare onChanged={invalidate} onError={onError} />
) : (
<p className="text-sm text-muted-foreground">{t('admin.channels.noTemplate')}</p>
))}
{tab === 'junctions' && (
<JunctionsCard
channel={channel}
template={template}
bare
onChanged={invalidate}
onError={onError}
/>
)} )}
<JunctionsCard {tab === 'bumpers' && (
channel={channel} <BumperCard channel={channel} bare onSaved={invalidate} onError={onError} />
template={template} )}
onChanged={invalidate}
onError={onError}
/>
<BumperCard channel={channel} onSaved={invalidate} onError={onError} /> {tab === 'viewer' && (
<ViewerCard channel={channel} bare onSaved={invalidate} onError={onError} />
)}
<ViewerCard channel={channel} onSaved={invalidate} onError={onError} /> {tab === 'air' && (
<Card>
<CardContent>
<SchedulePreview entries={schedule ?? []} onShowTrace={setTraceEntryId} />
</CardContent>
</Card>
)}
{applicabilityLayer && ( {applicabilityLayer && (
<LayerApplicabilityDialog <LayerApplicabilityDialog
@@ -436,8 +491,6 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
/> />
)} )}
<SchedulePreview entries={schedule ?? []} onShowTrace={setTraceEntryId} />
{applyOpen && ( {applyOpen && (
<ApplyDialog <ApplyDialog
channelId={channelId} channelId={channelId}
@@ -12,10 +12,12 @@ import { CollapsibleCard } from './CollapsibleCard'
export function BumperCard({ export function BumperCard({
channel, channel,
bare,
onSaved, onSaved,
onError, onError,
}: { }: {
channel: ChannelDto channel: ChannelDto
bare?: boolean
onSaved: () => void onSaved: () => void
onError: (e: unknown) => void onError: (e: unknown) => void
}) { }) {
@@ -58,7 +60,11 @@ export function BumperCard({
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position) const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
return ( return (
<CollapsibleCard title={t('admin.channels.bumpers')} contentClassName="flex flex-col gap-4"> <CollapsibleCard
title={t('admin.channels.bumpers')}
bare={bare}
contentClassName="flex flex-col gap-4"
>
<label className="flex items-start gap-2 text-sm"> <label className="flex items-start gap-2 text-sm">
<input <input
type="checkbox" type="checkbox"
@@ -6,15 +6,26 @@ import { Card, CardContent, CardTitle } from '@/shared/ui/card'
export function CollapsibleCard({ export function CollapsibleCard({
title, title,
defaultOpen = false, defaultOpen = false,
bare = false,
contentClassName, contentClassName,
children, children,
}: { }: {
title: string title: string
defaultOpen?: boolean defaultOpen?: boolean
/** Без своего заголовка и сворачивания — когда карточка и так лежит во вкладке с этим названием. */
bare?: boolean
contentClassName?: string contentClassName?: string
children: ReactNode children: ReactNode
}) { }) {
const [open, setOpen] = useState(defaultOpen) const [open, setOpen] = useState(defaultOpen)
if (bare)
return (
<Card>
<CardContent className={contentClassName}>{children}</CardContent>
</Card>
)
return ( return (
<Card> <Card>
<button <button
@@ -1,321 +1,323 @@
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { ChevronRight, Plus, Trash2 } from 'lucide-react' import { ChevronRight, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { listGroups } from '@/features/admin/groups/api' import { listGroups } from '@/features/admin/groups/api'
import { formatClock } from '@/features/admin/interstitials/format' import { formatClock } from '@/features/admin/interstitials/format'
import type { import type {
ChannelDto, ChannelDto,
GroupSummaryDto, GroupSummaryDto,
JunctionElementDto, JunctionElementDto,
JunctionElementKind, JunctionElementKind,
JunctionTemplateDto, JunctionTemplateDto,
ScheduleTemplateDto, ScheduleTemplateDto,
} from '@/shared/api/types' } from '@/shared/api/types'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label' import { Label } from '@/shared/ui/label'
import { cn } from '@/shared/lib/cn' import { cn } from '@/shared/lib/cn'
import { import {
addJunctionElement, addJunctionElement,
createJunction, createJunction,
deleteJunction, deleteJunction,
listJunctions, listJunctions,
renameJunction, renameJunction,
reorderJunction, reorderJunction,
updateTemplate, updateTemplate,
} from '../api' } from '../api'
import { CollapsibleCard } from './CollapsibleCard' import { CollapsibleCard } from './CollapsibleCard'
import { JunctionElementDialog } from './JunctionElementDialog' import { JunctionElementDialog } from './JunctionElementDialog'
/** Заставка без загруженного звука синтезируется фиксированной длины — та же цифра, что на сервере. */ /** Заставка без загруженного звука синтезируется фиксированной длины — та же цифра, что на сервере. */
const DEFAULT_BUMPER_SECONDS = 8 const DEFAULT_BUMPER_SECONDS = 8
const KIND_COLORS: Record<JunctionElementKind, string> = { const KIND_COLORS: Record<JunctionElementKind, string> = {
Ad: 'bg-amber-500/70', Ad: 'bg-amber-500/70',
Promo: 'bg-sky-500/70', Promo: 'bg-sky-500/70',
Bumper: 'bg-violet-500/70', Bumper: 'bg-violet-500/70',
Filler: 'bg-muted-foreground/40', Filler: 'bg-muted-foreground/40',
} }
/** /**
* Прикидка длины врезки для линейки. По длительности — точная, по количеству — среднее единицы * Прикидка длины врезки для линейки. По длительности — точная, по количеству — среднее единицы
* группы: в смешанной группе «одна единица» это то ли ролик, то ли блок, поэтому число заведомо * группы: в смешанной группе «одна единица» это то ли ролик, то ли блок, поэтому число заведомо
* приблизительное и помечается как оценка. * приблизительное и помечается как оценка.
*/ */
function estimateSeconds( function estimateSeconds(
element: JunctionElementDto, element: JunctionElementDto,
groups: GroupSummaryDto[] | undefined, groups: GroupSummaryDto[] | undefined,
channel: ChannelDto, channel: ChannelDto,
): { seconds: number; exact: boolean } { ): { seconds: number; exact: boolean } {
if (element.kind === 'Bumper') { if (element.kind === 'Bumper') {
const template = channel.bumperTemplates.find((b) => b.id === element.bumperTemplateId) const template = channel.bumperTemplates.find((b) => b.id === element.bumperTemplateId)
return { seconds: template?.audioDurationSeconds ?? DEFAULT_BUMPER_SECONDS, exact: true } return { seconds: template?.audioDurationSeconds ?? DEFAULT_BUMPER_SECONDS, exact: true }
} }
if (element.amountMode === 'Duration') return { seconds: element.amountValue * 60, exact: true } if (element.amountMode === 'Duration') return { seconds: element.amountValue * 60, exact: true }
const group = groups?.find((g) => g.id === element.groupId) const group = groups?.find((g) => g.id === element.groupId)
if (!group || group.unitCount === 0) return { seconds: 0, exact: false } if (!group || group.unitCount === 0) return { seconds: 0, exact: false }
return { return {
seconds: (element.amountValue * group.totalDurationSeconds) / group.unitCount, seconds: (element.amountValue * group.totalDurationSeconds) / group.unitCount,
exact: false, exact: false,
} }
} }
export function JunctionsCard({ export function JunctionsCard({
channel, channel,
template, template,
onChanged, bare,
onError, onChanged,
}: { onError,
channel: ChannelDto }: {
template: ScheduleTemplateDto | undefined channel: ChannelDto
onChanged: () => void template: ScheduleTemplateDto | undefined
onError: (error: unknown) => void bare?: boolean
}) { onChanged: () => void
const { t } = useTranslation() onError: (error: unknown) => void
const [newName, setNewName] = useState('') }) {
const { t } = useTranslation()
const { data: junctions } = useQuery({ const [newName, setNewName] = useState('')
queryKey: ['admin', 'channels', channel.id, 'junctions'],
queryFn: () => listJunctions(channel.id), const { data: junctions } = useQuery({
}) queryKey: ['admin', 'channels', channel.id, 'junctions'],
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups }) queryFn: () => listJunctions(channel.id),
})
const createMutation = useMutation({ const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
mutationFn: () => createJunction(channel.id, newName.trim()),
onSuccess: () => { const createMutation = useMutation({
setNewName('') mutationFn: () => createJunction(channel.id, newName.trim()),
onChanged() onSuccess: () => {
}, setNewName('')
onError, onChanged()
}) },
onError,
const defaultMutation = useMutation({ })
mutationFn: (junctionId: string | null) =>
updateTemplate(template!.id, { const defaultMutation = useMutation({
name: template!.name, mutationFn: (junctionId: string | null) =>
fallbackGroupId: template!.fallbackGroupId, updateTemplate(template!.id, {
defaultJunctionId: junctionId, name: template!.name,
rules: template!.rules, fallbackGroupId: template!.fallbackGroupId,
}), defaultJunctionId: junctionId,
onSuccess: onChanged, rules: template!.rules,
onError, }),
}) onSuccess: onChanged,
onError,
return ( })
<CollapsibleCard title={t('admin.channels.junctions')}>
<div className="flex flex-col gap-4"> return (
<p className="text-xs text-muted-foreground">{t('admin.channels.junctionsHint')}</p> <CollapsibleCard title={t('admin.channels.junctions')} bare={bare}>
<div className="flex flex-col gap-4">
{template && ( <p className="text-xs text-muted-foreground">{t('admin.channels.junctionsHint')}</p>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.defaultJunction')}</Label> {template && (
<select <div className="flex flex-col gap-1.5">
className="h-9 max-w-xs rounded-md border border-border bg-transparent px-2 text-sm" <Label>{t('admin.channels.defaultJunction')}</Label>
value={template.defaultJunctionId ?? ''} <select
onChange={(e) => defaultMutation.mutate(e.target.value || null)} className="h-9 max-w-xs rounded-md border border-border bg-transparent px-2 text-sm"
> value={template.defaultJunctionId ?? ''}
<option value="">{t('admin.channels.noJunction')}</option> onChange={(e) => defaultMutation.mutate(e.target.value || null)}
{(junctions ?? []).map((junction) => ( >
<option key={junction.id} value={junction.id}> <option value="">{t('admin.channels.noJunction')}</option>
{junction.name} {(junctions ?? []).map((junction) => (
</option> <option key={junction.id} value={junction.id}>
))} {junction.name}
</select> </option>
</div> ))}
)} </select>
</div>
{(junctions ?? []).map((junction) => ( )}
<JunctionChain
key={junction.id} {(junctions ?? []).map((junction) => (
junction={junction} <JunctionChain
channel={channel} key={junction.id}
groups={groups} junction={junction}
onChanged={onChanged} channel={channel}
onError={onError} groups={groups}
/> onChanged={onChanged}
))} onError={onError}
/>
<div className="flex max-w-md gap-2"> ))}
<Input
placeholder={t('admin.channels.newJunctionName')} <div className="flex max-w-md gap-2">
value={newName} <Input
maxLength={128} placeholder={t('admin.channels.newJunctionName')}
onChange={(e) => setNewName(e.target.value)} value={newName}
/> maxLength={128}
<Button onChange={(e) => setNewName(e.target.value)}
size="sm" />
variant="outline" <Button
disabled={!newName.trim() || createMutation.isPending} size="sm"
onClick={() => createMutation.mutate()} variant="outline"
> disabled={!newName.trim() || createMutation.isPending}
<Plus className="h-4 w-4" /> {t('common.create')} onClick={() => createMutation.mutate()}
</Button> >
</div> <Plus className="h-4 w-4" /> {t('common.create')}
</div> </Button>
</CollapsibleCard> </div>
) </div>
} </CollapsibleCard>
)
const ADDABLE: JunctionElementKind[] = ['Ad', 'Promo', 'Bumper', 'Filler'] }
function JunctionChain({ const ADDABLE: JunctionElementKind[] = ['Ad', 'Promo', 'Bumper', 'Filler']
junction,
channel, function JunctionChain({
groups, junction,
onChanged, channel,
onError, groups,
}: { onChanged,
junction: JunctionTemplateDto onError,
channel: ChannelDto }: {
groups: GroupSummaryDto[] | undefined junction: JunctionTemplateDto
onChanged: () => void channel: ChannelDto
onError: (error: unknown) => void groups: GroupSummaryDto[] | undefined
}) { onChanged: () => void
const { t } = useTranslation() onError: (error: unknown) => void
const [name, setName] = useState<string | null>(null) }) {
const [dragged, setDragged] = useState<string | null>(null) const { t } = useTranslation()
const [editing, setEditing] = useState<JunctionElementDto | null>(null) const [name, setName] = useState<string | null>(null)
const [dragged, setDragged] = useState<string | null>(null)
const renameMutation = useMutation({ const [editing, setEditing] = useState<JunctionElementDto | null>(null)
mutationFn: (value: string) => renameJunction(junction.id, value),
onSuccess: () => { const renameMutation = useMutation({
setName(null) mutationFn: (value: string) => renameJunction(junction.id, value),
onChanged() onSuccess: () => {
}, setName(null)
onError, onChanged()
}) },
const deleteMutation = useMutation({ onError,
mutationFn: () => deleteJunction(junction.id), })
onSuccess: onChanged, const deleteMutation = useMutation({
onError, mutationFn: () => deleteJunction(junction.id),
}) onSuccess: onChanged,
const addMutation = useMutation({ onError,
mutationFn: (kind: JunctionElementKind) => addJunctionElement(junction.id, kind), })
onSuccess: onChanged, const addMutation = useMutation({
onError, mutationFn: (kind: JunctionElementKind) => addJunctionElement(junction.id, kind),
}) onSuccess: onChanged,
const reorderMutation = useMutation({ onError,
mutationFn: (order: string[]) => reorderJunction(junction.id, order), })
onSuccess: onChanged, const reorderMutation = useMutation({
onError, mutationFn: (order: string[]) => reorderJunction(junction.id, order),
}) onSuccess: onChanged,
onError,
const elements = [...junction.elements].sort((a, b) => a.position - b.position) })
const estimates = elements.map((element) => estimateSeconds(element, groups, channel))
const total = estimates.reduce((sum, e) => sum + e.seconds, 0) const elements = [...junction.elements].sort((a, b) => a.position - b.position)
const exact = estimates.every((e) => e.exact) const estimates = elements.map((element) => estimateSeconds(element, groups, channel))
const total = estimates.reduce((sum, e) => sum + e.seconds, 0)
const dropOn = (targetId: string) => { const exact = estimates.every((e) => e.exact)
if (!dragged || dragged === targetId) return
const order = elements.map((e) => e.id).filter((id) => id !== dragged) const dropOn = (targetId: string) => {
order.splice(order.indexOf(targetId), 0, dragged) if (!dragged || dragged === targetId) return
setDragged(null) const order = elements.map((e) => e.id).filter((id) => id !== dragged)
reorderMutation.mutate(order) order.splice(order.indexOf(targetId), 0, dragged)
} setDragged(null)
reorderMutation.mutate(order)
return ( }
<div className="crt-panel flex flex-col gap-2 rounded-md p-3">
<div className="flex flex-wrap items-center gap-2"> return (
<Input <div className="crt-panel flex flex-col gap-2 rounded-md p-3">
className="h-8 max-w-56" <div className="flex flex-wrap items-center gap-2">
value={name ?? junction.name} <Input
maxLength={128} className="h-8 max-w-56"
onChange={(e) => setName(e.target.value)} value={name ?? junction.name}
onBlur={() => maxLength={128}
name !== null && name.trim() && name !== junction.name onChange={(e) => setName(e.target.value)}
? renameMutation.mutate(name.trim()) onBlur={() =>
: setName(null) name !== null && name.trim() && name !== junction.name
} ? renameMutation.mutate(name.trim())
/> : setName(null)
<select }
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm" />
value="" <select
onChange={(e) => e.target.value && addMutation.mutate(e.target.value as JunctionElementKind)} className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
> value=""
<option value="">{t('admin.channels.addJunctionElement')}</option> onChange={(e) => e.target.value && addMutation.mutate(e.target.value as JunctionElementKind)}
{ADDABLE.map((kind) => ( >
<option key={kind} value={kind}> <option value="">{t('admin.channels.addJunctionElement')}</option>
{t(`admin.channels.junctionKinds.${kind}`)} {ADDABLE.map((kind) => (
</option> <option key={kind} value={kind}>
))} {t(`admin.channels.junctionKinds.${kind}`)}
</select> </option>
<span className="ml-auto text-xs text-muted-foreground"> ))}
{exact ? '' : '≈ '} </select>
{formatClock(total)} <span className="ml-auto text-xs text-muted-foreground">
</span> {exact ? '' : '≈ '}
<Button size="sm" variant="ghost" onClick={() => deleteMutation.mutate()}> {formatClock(total)}
<Trash2 className="h-4 w-4" /> </span>
</Button> <Button size="sm" variant="ghost" onClick={() => deleteMutation.mutate()}>
</div> <Trash2 className="h-4 w-4" />
</Button>
{/* Цепочка: что играет между концом одной программы и началом следующей. */} </div>
<div className="flex flex-wrap items-center gap-1 text-xs">
<span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground"> {/* Цепочка: что играет между концом одной программы и началом следующей. */}
{t('admin.channels.junctionFrom')} <div className="flex flex-wrap items-center gap-1 text-xs">
</span> <span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground">
{elements.length === 0 && ( {t('admin.channels.junctionFrom')}
<> </span>
<ChevronRight className="h-3 w-3 text-muted-foreground" /> {elements.length === 0 && (
<span className="text-muted-foreground">{t('admin.channels.junctionEmpty')}</span> <>
</> <ChevronRight className="h-3 w-3 text-muted-foreground" />
)} <span className="text-muted-foreground">{t('admin.channels.junctionEmpty')}</span>
{elements.map((element) => ( </>
<span key={element.id} className="flex items-center gap-1"> )}
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" /> {elements.map((element) => (
<button <span key={element.id} className="flex items-center gap-1">
type="button" <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
draggable <button
onDragStart={() => setDragged(element.id)} type="button"
onDragOver={(e) => e.preventDefault()} draggable
onDrop={() => dropOn(element.id)} onDragStart={() => setDragged(element.id)}
onClick={() => setEditing(element)} onDragOver={(e) => e.preventDefault()}
className={cn( onDrop={() => dropOn(element.id)}
'cursor-grab rounded border border-border px-2 py-1 hover:border-primary', onClick={() => setEditing(element)}
element.isRequired && 'border-primary/70', className={cn(
)} 'cursor-grab rounded border border-border px-2 py-1 hover:border-primary',
> element.isRequired && 'border-primary/70',
{t(`admin.channels.junctionKinds.${element.kind}`)} )}
{element.kind === 'Bumper' >
? element.bumperTemplateName {t(`admin.channels.junctionKinds.${element.kind}`)}
? ` · ${element.bumperTemplateName}` {element.kind === 'Bumper'
: '' ? element.bumperTemplateName
: ` ×${element.amountValue}${element.amountMode === 'Duration' ? t('admin.channels.minutesShort') : ''}`} ? ` · ${element.bumperTemplateName}`
{element.isRequired && ' *'} : ''
</button> : ` ×${element.amountValue}${element.amountMode === 'Duration' ? t('admin.channels.minutesShort') : ''}`}
</span> {element.isRequired && ' *'}
))} </button>
<ChevronRight className="h-3 w-3 text-muted-foreground" /> </span>
<span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground"> ))}
{t('admin.channels.junctionTo')} <ChevronRight className="h-3 w-3 text-muted-foreground" />
</span> <span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground">
</div> {t('admin.channels.junctionTo')}
</span>
{/* Линейка: доля каждой врезки в стыке. Пустые (без группы) в неё не попадают. */} </div>
{total > 0 && (
<div className="flex h-2 overflow-hidden rounded-full bg-muted/40"> {/* Линейка: доля каждой врезки в стыке. Пустые (без группы) в неё не попадают. */}
{elements.map((element, index) => ( {total > 0 && (
<div <div className="flex h-2 overflow-hidden rounded-full bg-muted/40">
key={element.id} {elements.map((element, index) => (
className={KIND_COLORS[element.kind]} <div
style={{ width: `${(estimates[index].seconds / total) * 100}%` }} key={element.id}
title={`${t(`admin.channels.junctionKinds.${element.kind}`)} · ${formatClock(estimates[index].seconds)}`} className={KIND_COLORS[element.kind]}
/> style={{ width: `${(estimates[index].seconds / total) * 100}%` }}
))} title={`${t(`admin.channels.junctionKinds.${element.kind}`)} · ${formatClock(estimates[index].seconds)}`}
</div> />
)} ))}
</div>
{editing && ( )}
<JunctionElementDialog
junctionId={junction.id} {editing && (
element={editing} <JunctionElementDialog
bumperTemplates={channel.bumperTemplates} junctionId={junction.id}
onClose={() => setEditing(null)} element={editing}
onChanged={onChanged} bumperTemplates={channel.bumperTemplates}
onError={onError} onClose={() => setEditing(null)}
/> onChanged={onChanged}
)} onError={onError}
</div> />
) )}
} </div>
)
}
@@ -24,10 +24,12 @@ const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudi
*/ */
export function RulesCard({ export function RulesCard({
template, template,
bare,
onChanged, onChanged,
onError, onError,
}: { }: {
template: ScheduleTemplateDto template: ScheduleTemplateDto
bare?: boolean
onChanged: () => void onChanged: () => void
onError: (error: unknown) => void onError: (error: unknown) => void
}) { }) {
@@ -79,7 +81,7 @@ export function RulesCard({
setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w))) setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w)))
return ( return (
<CollapsibleCard title={t('admin.channels.rules')}> <CollapsibleCard title={t('admin.channels.rules')} bare={bare}>
<div className="flex flex-col gap-4 text-sm"> <div className="flex flex-col gap-4 text-sm">
<p className="text-xs text-muted-foreground">{t('admin.channels.rulesHint')}</p> <p className="text-xs text-muted-foreground">{t('admin.channels.rulesHint')}</p>
@@ -13,11 +13,13 @@ import { CollapsibleCard } from './CollapsibleCard'
export function SettingsCard({ export function SettingsCard({
channel, channel,
readyAssets, readyAssets,
bare,
onSaved, onSaved,
onError, onError,
}: { }: {
channel: ChannelDto channel: ChannelDto
readyAssets: { id: string; originalFileName: string }[] readyAssets: { id: string; originalFileName: string }[]
bare?: boolean
onSaved: () => void onSaved: () => void
onError: (e: unknown) => void onError: (e: unknown) => void
}) { }) {
@@ -67,6 +69,7 @@ export function SettingsCard({
<CollapsibleCard <CollapsibleCard
title={t('admin.channels.settings')} title={t('admin.channels.settings')}
defaultOpen defaultOpen
bare={bare}
contentClassName="grid gap-4 sm:grid-cols-2" contentClassName="grid gap-4 sm:grid-cols-2"
> >
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@@ -1,146 +1,148 @@
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { imageUrl } from '@/features/admin/images/api' import { imageUrl } from '@/features/admin/images/api'
import { ImageGallery } from '@/features/admin/images/ImageGallery' import { ImageGallery } from '@/features/admin/images/ImageGallery'
import type { ChannelDto, LogoCorner, ViewerSettings } from '@/shared/api/types' import type { ChannelDto, LogoCorner, ViewerSettings } from '@/shared/api/types'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label' import { Label } from '@/shared/ui/label'
import { updateViewerSettings } from '../api' import { updateViewerSettings } from '../api'
import { CollapsibleCard } from './CollapsibleCard' import { CollapsibleCard } from './CollapsibleCard'
const CORNERS: LogoCorner[] = ['TopLeft', 'TopRight', 'BottomLeft', 'BottomRight'] const CORNERS: LogoCorner[] = ['TopLeft', 'TopRight', 'BottomLeft', 'BottomRight']
/** /**
* Как канал выглядит у зрителя (см. 6.8): логотип, часы, аналоговый фильтр. Всё рисуется на клиенте * Как канал выглядит у зрителя (см. 6.8): логотип, часы, аналоговый фильтр. Всё рисуется на клиенте
* поверх видео и по умолчанию выключено — канал без логотипа и без шума остаётся нормальным каналом. * поверх видео и по умолчанию выключено — канал без логотипа и без шума остаётся нормальным каналом.
*/ */
export function ViewerCard({ export function ViewerCard({
channel, channel,
onSaved, bare,
onError, onSaved,
}: { onError,
channel: ChannelDto }: {
onSaved: () => void channel: ChannelDto
onError: (error: unknown) => void bare?: boolean
}) { onSaved: () => void
const { t } = useTranslation() onError: (error: unknown) => void
const [viewer, setViewer] = useState<ViewerSettings>(channel.viewer) }) {
const [galleryOpen, setGalleryOpen] = useState(false) const { t } = useTranslation()
const [viewer, setViewer] = useState<ViewerSettings>(channel.viewer)
useEffect(() => setViewer(channel.viewer), [channel]) const [galleryOpen, setGalleryOpen] = useState(false)
const patch = (part: Partial<ViewerSettings>) => setViewer((prev) => ({ ...prev, ...part })) useEffect(() => setViewer(channel.viewer), [channel])
const save = useMutation({ const patch = (part: Partial<ViewerSettings>) => setViewer((prev) => ({ ...prev, ...part }))
mutationFn: () => updateViewerSettings(channel.id, viewer),
onSuccess: onSaved, const save = useMutation({
onError, mutationFn: () => updateViewerSettings(channel.id, viewer),
}) onSuccess: onSaved,
onError,
return ( })
<CollapsibleCard title={t('admin.channels.viewer')}>
<div className="flex flex-col gap-4 text-sm"> return (
<p className="text-xs text-muted-foreground">{t('admin.channels.viewerHint')}</p> <CollapsibleCard title={t('admin.channels.viewer')} bare={bare}>
<div className="flex flex-col gap-4 text-sm">
<div className="flex flex-wrap items-end gap-3"> <p className="text-xs text-muted-foreground">{t('admin.channels.viewerHint')}</p>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.logo')}</Label> <div className="flex flex-wrap items-end gap-3">
<div className="flex h-16 w-24 items-center justify-center rounded-md border border-border bg-muted/30"> <div className="flex flex-col gap-1.5">
{viewer.logoImageId ? ( <Label>{t('admin.channels.logo')}</Label>
<img <div className="flex h-16 w-24 items-center justify-center rounded-md border border-border bg-muted/30">
src={imageUrl(viewer.logoImageId)} {viewer.logoImageId ? (
alt="" <img
className="h-full w-full object-contain" src={imageUrl(viewer.logoImageId)}
/> alt=""
) : ( className="h-full w-full object-contain"
<span className="text-xs text-muted-foreground">{t('admin.channels.noLogo')}</span> />
)} ) : (
</div> <span className="text-xs text-muted-foreground">{t('admin.channels.noLogo')}</span>
</div> )}
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}> </div>
{t('admin.channels.pickLogo')} </div>
</Button> <Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
{viewer.logoImageId && ( {t('admin.channels.pickLogo')}
<Button size="sm" variant="ghost" onClick={() => patch({ logoImageId: null })}> </Button>
{t('common.delete')} {viewer.logoImageId && (
</Button> <Button size="sm" variant="ghost" onClick={() => patch({ logoImageId: null })}>
)} {t('common.delete')}
<ImageGallery </Button>
open={galleryOpen} )}
onOpenChange={setGalleryOpen} <ImageGallery
category="Library" open={galleryOpen}
onSelect={(image) => patch({ logoImageId: image.id })} onOpenChange={setGalleryOpen}
/> category="Library"
</div> onSelect={(image) => patch({ logoImageId: image.id })}
/>
{viewer.logoImageId && ( </div>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5"> {viewer.logoImageId && (
<Label>{t('admin.channels.logoCorner')}</Label> <div className="flex flex-wrap items-end gap-3">
<select <div className="flex flex-col gap-1.5">
className="h-9 rounded-md border border-border bg-transparent px-2" <Label>{t('admin.channels.logoCorner')}</Label>
value={viewer.logoCorner} <select
onChange={(e) => patch({ logoCorner: e.target.value as LogoCorner })} className="h-9 rounded-md border border-border bg-transparent px-2"
> value={viewer.logoCorner}
{CORNERS.map((corner) => ( onChange={(e) => patch({ logoCorner: e.target.value as LogoCorner })}
<option key={corner} value={corner}> >
{t(`admin.channels.corners.${corner}`)} {CORNERS.map((corner) => (
</option> <option key={corner} value={corner}>
))} {t(`admin.channels.corners.${corner}`)}
</select> </option>
</div> ))}
<div className="flex flex-col gap-1.5"> </select>
<Label>{t('admin.channels.logoOpacity')}</Label> </div>
<Input <div className="flex flex-col gap-1.5">
type="number" <Label>{t('admin.channels.logoOpacity')}</Label>
min={0} <Input
max={1} type="number"
step={0.05} min={0}
className="w-28" max={1}
value={viewer.logoOpacity} step={0.05}
onChange={(e) => patch({ logoOpacity: clamp01(e.target.value) })} className="w-28"
/> value={viewer.logoOpacity}
</div> onChange={(e) => patch({ logoOpacity: clamp01(e.target.value) })}
</div> />
)} </div>
</div>
<label className="flex items-center gap-2"> )}
<input
type="checkbox" <label className="flex items-center gap-2">
checked={viewer.showClock} <input
onChange={(e) => patch({ showClock: e.target.checked })} type="checkbox"
/> checked={viewer.showClock}
{t('admin.channels.showClock')} onChange={(e) => patch({ showClock: e.target.checked })}
</label> />
{t('admin.channels.showClock')}
<div className="flex flex-col gap-1.5"> </label>
<Label>{t('admin.channels.analogFilter')}</Label>
<Input <div className="flex flex-col gap-1.5">
type="number" <Label>{t('admin.channels.analogFilter')}</Label>
min={0} <Input
max={1} type="number"
step={0.1} min={0}
className="w-28" max={1}
value={viewer.analogFilterStrength} step={0.1}
onChange={(e) => patch({ analogFilterStrength: clamp01(e.target.value) })} className="w-28"
/> value={viewer.analogFilterStrength}
<p className="text-xs text-muted-foreground">{t('admin.channels.analogFilterHint')}</p> onChange={(e) => patch({ analogFilterStrength: clamp01(e.target.value) })}
</div> />
<p className="text-xs text-muted-foreground">{t('admin.channels.analogFilterHint')}</p>
<div className="flex justify-end"> </div>
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')} <div className="flex justify-end">
</Button> <Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
</div> {t('common.save')}
</div> </Button>
</CollapsibleCard> </div>
) </div>
} </CollapsibleCard>
)
/** Сила и прозрачность живут в 0..1: пустой ввод трактуем как ноль, а не как NaN. */ }
function clamp01(value: string): number {
const n = Number(value) /** Сила и прозрачность живут в 0..1: пустой ввод трактуем как ноль, а не как NaN. */
return Number.isNaN(n) ? 0 : Math.min(1, Math.max(0, n)) function clamp01(value: string): number {
} const n = Number(value)
return Number.isNaN(n) ? 0 : Math.min(1, Math.max(0, n))
}
+20
View File
@@ -388,6 +388,16 @@ const resources = {
applicabilityNone: 'не задано', applicabilityNone: 'не задано',
showForDate: 'Сетка на дату', showForDate: 'Сетка на дату',
allDates: 'Все слои', allDates: 'Все слои',
tabs: {
grid: 'Сетка',
rules: 'Правила',
junctions: 'Стыки',
bumpers: 'Заставки',
viewer: 'Зритель',
settings: 'Настройки',
air: 'Эфир',
},
noTemplate: 'Сетка канала не загрузилась',
rules: 'Правила отбора', rules: 'Правила отбора',
rulesHint: rulesHint:
'Жёсткие фильтры: отсекают неподходящее до жребия. Как и правка сетки, эфир не двигают — нужно применить.', 'Жёсткие фильтры: отсекают неподходящее до жребия. Как и правка сетки, эфир не двигают — нужно применить.',
@@ -1080,6 +1090,16 @@ const resources = {
applicabilityNone: 'not set', applicabilityNone: 'not set',
showForDate: 'Grid for date', showForDate: 'Grid for date',
allDates: 'All layers', allDates: 'All layers',
tabs: {
grid: 'Grid',
rules: 'Rules',
junctions: 'Junctions',
bumpers: 'Bumpers',
viewer: 'Viewer',
settings: 'Settings',
air: 'On air',
},
noTemplate: 'The channel grid failed to load',
rules: 'Candidate rules', rules: 'Candidate rules',
rulesHint: rulesHint:
'Hard filters: they cut out what is not allowed before the draw. Like grid edits, they do not move the air — apply to take effect.', 'Hard filters: they cut out what is not allowed before the draw. Like grid edits, they do not move the air — apply to take effect.',