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 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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
Reference in New Issue
Block a user