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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -65,11 +65,13 @@ function estimateSeconds(
|
||||
export function JunctionsCard({
|
||||
channel,
|
||||
template,
|
||||
bare,
|
||||
onChanged,
|
||||
onError,
|
||||
}: {
|
||||
channel: ChannelDto
|
||||
template: ScheduleTemplateDto | undefined
|
||||
bare?: boolean
|
||||
onChanged: () => void
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
@@ -104,7 +106,7 @@ export function JunctionsCard({
|
||||
})
|
||||
|
||||
return (
|
||||
<CollapsibleCard title={t('admin.channels.junctions')}>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -18,10 +18,12 @@ const CORNERS: LogoCorner[] = ['TopLeft', 'TopRight', 'BottomLeft', 'BottomRight
|
||||
*/
|
||||
export function ViewerCard({
|
||||
channel,
|
||||
bare,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channel: ChannelDto
|
||||
bare?: boolean
|
||||
onSaved: () => void
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
@@ -40,7 +42,7 @@ export function ViewerCard({
|
||||
})
|
||||
|
||||
return (
|
||||
<CollapsibleCard title={t('admin.channels.viewer')}>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -388,6 +388,16 @@ const resources = {
|
||||
applicabilityNone: 'не задано',
|
||||
showForDate: 'Сетка на дату',
|
||||
allDates: 'Все слои',
|
||||
tabs: {
|
||||
grid: 'Сетка',
|
||||
rules: 'Правила',
|
||||
junctions: 'Стыки',
|
||||
bumpers: 'Заставки',
|
||||
viewer: 'Зритель',
|
||||
settings: 'Настройки',
|
||||
air: 'Эфир',
|
||||
},
|
||||
noTemplate: 'Сетка канала не загрузилась',
|
||||
rules: 'Правила отбора',
|
||||
rulesHint:
|
||||
'Жёсткие фильтры: отсекают неподходящее до жребия. Как и правка сетки, эфир не двигают — нужно применить.',
|
||||
@@ -1080,6 +1090,16 @@ const resources = {
|
||||
applicabilityNone: 'not set',
|
||||
showForDate: 'Grid for date',
|
||||
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',
|
||||
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.',
|
||||
|
||||
Reference in New Issue
Block a user