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