From 69c236d8cc43d521864c510c8f39d39ffadb6739 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 26 Jul 2026 20:03:14 +0300 Subject: [PATCH] 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. --- .../features/admin/channels/ChannelDetail.tsx | 103 ++- .../admin/channels/components/BumperCard.tsx | 8 +- .../channels/components/CollapsibleCard.tsx | 11 + .../channels/components/JunctionsCard.tsx | 644 +++++++++--------- .../admin/channels/components/RulesCard.tsx | 4 +- .../channels/components/SettingsCard.tsx | 3 + .../admin/channels/components/ViewerCard.tsx | 294 ++++---- frontend/src/shared/lib/i18n.ts | 20 + 8 files changed, 593 insertions(+), 494 deletions(-) diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index 839860f..d2bf967 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -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(null) const [copyToChannel, setCopyToChannel] = useState('') + const [tab, setTab] = useState('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 }) { )} - + {/* Вкладки вместо колонки карточек: экран канала перестал помещаться в один свиток. */} + - {template && ( - -
+ {tab === 'settings' && ( + + )} + + {/* Шаблон мог не загрузиться — раньше вкладка сетки просто оказывалась пустой. */} + {tab === 'grid' && !template && ( +

+ {templateError instanceof HttpError + ? templateError.detail + : t('admin.channels.noTemplate')} +

+ )} + + {tab === 'grid' && template && ( + + +

@@ -408,24 +444,43 @@ export function ChannelDetail({ channelId }: { channelId: string }) { /> )}

-
- +
+
+
)} - {template && ( - + {tab === 'rules' && + (template ? ( + + ) : ( +

{t('admin.channels.noTemplate')}

+ ))} + + {tab === 'junctions' && ( + )} - + {tab === 'bumpers' && ( + + )} - + {tab === 'viewer' && ( + + )} - + {tab === 'air' && ( + + + + + + )} {applicabilityLayer && ( )} - - {applyOpen && ( void onError: (e: unknown) => void }) { @@ -58,7 +60,11 @@ export function BumperCard({ const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position) return ( - +
- -
- ) -} - -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(null) - const [dragged, setDragged] = useState(null) - const [editing, setEditing] = useState(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 ( -
-
- setName(e.target.value)} - onBlur={() => - name !== null && name.trim() && name !== junction.name - ? renameMutation.mutate(name.trim()) - : setName(null) - } - /> - - - {exact ? '' : '≈ '} - {formatClock(total)} - - -
- - {/* Цепочка: что играет между концом одной программы и началом следующей. */} -
- - {t('admin.channels.junctionFrom')} - - {elements.length === 0 && ( - <> - - {t('admin.channels.junctionEmpty')} - - )} - {elements.map((element) => ( - - - - - ))} - - - {t('admin.channels.junctionTo')} - -
- - {/* Линейка: доля каждой врезки в стыке. Пустые (без группы) в неё не попадают. */} - {total > 0 && ( -
- {elements.map((element, index) => ( -
- ))} -
- )} - - {editing && ( - setEditing(null)} - onChanged={onChanged} - onError={onError} - /> - )} -
- ) -} +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 = { + 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 ( + +
+

{t('admin.channels.junctionsHint')}

+ + {template && ( +
+ + +
+ )} + + {(junctions ?? []).map((junction) => ( + + ))} + +
+ setNewName(e.target.value)} + /> + +
+
+
+ ) +} + +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(null) + const [dragged, setDragged] = useState(null) + const [editing, setEditing] = useState(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 ( +
+
+ setName(e.target.value)} + onBlur={() => + name !== null && name.trim() && name !== junction.name + ? renameMutation.mutate(name.trim()) + : setName(null) + } + /> + + + {exact ? '' : '≈ '} + {formatClock(total)} + + +
+ + {/* Цепочка: что играет между концом одной программы и началом следующей. */} +
+ + {t('admin.channels.junctionFrom')} + + {elements.length === 0 && ( + <> + + {t('admin.channels.junctionEmpty')} + + )} + {elements.map((element) => ( + + + + + ))} + + + {t('admin.channels.junctionTo')} + +
+ + {/* Линейка: доля каждой врезки в стыке. Пустые (без группы) в неё не попадают. */} + {total > 0 && ( +
+ {elements.map((element, index) => ( +
+ ))} +
+ )} + + {editing && ( + setEditing(null)} + onChanged={onChanged} + onError={onError} + /> + )} +
+ ) +} diff --git a/frontend/src/features/admin/channels/components/RulesCard.tsx b/frontend/src/features/admin/channels/components/RulesCard.tsx index b348b17..d33fa61 100644 --- a/frontend/src/features/admin/channels/components/RulesCard.tsx +++ b/frontend/src/features/admin/channels/components/RulesCard.tsx @@ -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 ( - +

{t('admin.channels.rulesHint')}

diff --git a/frontend/src/features/admin/channels/components/SettingsCard.tsx b/frontend/src/features/admin/channels/components/SettingsCard.tsx index e59e9ed..00a6c7b 100644 --- a/frontend/src/features/admin/channels/components/SettingsCard.tsx +++ b/frontend/src/features/admin/channels/components/SettingsCard.tsx @@ -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({
diff --git a/frontend/src/features/admin/channels/components/ViewerCard.tsx b/frontend/src/features/admin/channels/components/ViewerCard.tsx index d6d5ccc..3a3c1c2 100644 --- a/frontend/src/features/admin/channels/components/ViewerCard.tsx +++ b/frontend/src/features/admin/channels/components/ViewerCard.tsx @@ -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(channel.viewer) - const [galleryOpen, setGalleryOpen] = useState(false) - - useEffect(() => setViewer(channel.viewer), [channel]) - - const patch = (part: Partial) => setViewer((prev) => ({ ...prev, ...part })) - - const save = useMutation({ - mutationFn: () => updateViewerSettings(channel.id, viewer), - onSuccess: onSaved, - onError, - }) - - return ( - -
-

{t('admin.channels.viewerHint')}

- -
-
- -
- {viewer.logoImageId ? ( - - ) : ( - {t('admin.channels.noLogo')} - )} -
-
- - {viewer.logoImageId && ( - - )} - patch({ logoImageId: image.id })} - /> -
- - {viewer.logoImageId && ( -
-
- - -
-
- - patch({ logoOpacity: clamp01(e.target.value) })} - /> -
-
- )} - - - -
- - patch({ analogFilterStrength: clamp01(e.target.value) })} - /> -

{t('admin.channels.analogFilterHint')}

-
- -
- -
-
-
- ) -} - -/** Сила и прозрачность живут в 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(channel.viewer) + const [galleryOpen, setGalleryOpen] = useState(false) + + useEffect(() => setViewer(channel.viewer), [channel]) + + const patch = (part: Partial) => setViewer((prev) => ({ ...prev, ...part })) + + const save = useMutation({ + mutationFn: () => updateViewerSettings(channel.id, viewer), + onSuccess: onSaved, + onError, + }) + + return ( + +
+

{t('admin.channels.viewerHint')}

+ +
+
+ +
+ {viewer.logoImageId ? ( + + ) : ( + {t('admin.channels.noLogo')} + )} +
+
+ + {viewer.logoImageId && ( + + )} + patch({ logoImageId: image.id })} + /> +
+ + {viewer.logoImageId && ( +
+
+ + +
+
+ + patch({ logoOpacity: clamp01(e.target.value) })} + /> +
+
+ )} + + + +
+ + patch({ analogFilterStrength: clamp01(e.target.value) })} + /> +

{t('admin.channels.analogFilterHint')}

+
+ +
+ +
+
+
+ ) +} + +/** Сила и прозрачность живут в 0..1: пустой ввод трактуем как ноль, а не как NaN. */ +function clamp01(value: string): number { + const n = Number(value) + return Number.isNaN(n) ? 0 : Math.min(1, Math.max(0, n)) +} diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index c290497..1f4926b 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -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.',