Enhance channel and settings functionalities: introduce viewer settings in ChannelEndpoints, update SiteSettings to include channel number toggling, and refactor related data structures. Implement new endpoints for validating templates and diffing scheduling changes, improving overall user experience and configuration management.
This commit is contained in:
@@ -12,25 +12,31 @@ import { Input } from '@/shared/ui/input'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import {
|
||||
applyChannelTemplate,
|
||||
copyTemplateTo,
|
||||
createLayer,
|
||||
createSlot,
|
||||
deleteLayer,
|
||||
getChannel,
|
||||
getChannelTemplate,
|
||||
getSchedule,
|
||||
listChannels,
|
||||
toSlotBody,
|
||||
updateLayer,
|
||||
updateSlot,
|
||||
} 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'
|
||||
import { LayerList, ScheduleGrid } from './components/ScheduleGrid'
|
||||
import { SchedulePreview } from './components/SchedulePreview'
|
||||
import { RulesCard } from './components/RulesCard'
|
||||
import { SettingsCard } from './components/SettingsCard'
|
||||
import { TemplateIssues } from './components/TemplateIssues'
|
||||
import { TemplatePreview } from './components/TemplatePreview'
|
||||
import { ViewerCard } from './components/ViewerCard'
|
||||
import { SlotInspector, type SlotDraft } from './components/SlotInspector'
|
||||
import { toTime } from './lib/format'
|
||||
|
||||
@@ -44,6 +50,9 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
// День, который копируем, и отмеченные дни-приёмники.
|
||||
const [copySource, setCopySource] = useState<number | null>(null)
|
||||
const [copyTargets, setCopyTargets] = useState<number[]>([])
|
||||
const [applyOpen, setApplyOpen] = useState(false)
|
||||
const [traceEntryId, setTraceEntryId] = useState<string | null>(null)
|
||||
const [copyToChannel, setCopyToChannel] = useState('')
|
||||
|
||||
const { data: channel, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId],
|
||||
@@ -68,9 +77,12 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const { data: channels } = useQuery({ queryKey: ['admin', 'channels'], queryFn: listChannels })
|
||||
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: () => applyChannelTemplate(channelId),
|
||||
onSuccess: (result) => {
|
||||
setApplyOpen(false)
|
||||
toast.success(t('admin.channels.applied', { count: result.added }))
|
||||
// Предупреждения показываем по одному: каждое указывает на конкретный слот.
|
||||
for (const warning of result.warnings)
|
||||
@@ -136,6 +148,21 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
onError,
|
||||
})
|
||||
|
||||
const copyTemplateMutation = useMutation({
|
||||
mutationFn: (targetChannelId: string) => copyTemplateTo(channelId, targetChannelId),
|
||||
onSuccess: (result) => {
|
||||
setCopyToChannel('')
|
||||
toast.success(
|
||||
t('admin.channels.templateCopied', { layers: result.layers, slots: result.slots }),
|
||||
)
|
||||
if (result.droppedBumperRefs > 0)
|
||||
toast.error(
|
||||
t('admin.channels.copyDroppedBumpers', { count: result.droppedBumperRefs }),
|
||||
)
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const moveSlotMutation = useMutation({
|
||||
mutationFn: ({
|
||||
slot,
|
||||
@@ -215,7 +242,7 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
{template?.hasPendingChanges && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-amber-500/50 bg-amber-500/10 px-4 py-2 text-sm">
|
||||
<span>{t('admin.channels.pendingChanges')}</span>
|
||||
<Button size="sm" disabled={applyMutation.isPending} onClick={() => applyMutation.mutate()}>
|
||||
<Button size="sm" disabled={applyMutation.isPending} onClick={() => setApplyOpen(true)}>
|
||||
<Send className="h-4 w-4" /> {t('admin.channels.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -256,9 +283,48 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
onEditApplicability={setApplicabilityLayer}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.layersHint')}</p>
|
||||
|
||||
{/* Копия сетки на другой канал: группы общие, поэтому переносятся только правила. */}
|
||||
<div className="flex flex-col gap-1.5 border-t border-border pt-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('admin.channels.copyTemplate')}
|
||||
</span>
|
||||
<select
|
||||
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
||||
value={copyToChannel}
|
||||
onChange={(e) => setCopyToChannel(e.target.value)}
|
||||
>
|
||||
<option value="">{t('admin.channels.pickTargetChannel')}</option>
|
||||
{(channels ?? [])
|
||||
.filter((c) => c.id !== channelId)
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!copyToChannel || copyTemplateMutation.isPending}
|
||||
onClick={() => copyTemplateMutation.mutate(copyToChannel)}
|
||||
>
|
||||
{t('admin.channels.copy')}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.copyTemplateHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<TemplateIssues
|
||||
channelId={channelId}
|
||||
slotsById={
|
||||
new Map(template.layers.flatMap((l) => l.slots).map((slot) => [slot.id, slot]))
|
||||
}
|
||||
onGoToSlot={openSlot}
|
||||
/>
|
||||
<TemplatePreview channelId={channelId} />
|
||||
|
||||
{/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */}
|
||||
@@ -359,6 +425,8 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
|
||||
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
|
||||
|
||||
<ViewerCard channel={channel} onSaved={invalidate} onError={onError} />
|
||||
|
||||
{applicabilityLayer && (
|
||||
<LayerApplicabilityDialog
|
||||
layer={applicabilityLayer}
|
||||
@@ -368,7 +436,25 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<SchedulePreview entries={schedule ?? []} />
|
||||
<SchedulePreview entries={schedule ?? []} onShowTrace={setTraceEntryId} />
|
||||
|
||||
{applyOpen && (
|
||||
<ApplyDialog
|
||||
channelId={channelId}
|
||||
utcOffsetMinutes={channel.utcOffsetMinutes}
|
||||
pending={applyMutation.isPending}
|
||||
onApply={() => applyMutation.mutate()}
|
||||
onClose={() => setApplyOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{traceEntryId && (
|
||||
<EntryTraceDialog
|
||||
entryId={traceEntryId}
|
||||
utcOffsetMinutes={channel.utcOffsetMinutes}
|
||||
onClose={() => setTraceEntryId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,17 +6,22 @@ import type {
|
||||
BumperTrigger,
|
||||
ChannelDto,
|
||||
ChannelSummaryDto,
|
||||
CopyTemplateResultDto,
|
||||
CreatedIdResponse,
|
||||
EntryTraceDto,
|
||||
JunctionAmountMode,
|
||||
JunctionConditions,
|
||||
JunctionElementKind,
|
||||
JunctionTemplateDto,
|
||||
LayerApplicability,
|
||||
PlanningRules,
|
||||
ScheduleDiffDto,
|
||||
ScheduleEntryDto,
|
||||
SchedulePreviewDto,
|
||||
ScheduleTemplateDto,
|
||||
SlotDto,
|
||||
TemplateIssueDto,
|
||||
ViewerSettings,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
export function listChannels() {
|
||||
@@ -43,6 +48,11 @@ export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/settings`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
/** Оверлеи и аналоговый фильтр канала — как он выглядит у зрителя. */
|
||||
export function updateViewerSettings(id: string, body: ViewerSettings) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/viewer`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
/** Номер канала и его время: смещение от UTC и начало вещательных суток. */
|
||||
export function updateChannelTime(
|
||||
id: string,
|
||||
@@ -64,6 +74,29 @@ export function applyChannelTemplate(channelId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Проверки сетки по правилам — считаются по шаблону, без прогона генератора. */
|
||||
export function getTemplateIssues(channelId: string) {
|
||||
return apiRequest<TemplateIssueDto[]>(`/admin/channels/${channelId}/template/issues`)
|
||||
}
|
||||
|
||||
/** Что изменится в эфире, если применить сейчас. Прогон сухой — лента не трогается. */
|
||||
export function getApplyDiff(channelId: string) {
|
||||
return apiRequest<ScheduleDiffDto>(`/admin/channels/${channelId}/template/diff`)
|
||||
}
|
||||
|
||||
/** Копия сетки на другой канал: слои, слоты, стыки и правила. Группы общие и не копируются. */
|
||||
export function copyTemplateTo(channelId: string, targetChannelId: string) {
|
||||
return apiRequest<CopyTemplateResultDto>(
|
||||
`/admin/channels/${channelId}/template/copy-to/${targetChannelId}`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
}
|
||||
|
||||
/** Цепочка происхождения записи, записанная в момент генерации. */
|
||||
export function getEntryTrace(entryId: string) {
|
||||
return apiRequest<EntryTraceDto>(`/admin/channels/entries/${entryId}/trace`)
|
||||
}
|
||||
|
||||
/** Сухой прогон по текущим правилам: ничего не пишет и не двигает курсоры слотов. */
|
||||
export function previewTemplate(channelId: string, days: number) {
|
||||
const query = new URLSearchParams({ days: String(days) })
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { getApplyDiff } from '../api'
|
||||
import { formatChannelTime } from '../lib/format'
|
||||
|
||||
/**
|
||||
* Диф перед применением (см. 6.6). Изменения в ближайшие сутки подсвечены отдельно — это самая
|
||||
* частая причина случайного ущерба: у зрителя из-под носа уезжает то, что он уже видит в программе.
|
||||
*/
|
||||
export function ApplyDialog({
|
||||
channelId,
|
||||
utcOffsetMinutes,
|
||||
pending,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
channelId: string
|
||||
utcOffsetMinutes: number
|
||||
pending: boolean
|
||||
onApply: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'diff'],
|
||||
queryFn: () => getApplyDiff(channelId),
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.channels.apply')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isFetching || !data
|
||||
? t('common.loading')
|
||||
: t('admin.channels.diffSummary', { total: data.total, changed: data.changed })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{data && (
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
{data.changedSoon > 0 && (
|
||||
<p className="flex items-center gap-1.5 text-amber-500">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{t('admin.channels.diffSoon', { count: data.changedSoon })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data.changes.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t('admin.channels.diffNoChanges')}</p>
|
||||
) : (
|
||||
<ul className="max-h-80 divide-y divide-border overflow-y-auto text-xs">
|
||||
{data.changes.map((change, index) => (
|
||||
<li
|
||||
key={`${change.startsAtUtc}-${index}`}
|
||||
className={cn('flex items-center gap-2 py-1', change.soon && 'text-amber-500')}
|
||||
>
|
||||
<span className="w-24 shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatChannelTime(change.startsAtUtc, utcOffsetMinutes)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{change.before ?? '—'}</span>
|
||||
<span className="shrink-0 text-muted-foreground">→</span>
|
||||
<span className="min-w-0 flex-1 truncate">{change.after ?? '—'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{data.changed > data.changes.length && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.andMore', { count: data.changed - data.changes.length })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="outline" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" disabled={pending} onClick={onApply}>
|
||||
{t('admin.channels.apply')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { getEntryTrace } from '../api'
|
||||
import { formatChannelTime } from '../lib/format'
|
||||
|
||||
/**
|
||||
* «Почему это здесь» (см. 6.5): цепочка происхождения записи. Трейс пишется в момент генерации —
|
||||
* восстановить его потом нельзя, поэтому у старых записей часть строк будет пустой.
|
||||
*/
|
||||
export function EntryTraceDialog({
|
||||
entryId,
|
||||
utcOffsetMinutes,
|
||||
onClose,
|
||||
}: {
|
||||
entryId: string
|
||||
utcOffsetMinutes: number
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { data } = useQuery({
|
||||
queryKey: ['admin', 'entries', entryId, 'trace'],
|
||||
queryFn: () => getEntryTrace(entryId),
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{data
|
||||
? `${data.showName ?? '—'} · ${formatChannelTime(data.startsAtUtc, utcOffsetMinutes)}`
|
||||
: t('common.loading')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{data && (
|
||||
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1.5 text-sm">
|
||||
<Row label={t('admin.channels.traceLayer')}>
|
||||
{data.layerName
|
||||
? `${data.layerName}${data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''}`
|
||||
: null}
|
||||
</Row>
|
||||
<Row label={t('admin.channels.traceSlot')}>
|
||||
{data.slotTitle
|
||||
? [
|
||||
data.slotTitle,
|
||||
data.slotWeekday === null
|
||||
? t('admin.channels.everyDay')
|
||||
: t(`admin.channels.weekdays.${data.slotWeekday}`),
|
||||
data.slotTargetStart?.slice(0, 5),
|
||||
data.slotDurationMinutes ? `${data.slotDurationMinutes} мин` : null,
|
||||
data.driftMinutes !== 0
|
||||
? t('admin.channels.traceDrift', { minutes: data.driftMinutes })
|
||||
: null,
|
||||
data.snapped ? t('admin.channels.traceSnapped') : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: null}
|
||||
</Row>
|
||||
<Row label={t('admin.channels.traceGroup')}>
|
||||
{data.groupName
|
||||
? `${data.groupName}${data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''}`
|
||||
: null}
|
||||
</Row>
|
||||
<Row label={t('admin.channels.traceStrategy')}>
|
||||
{data.strategy
|
||||
? [
|
||||
t(`admin.channels.strategies.${data.strategy}`),
|
||||
data.cooldownDays
|
||||
? t('admin.channels.traceCooldown', { days: data.cooldownDays })
|
||||
: null,
|
||||
data.candidatesAfterCooldown !== null
|
||||
? t('admin.channels.traceCandidates', {
|
||||
count: data.candidatesAfterCooldown,
|
||||
})
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: null}
|
||||
</Row>
|
||||
<Row label={t('admin.channels.traceJunction')}>{data.junctionName}</Row>
|
||||
</dl>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd>{children || '—'}</dd>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,189 +1,240 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
SHOW_AUDIENCES,
|
||||
type AudienceWindow,
|
||||
type PlanningRules,
|
||||
type ScheduleTemplateDto,
|
||||
type ShowAudience,
|
||||
} from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { updateTemplate } from '../api'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'Teen' }
|
||||
|
||||
/**
|
||||
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
|
||||
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
|
||||
* воспроизводимость. Как и правка сетки, эфира сами по себе не двигают.
|
||||
*/
|
||||
export function RulesCard({
|
||||
template,
|
||||
onChanged,
|
||||
onError,
|
||||
}: {
|
||||
template: ScheduleTemplateDto
|
||||
onChanged: () => void
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [windows, setWindows] = useState<AudienceWindow[]>(
|
||||
() => template.rules?.maxAudienceByTime ?? [],
|
||||
)
|
||||
const [limitOn, setLimitOn] = useState(() => template.rules?.maxRepeatsInWindow != null)
|
||||
const [windowDays, setWindowDays] = useState(
|
||||
() => template.rules?.maxRepeatsInWindow?.windowDays ?? 7,
|
||||
)
|
||||
const [max, setMax] = useState(() => template.rules?.maxRepeatsInWindow?.max ?? 2)
|
||||
|
||||
useEffect(() => {
|
||||
setWindows(template.rules?.maxAudienceByTime ?? [])
|
||||
setLimitOn(template.rules?.maxRepeatsInWindow != null)
|
||||
setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7)
|
||||
setMax(template.rules?.maxRepeatsInWindow?.max ?? 2)
|
||||
}, [template])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const rules: PlanningRules = {
|
||||
maxAudienceByTime: windows.length > 0 ? windows : null,
|
||||
maxRepeatsInWindow: limitOn ? { windowDays, max } : null,
|
||||
}
|
||||
return updateTemplate(template.id, {
|
||||
name: template.name,
|
||||
fallbackGroupId: template.fallbackGroupId,
|
||||
defaultJunctionId: template.defaultJunctionId,
|
||||
rules,
|
||||
})
|
||||
},
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
const patchWindow = (index: number, part: Partial<AudienceWindow>) =>
|
||||
setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w)))
|
||||
|
||||
return (
|
||||
<CollapsibleCard title={t('admin.channels.rules')}>
|
||||
<div className="flex flex-col gap-4 text-sm">
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.rulesHint')}</p>
|
||||
|
||||
<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">
|
||||
{t('admin.channels.audienceWindows')}
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setWindows((c) => [...c, EMPTY_WINDOW])}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{windows.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.noAudienceWindows')}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{windows.map((window, index) => (
|
||||
<li key={index} className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.from')}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
className="w-28"
|
||||
value={window.from.slice(0, 5)}
|
||||
onChange={(e) => patchWindow(index, { from: `${e.target.value}:00` })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.to')}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
className="w-28"
|
||||
value={window.to.slice(0, 5)}
|
||||
onChange={(e) => patchWindow(index, { to: `${e.target.value}:00` })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.maxAudience')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={window.maxAudience}
|
||||
onChange={(e) =>
|
||||
patchWindow(index, { maxAudience: e.target.value as ShowAudience })
|
||||
}
|
||||
>
|
||||
{SHOW_AUDIENCES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.shows.audiences.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setWindows((c) => c.filter((_, i) => i !== index))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.audienceWindowsHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={limitOn}
|
||||
onChange={(e) => setLimitOn(e.target.checked)}
|
||||
/>
|
||||
{t('admin.channels.repeatLimit')}
|
||||
</label>
|
||||
{limitOn && (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatWindowDays')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
className="w-28"
|
||||
value={windowDays}
|
||||
onChange={(e) => setWindowDays(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatMax')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-28"
|
||||
value={max}
|
||||
onChange={(e) => setMax(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.repeatLimitHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
SHOW_AUDIENCES,
|
||||
type AudienceWindow,
|
||||
type PlanningRules,
|
||||
type ScheduleTemplateDto,
|
||||
type ShowAudience,
|
||||
} from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { updateTemplate } from '../api'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'Teen' }
|
||||
|
||||
/**
|
||||
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
|
||||
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
|
||||
* воспроизводимость. Как и правка сетки, эфира сами по себе не двигают.
|
||||
*/
|
||||
export function RulesCard({
|
||||
template,
|
||||
onChanged,
|
||||
onError,
|
||||
}: {
|
||||
template: ScheduleTemplateDto
|
||||
onChanged: () => void
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [windows, setWindows] = useState<AudienceWindow[]>(
|
||||
() => template.rules?.maxAudienceByTime ?? [],
|
||||
)
|
||||
const [limitOn, setLimitOn] = useState(() => template.rules?.maxRepeatsInWindow != null)
|
||||
const [windowDays, setWindowDays] = useState(
|
||||
() => template.rules?.maxRepeatsInWindow?.windowDays ?? 7,
|
||||
)
|
||||
const [max, setMax] = useState(() => template.rules?.maxRepeatsInWindow?.max ?? 2)
|
||||
const [breakCap, setBreakCap] = useState(() => template.rules?.maxBreakMinutesPerHour ?? 0)
|
||||
const [genreCap, setGenreCap] = useState(() => template.rules?.maxGenreSharePercent ?? 0)
|
||||
const [fallbackCap, setFallbackCap] = useState(() => template.rules?.maxFallbackSharePercent ?? 0)
|
||||
|
||||
useEffect(() => {
|
||||
setWindows(template.rules?.maxAudienceByTime ?? [])
|
||||
setLimitOn(template.rules?.maxRepeatsInWindow != null)
|
||||
setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7)
|
||||
setMax(template.rules?.maxRepeatsInWindow?.max ?? 2)
|
||||
setBreakCap(template.rules?.maxBreakMinutesPerHour ?? 0)
|
||||
setGenreCap(template.rules?.maxGenreSharePercent ?? 0)
|
||||
setFallbackCap(template.rules?.maxFallbackSharePercent ?? 0)
|
||||
}, [template])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const rules: PlanningRules = {
|
||||
maxAudienceByTime: windows.length > 0 ? windows : null,
|
||||
maxRepeatsInWindow: limitOn ? { windowDays, max } : null,
|
||||
// Ноль означает «не проверять»: отдельного выключателя на каждый порог не нужно.
|
||||
maxBreakMinutesPerHour: breakCap > 0 ? breakCap : null,
|
||||
maxGenreSharePercent: genreCap > 0 ? genreCap : null,
|
||||
maxFallbackSharePercent: fallbackCap > 0 ? fallbackCap : null,
|
||||
}
|
||||
return updateTemplate(template.id, {
|
||||
name: template.name,
|
||||
fallbackGroupId: template.fallbackGroupId,
|
||||
defaultJunctionId: template.defaultJunctionId,
|
||||
rules,
|
||||
})
|
||||
},
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
const patchWindow = (index: number, part: Partial<AudienceWindow>) =>
|
||||
setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w)))
|
||||
|
||||
return (
|
||||
<CollapsibleCard title={t('admin.channels.rules')}>
|
||||
<div className="flex flex-col gap-4 text-sm">
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.rulesHint')}</p>
|
||||
|
||||
<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">
|
||||
{t('admin.channels.audienceWindows')}
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setWindows((c) => [...c, EMPTY_WINDOW])}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{windows.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.noAudienceWindows')}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{windows.map((window, index) => (
|
||||
<li key={index} className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.from')}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
className="w-28"
|
||||
value={window.from.slice(0, 5)}
|
||||
onChange={(e) => patchWindow(index, { from: `${e.target.value}:00` })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.to')}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
className="w-28"
|
||||
value={window.to.slice(0, 5)}
|
||||
onChange={(e) => patchWindow(index, { to: `${e.target.value}:00` })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.maxAudience')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={window.maxAudience}
|
||||
onChange={(e) =>
|
||||
patchWindow(index, { maxAudience: e.target.value as ShowAudience })
|
||||
}
|
||||
>
|
||||
{SHOW_AUDIENCES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.shows.audiences.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setWindows((c) => c.filter((_, i) => i !== index))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.audienceWindowsHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={limitOn}
|
||||
onChange={(e) => setLimitOn(e.target.checked)}
|
||||
/>
|
||||
{t('admin.channels.repeatLimit')}
|
||||
</label>
|
||||
{limitOn && (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatWindowDays')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
className="w-28"
|
||||
value={windowDays}
|
||||
onChange={(e) => setWindowDays(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatMax')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-28"
|
||||
value={max}
|
||||
onChange={(e) => setMax(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.repeatLimitHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('admin.channels.postChecks')}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.breakLimit')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-28"
|
||||
value={breakCap}
|
||||
onChange={(e) => setBreakCap(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.genreShare')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
className="w-28"
|
||||
value={genreCap}
|
||||
onChange={(e) => setGenreCap(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.fallbackShare')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
className="w-28"
|
||||
value={fallbackCap}
|
||||
onChange={(e) => setFallbackCap(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.postChecksHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,52 +1,67 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ScheduleEntryDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { formatTime } from '../lib/format'
|
||||
|
||||
export function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
|
||||
const { t } = useTranslation()
|
||||
if (entries.length === 0)
|
||||
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col divide-y divide-border text-sm">
|
||||
{entries.slice(0, 40).map((e) => (
|
||||
<li key={e.id} className="flex items-center gap-3 py-1.5">
|
||||
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
||||
{formatTime(e.startsAtUtc)}
|
||||
</span>
|
||||
{e.kind === 'Ad' ? (
|
||||
<Badge variant="muted">{t('air.ad')}</Badge>
|
||||
) : e.kind === 'Bumper' ? (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="muted" className="shrink-0">
|
||||
{t('air.bumper')}
|
||||
</Badge>
|
||||
{(e.bumperName || e.bumperText) && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">
|
||||
{e.bumperName}
|
||||
{e.bumperName && e.bumperText ? ' · ' : ''}
|
||||
{e.bumperText}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{e.showName ?? '—'}
|
||||
{e.seasonEpisode ? (
|
||||
<span className="text-muted-foreground"> · {e.seasonEpisode}</span>
|
||||
) : (
|
||||
e.episodeIndex != null && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
· {t('air.episode')} {e.episodeIndex + 1}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ScheduleEntryDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { formatTime } from '../lib/format'
|
||||
|
||||
export function SchedulePreview({
|
||||
entries,
|
||||
onShowTrace,
|
||||
}: {
|
||||
entries: ScheduleEntryDto[]
|
||||
onShowTrace: (entryId: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (entries.length === 0)
|
||||
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col divide-y divide-border text-sm">
|
||||
{entries.slice(0, 40).map((e) => (
|
||||
<li key={e.id} className="group flex items-center gap-3 py-1.5">
|
||||
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
||||
{formatTime(e.startsAtUtc)}
|
||||
</span>
|
||||
{e.kind === 'Ad' ? (
|
||||
<Badge variant="muted">{t('air.ad')}</Badge>
|
||||
) : e.kind === 'Bumper' ? (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="muted" className="shrink-0">
|
||||
{t('air.bumper')}
|
||||
</Badge>
|
||||
{(e.bumperName || e.bumperText) && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">
|
||||
{e.bumperName}
|
||||
{e.bumperName && e.bumperText ? ' · ' : ''}
|
||||
{e.bumperText}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{e.showName ?? '—'}
|
||||
{e.seasonEpisode ? (
|
||||
<span className="text-muted-foreground"> · {e.seasonEpisode}</span>
|
||||
) : (
|
||||
e.episodeIndex != null && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
· {t('air.episode')} {e.episodeIndex + 1}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
title={t('admin.channels.whyHere')}
|
||||
className="ml-auto shrink-0 text-muted-foreground opacity-0 hover:text-foreground group-hover:opacity-100"
|
||||
onClick={() => onShowTrace(e.id)}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { AlertTriangle, CircleAlert } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SlotDto, TemplateIssueDto } from '@/shared/api/types'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { getTemplateIssues } from '../api'
|
||||
|
||||
/**
|
||||
* Проверки по правилам (см. 5.1). Считаются на сервере по шаблону, без прогона генератора, поэтому
|
||||
* показываются прямо в редакторе и обновляются вместе с сеткой.
|
||||
*/
|
||||
export function TemplateIssues({
|
||||
channelId,
|
||||
slotsById,
|
||||
onGoToSlot,
|
||||
}: {
|
||||
channelId: string
|
||||
slotsById: Map<string, SlotDto>
|
||||
onGoToSlot: (slot: SlotDto) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { data: issues } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'issues'],
|
||||
queryFn: () => getTemplateIssues(channelId),
|
||||
})
|
||||
|
||||
if (!issues || issues.length === 0) return null
|
||||
|
||||
const errors = issues.filter((i) => i.severity === 'Error')
|
||||
const warnings = issues.filter((i) => i.severity === 'Warning')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 rounded-md border border-border px-3 py-2 text-xs">
|
||||
<span className="font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('admin.channels.issues', { errors: errors.length, warnings: warnings.length })}
|
||||
</span>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{[...errors, ...warnings].map((issue, index) => (
|
||||
<IssueRow
|
||||
key={`${issue.kind}-${index}`}
|
||||
issue={issue}
|
||||
slot={issue.slotId ? slotsById.get(issue.slotId) : undefined}
|
||||
onGoToSlot={onGoToSlot}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IssueRow({
|
||||
issue,
|
||||
slot,
|
||||
onGoToSlot,
|
||||
}: {
|
||||
issue: TemplateIssueDto
|
||||
slot: SlotDto | undefined
|
||||
onGoToSlot: (slot: SlotDto) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const Icon = issue.severity === 'Error' ? CircleAlert : AlertTriangle
|
||||
|
||||
return (
|
||||
<li className="flex items-start gap-1.5">
|
||||
<Icon
|
||||
className={cn(
|
||||
'mt-0.5 h-3 w-3 shrink-0',
|
||||
issue.severity === 'Error' ? 'text-red-500' : 'text-amber-500',
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="text-muted-foreground">
|
||||
{t(`admin.channels.issueKinds.${issue.kind}`)}:{' '}
|
||||
</span>
|
||||
{issue.details}
|
||||
</span>
|
||||
{slot && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-primary hover:underline"
|
||||
onClick={() => onGoToSlot(slot)}
|
||||
>
|
||||
{t('admin.channels.goToSlot')}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -1,205 +1,327 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Eye } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { PlannedItemKind, PreviewItemDto, SchedulePreviewDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { previewTemplate } from '../api'
|
||||
import { channelTime, formatChannelTime } from '../lib/format'
|
||||
|
||||
const KIND_COLORS: Record<PlannedItemKind, string> = {
|
||||
Program: 'bg-primary/70',
|
||||
Fallback: 'bg-muted-foreground/40',
|
||||
SignOff: 'bg-slate-500/60',
|
||||
Ad: 'bg-amber-500/70',
|
||||
Promo: 'bg-sky-500/70',
|
||||
Bumper: 'bg-violet-500/70',
|
||||
}
|
||||
|
||||
/** Что видит зритель как программу — врезки в программу передач не попадают. */
|
||||
const PROGRAMME_KINDS: PlannedItemKind[] = ['Program', 'Fallback', 'SignOff']
|
||||
|
||||
/**
|
||||
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
|
||||
* курсоров. Заставки приходят резервом известной длины — реальный рендер только при применении.
|
||||
*/
|
||||
export function TemplatePreview({ channelId }: { channelId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [days, setDays] = useState(1)
|
||||
const [tab, setTab] = useState<'programme' | 'tape'>('programme')
|
||||
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'preview', days],
|
||||
queryFn: () => previewTemplate(channelId, days),
|
||||
enabled: open,
|
||||
// Черновик правил может меняться между открытиями — кэшировать прогон смысла нет.
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen((v) => !v)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
{open ? t('admin.channels.previewHide') : t('admin.channels.preview')}
|
||||
</Button>
|
||||
{open && (
|
||||
<>
|
||||
<select
|
||||
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
>
|
||||
{[1, 3, 7].map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t('admin.channels.previewDays', { count: value })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isFetching ? t('common.loading') : t('admin.channels.previewHint')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && data && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2 border-b border-border text-xs uppercase tracking-wide">
|
||||
{(['programme', 'tape'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTab(value)}
|
||||
className={cn(
|
||||
'pb-1 text-muted-foreground hover:text-foreground',
|
||||
tab === value && 'border-b-2 border-primary text-primary',
|
||||
)}
|
||||
>
|
||||
{t(`admin.channels.previewTabs.${value}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'programme' ? <Programme preview={data} /> : <Tape preview={data} />}
|
||||
|
||||
{data.warnings.length > 0 && (
|
||||
<ul className="flex flex-col gap-1 text-xs text-amber-500">
|
||||
{data.warnings.map((warning, index) => (
|
||||
<li key={`${warning.kind}-${index}`}>
|
||||
{t(`admin.channels.warnings.${warning.kind}`)}: {warning.details}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Programme({ preview }: { preview: SchedulePreviewDto }) {
|
||||
const { t } = useTranslation()
|
||||
const items = preview.items.filter((i) => PROGRAMME_KINDS.includes(i.kind))
|
||||
|
||||
if (items.length === 0)
|
||||
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col divide-y divide-border text-sm">
|
||||
{items.map((item, index) => (
|
||||
<li key={`${item.startsAtUtc}-${index}`} className="flex items-center gap-3 py-1.5">
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{item.title ?? t(`admin.channels.previewKinds.${item.kind}`)}
|
||||
</span>
|
||||
{item.slotTitle && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{item.slotTitle}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
/** Час вещания → сколько в нём минут врезок. По ним же строится гистограмма нагрузки. */
|
||||
function loadByHour(preview: SchedulePreviewDto): { hour: Date; minutes: number }[] {
|
||||
const buckets = new Map<number, number>()
|
||||
for (const item of preview.items) {
|
||||
if (item.kind !== 'Ad' && item.kind !== 'Promo' && item.kind !== 'Bumper') continue
|
||||
const start = channelTime(item.startsAtUtc, preview.utcOffsetMinutes)
|
||||
const hour = Date.UTC(
|
||||
start.getUTCFullYear(),
|
||||
start.getUTCMonth(),
|
||||
start.getUTCDate(),
|
||||
start.getUTCHours(),
|
||||
)
|
||||
const minutes = (new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
|
||||
buckets.set(hour, (buckets.get(hour) ?? 0) + minutes)
|
||||
}
|
||||
return [...buckets.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([hour, minutes]) => ({ hour: new Date(hour), minutes }))
|
||||
}
|
||||
|
||||
function Tape({ preview }: { preview: SchedulePreviewDto }) {
|
||||
const { t } = useTranslation()
|
||||
const load = useMemo(() => loadByHour(preview), [preview])
|
||||
const peak = Math.max(1, ...load.map((l) => l.minutes))
|
||||
|
||||
if (preview.items.length === 0)
|
||||
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{load.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.previewLoad', { peak: Math.round(peak) })}
|
||||
</span>
|
||||
<div className="flex h-16 items-end gap-px">
|
||||
{load.map((bucket) => (
|
||||
<div
|
||||
key={bucket.hour.toISOString()}
|
||||
className="flex-1 bg-amber-500/70"
|
||||
style={{ height: `${(bucket.minutes / peak) * 100}%` }}
|
||||
title={`${String(bucket.hour.getUTCHours()).padStart(2, '0')}:00 · ${Math.round(bucket.minutes)} ${t('admin.groups.minutesShort')}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="flex flex-col gap-0.5 text-xs">
|
||||
{preview.items.map((item, index) => (
|
||||
<TapeRow key={`${item.startsAtUtc}-${index}`} item={item} preview={preview} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TapeRow({ item, preview }: { item: PreviewItemDto; preview: SchedulePreviewDto }) {
|
||||
const { t } = useTranslation()
|
||||
const minutes =
|
||||
(new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-10 shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
|
||||
</span>
|
||||
<span className={cn('h-2 shrink-0 rounded-sm', KIND_COLORS[item.kind])} style={{ width: `${Math.max(4, minutes * 2)}px` }} />
|
||||
<Badge variant="muted" className="shrink-0">
|
||||
{t(`admin.channels.previewKinds.${item.kind}`)}
|
||||
</Badge>
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">{item.title ?? ''}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Eye } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { PlannedItemKind, PreviewItemDto, SchedulePreviewDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { previewTemplate } from '../api'
|
||||
import { channelTime, formatChannelTime } from '../lib/format'
|
||||
import { toIsoDate } from '../lib/applicability'
|
||||
|
||||
const KIND_COLORS: Record<PlannedItemKind, string> = {
|
||||
Program: 'bg-primary/70',
|
||||
Fallback: 'bg-muted-foreground/40',
|
||||
SignOff: 'bg-slate-500/60',
|
||||
Ad: 'bg-amber-500/70',
|
||||
Promo: 'bg-sky-500/70',
|
||||
Bumper: 'bg-violet-500/70',
|
||||
}
|
||||
|
||||
/** Что видит зритель как программу — врезки в программу передач не попадают. */
|
||||
const PROGRAMME_KINDS: PlannedItemKind[] = ['Program', 'Fallback', 'SignOff']
|
||||
|
||||
/**
|
||||
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
|
||||
* курсоров. Заставки приходят резервом известной длины — реальный рендер только при применении.
|
||||
*/
|
||||
export function TemplatePreview({ channelId }: { channelId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [days, setDays] = useState(1)
|
||||
const [tab, setTab] = useState<'programme' | 'tape' | 'problems'>('programme')
|
||||
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'preview', days],
|
||||
queryFn: () => previewTemplate(channelId, days),
|
||||
enabled: open,
|
||||
// Черновик правил может меняться между открытиями — кэшировать прогон смысла нет.
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen((v) => !v)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
{open ? t('admin.channels.previewHide') : t('admin.channels.preview')}
|
||||
</Button>
|
||||
{open && (
|
||||
<>
|
||||
<select
|
||||
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
>
|
||||
{[1, 3, 7].map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t('admin.channels.previewDays', { count: value })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isFetching ? t('common.loading') : t('admin.channels.previewHint')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && data && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2 border-b border-border text-xs uppercase tracking-wide">
|
||||
{(['programme', 'tape', 'problems'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTab(value)}
|
||||
className={cn(
|
||||
'pb-1 text-muted-foreground hover:text-foreground',
|
||||
tab === value && 'border-b-2 border-primary text-primary',
|
||||
)}
|
||||
>
|
||||
{t(`admin.channels.previewTabs.${value}`)}
|
||||
{value === 'problems' && data.warnings.length > 0 && ` · ${data.warnings.length}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'programme' && <Programme preview={data} />}
|
||||
{tab === 'tape' && <Tape preview={data} />}
|
||||
{tab === 'problems' && <Problems preview={data} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Programme({ preview }: { preview: SchedulePreviewDto }) {
|
||||
const { t } = useTranslation()
|
||||
const items = preview.items.filter((i) => PROGRAMME_KINDS.includes(i.kind))
|
||||
|
||||
if (items.length === 0)
|
||||
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col divide-y divide-border text-sm">
|
||||
{items.map((item, index) => (
|
||||
<li key={`${item.startsAtUtc}-${index}`} className="flex items-center gap-3 py-1.5">
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{item.title ?? t(`admin.channels.previewKinds.${item.kind}`)}
|
||||
</span>
|
||||
{item.slotTitle && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{item.slotTitle}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
/** Час вещания → сколько в нём минут врезок. По ним же строится гистограмма нагрузки. */
|
||||
function loadByHour(preview: SchedulePreviewDto): { hour: Date; minutes: number }[] {
|
||||
const buckets = new Map<number, number>()
|
||||
for (const item of preview.items) {
|
||||
if (item.kind !== 'Ad' && item.kind !== 'Promo' && item.kind !== 'Bumper') continue
|
||||
const start = channelTime(item.startsAtUtc, preview.utcOffsetMinutes)
|
||||
const hour = Date.UTC(
|
||||
start.getUTCFullYear(),
|
||||
start.getUTCMonth(),
|
||||
start.getUTCDate(),
|
||||
start.getUTCHours(),
|
||||
)
|
||||
const minutes = (new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
|
||||
buckets.set(hour, (buckets.get(hour) ?? 0) + minutes)
|
||||
}
|
||||
return [...buckets.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([hour, minutes]) => ({ hour: new Date(hour), minutes }))
|
||||
}
|
||||
|
||||
function Tape({ preview }: { preview: SchedulePreviewDto }) {
|
||||
const { t } = useTranslation()
|
||||
const load = useMemo(() => loadByHour(preview), [preview])
|
||||
const peak = Math.max(1, ...load.map((l) => l.minutes))
|
||||
|
||||
if (preview.items.length === 0)
|
||||
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{load.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.previewLoad', { peak: Math.round(peak) })}
|
||||
</span>
|
||||
<div className="flex h-16 items-end gap-px">
|
||||
{load.map((bucket) => (
|
||||
<div
|
||||
key={bucket.hour.toISOString()}
|
||||
className="flex-1 bg-amber-500/70"
|
||||
style={{ height: `${(bucket.minutes / peak) * 100}%` }}
|
||||
title={`${String(bucket.hour.getUTCHours()).padStart(2, '0')}:00 · ${Math.round(bucket.minutes)} ${t('admin.groups.minutesShort')}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="flex flex-col gap-0.5 text-xs">
|
||||
{preview.items.map((item, index) => (
|
||||
<TapeRow key={`${item.startsAtUtc}-${index}`} item={item} preview={preview} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TapeRow({ item, preview }: { item: PreviewItemDto; preview: SchedulePreviewDto }) {
|
||||
const { t } = useTranslation()
|
||||
const minutes =
|
||||
(new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-10 shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
|
||||
</span>
|
||||
<span className={cn('h-2 shrink-0 rounded-sm', KIND_COLORS[item.kind])} style={{ width: `${Math.max(4, minutes * 2)}px` }} />
|
||||
<Badge variant="muted" className="shrink-0">
|
||||
{t(`admin.channels.previewKinds.${item.kind}`)}
|
||||
</Badge>
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">{item.title ?? ''}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** Предупреждения, сгруппированные по виду: десять однотипных строк читаются как одна проблема. */
|
||||
function Problems({ preview }: { preview: SchedulePreviewDto }) {
|
||||
const { t } = useTranslation()
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, string[]>()
|
||||
for (const warning of preview.warnings) {
|
||||
const list = map.get(warning.kind) ?? []
|
||||
list.push(warning.details)
|
||||
map.set(warning.kind, list)
|
||||
}
|
||||
return [...map.entries()]
|
||||
}, [preview])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{grouped.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('admin.channels.noProblems')}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2 text-xs">
|
||||
{grouped.map(([kind, details]) => (
|
||||
<li key={kind} className="flex flex-col gap-0.5">
|
||||
<span className="font-medium text-amber-500">
|
||||
{t(`admin.channels.warnings.${kind}`)} · {details.length}
|
||||
</span>
|
||||
{details.slice(0, 20).map((detail, index) => (
|
||||
<span key={index} className="text-muted-foreground">
|
||||
{detail}
|
||||
</span>
|
||||
))}
|
||||
{details.length > 20 && (
|
||||
<span className="text-muted-foreground">
|
||||
{t('admin.channels.andMore', { count: details.length - 20 })}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<RepeatHeatmap preview={preview} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Тепловая карта повторов: матрица «шоу × вещательные сутки», яркость — число показов. Сразу видно,
|
||||
* что один фильм крутится четыре раза за неделю.
|
||||
*/
|
||||
function RepeatHeatmap({ preview }: { preview: SchedulePreviewDto }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { days, rows } = useMemo(() => {
|
||||
const counts = new Map<string, Map<string, number>>()
|
||||
const dayKeys = new Set<string>()
|
||||
|
||||
for (const item of preview.items) {
|
||||
if (item.kind !== 'Program' || !item.title) continue
|
||||
const day = toIsoDate(channelTime(item.startsAtUtc, preview.utcOffsetMinutes))
|
||||
dayKeys.add(day)
|
||||
const row = counts.get(item.title) ?? new Map<string, number>()
|
||||
row.set(day, (row.get(day) ?? 0) + 1)
|
||||
counts.set(item.title, row)
|
||||
}
|
||||
|
||||
const sortedDays = [...dayKeys].sort()
|
||||
const sortedRows = [...counts.entries()]
|
||||
.map(([title, byDay]) => ({
|
||||
title,
|
||||
byDay,
|
||||
total: [...byDay.values()].reduce((sum, n) => sum + n, 0),
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 25)
|
||||
|
||||
return { days: sortedDays, rows: sortedRows }
|
||||
}, [preview])
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const peak = Math.max(1, ...rows.flatMap((row) => [...row.byDay.values()]))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('admin.channels.heatmap')}
|
||||
</span>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="text-[11px]">
|
||||
<thead className="text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-1 text-left font-medium" />
|
||||
{days.map((day) => (
|
||||
<th key={day} className="px-1 font-medium">
|
||||
{day.slice(8)}.{day.slice(5, 7)}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-1 font-medium">{t('admin.channels.heatmapTotal')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.title}>
|
||||
<td className="max-w-56 truncate px-1" title={row.title}>
|
||||
{row.title}
|
||||
</td>
|
||||
{days.map((day) => {
|
||||
const count = row.byDay.get(day) ?? 0
|
||||
return (
|
||||
<td key={day} className="px-0.5 py-0.5">
|
||||
<span
|
||||
className="block h-4 w-6 rounded-sm bg-primary text-center text-[10px] leading-4"
|
||||
style={{ opacity: count === 0 ? 0.06 : 0.25 + (count / peak) * 0.75 }}
|
||||
>
|
||||
{count > 0 ? count : ''}
|
||||
</span>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
<td className="px-1 tabular-nums text-muted-foreground">{row.total}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,85 +1,108 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { getSiteSettings, updateSiteSettings } from './api'
|
||||
|
||||
export function SettingsPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [registrationEnabled, setRegistrationEnabled] = useState(false)
|
||||
const [preferredAudioLanguages, setPreferredAudioLanguages] = useState('')
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'settings'],
|
||||
queryFn: getSiteSettings,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setRegistrationEnabled(data.registrationEnabled)
|
||||
setPreferredAudioLanguages(data.preferredAudioLanguages)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => updateSiteSettings({ registrationEnabled, preferredAudioLanguages }),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
|
||||
},
|
||||
onError: (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.settings.title')}</h2>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.settings.registration')}</CardTitle>
|
||||
<CardDescription>{t('admin.settings.registrationHint')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={registrationEnabled}
|
||||
disabled={isLoading}
|
||||
onChange={(e) => setRegistrationEnabled(e.target.checked)}
|
||||
/>
|
||||
{t('admin.settings.registrationLabel')}
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="preferred-audio">
|
||||
{t('admin.settings.preferredAudio')}
|
||||
</label>
|
||||
<Input
|
||||
id="preferred-audio"
|
||||
className="max-w-xs"
|
||||
placeholder="rus, eng"
|
||||
value={preferredAudioLanguages}
|
||||
disabled={isLoading}
|
||||
onChange={(e) => setPreferredAudioLanguages(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.settings.preferredAudioHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button size="sm" disabled={isLoading || save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { getSiteSettings, updateSiteSettings } from './api'
|
||||
|
||||
export function SettingsPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [registrationEnabled, setRegistrationEnabled] = useState(false)
|
||||
const [preferredAudioLanguages, setPreferredAudioLanguages] = useState('')
|
||||
const [channelNumbersEnabled, setChannelNumbersEnabled] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'settings'],
|
||||
queryFn: getSiteSettings,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setRegistrationEnabled(data.registrationEnabled)
|
||||
setPreferredAudioLanguages(data.preferredAudioLanguages)
|
||||
setChannelNumbersEnabled(data.channelNumbersEnabled)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateSiteSettings({
|
||||
registrationEnabled,
|
||||
preferredAudioLanguages,
|
||||
channelNumbersEnabled,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
|
||||
},
|
||||
onError: (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.settings.title')}</h2>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.settings.registration')}</CardTitle>
|
||||
<CardDescription>{t('admin.settings.registrationHint')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={registrationEnabled}
|
||||
disabled={isLoading}
|
||||
onChange={(e) => setRegistrationEnabled(e.target.checked)}
|
||||
/>
|
||||
{t('admin.settings.registrationLabel')}
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="preferred-audio">
|
||||
{t('admin.settings.preferredAudio')}
|
||||
</label>
|
||||
<Input
|
||||
id="preferred-audio"
|
||||
className="max-w-xs"
|
||||
placeholder="rus, eng"
|
||||
value={preferredAudioLanguages}
|
||||
disabled={isLoading}
|
||||
onChange={(e) => setPreferredAudioLanguages(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.settings.preferredAudioHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={channelNumbersEnabled}
|
||||
disabled={isLoading}
|
||||
onChange={(e) => setChannelNumbersEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t('admin.settings.channelNumbers')}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('admin.settings.channelNumbersHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<Button size="sm" disabled={isLoading || save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,261 +1,325 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Radio, RotateCw } from 'lucide-react'
|
||||
import type { PublicEpgEntryDto } from '@/shared/api/types'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { ChannelPlayer } from './ChannelPlayer'
|
||||
import { getEpg, imageUrl, listChannels, watchChannel } from './api'
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export function AirPage() {
|
||||
const { t } = useTranslation()
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [watchReady, setWatchReady] = useState(false)
|
||||
const [playerError, setPlayerError] = useState(false)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
|
||||
const handleUnavailable = useCallback(() => setPlayerError(true), [])
|
||||
const retry = () => {
|
||||
setPlayerError(false)
|
||||
setAttempt((a) => a + 1)
|
||||
}
|
||||
|
||||
const { data: channels, isLoading } = useQuery({
|
||||
queryKey: ['air', 'channels'],
|
||||
queryFn: listChannels,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected && channels && channels.length > 0) setSelected(channels[0].slug)
|
||||
}, [channels, selected])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return
|
||||
setWatchReady(false)
|
||||
setPlayerError(false)
|
||||
let cancelled = false
|
||||
void watchChannel(selected)
|
||||
.then(() => {
|
||||
if (!cancelled) setWatchReady(true)
|
||||
})
|
||||
.catch(() => {
|
||||
// Выдача stream-cookie не удалась (403/500/сеть) — показываем offline-панель с кнопкой ретрая,
|
||||
// а не бесконечный скелетон. Ретрай (attempt) заново дёрнет watchChannel.
|
||||
if (!cancelled) {
|
||||
setWatchReady(true)
|
||||
setPlayerError(true)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selected, attempt])
|
||||
|
||||
// Stream-cookie короткоживущий (TTL на сервере ~30 мин) — периодически перевыпускаем, пока смотрим,
|
||||
// иначе плейлист/сегменты начнут отдавать 401 посреди эфира. Тихо: ошибку словит перезагрузка плейлиста.
|
||||
useEffect(() => {
|
||||
if (!selected || playerError) return
|
||||
const id = window.setInterval(
|
||||
() => {
|
||||
void watchChannel(selected).catch(() => undefined)
|
||||
},
|
||||
20 * 60_000,
|
||||
)
|
||||
return () => window.clearInterval(id)
|
||||
}, [selected, playerError])
|
||||
|
||||
const { data: epg } = useQuery({
|
||||
queryKey: ['air', 'epg', selected],
|
||||
queryFn: () =>
|
||||
getEpg(
|
||||
selected!,
|
||||
new Date(Date.now() - 30 * 60_000),
|
||||
new Date(Date.now() + 3 * 60 * 60_000),
|
||||
),
|
||||
enabled: !!selected,
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
const { current, upcoming, currentEntry } = useMemo(() => buildGuide(epg ?? []), [epg])
|
||||
|
||||
if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
|
||||
if (!channels || channels.length === 0)
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
|
||||
<p className="text-muted-foreground">{t('air.noChannels')}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
|
||||
<aside className="flex gap-2 overflow-x-auto md:flex-col md:overflow-visible">
|
||||
{channels.map((channel) => (
|
||||
<button
|
||||
key={channel.id}
|
||||
onClick={() => setSelected(channel.slug)}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-2 rounded-sm border border-border px-3 py-2 text-left text-sm hover:bg-muted md:shrink',
|
||||
selected === channel.slug && 'border-primary text-primary',
|
||||
)}
|
||||
>
|
||||
{channel.currentShowPosterImageId ? (
|
||||
<img
|
||||
src={imageUrl(channel.currentShowPosterImageId)}
|
||||
alt=""
|
||||
className="h-10 w-7 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Radio className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{channel.name}</span>
|
||||
{channel.currentShowName && (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{channel.currentShowName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{selected && watchReady ? (
|
||||
playerError ? (
|
||||
<div className="crt-panel flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-md text-center">
|
||||
<Radio className="h-10 w-10 text-muted-foreground" strokeWidth={1} />
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium">{t('air.offline')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('air.offlineHint')}</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={retry}>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
{t('air.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ChannelPlayer
|
||||
key={`${selected}-${attempt}`}
|
||||
slug={selected}
|
||||
onUnavailable={handleUnavailable}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{current && (
|
||||
<div className="crt-panel flex gap-3 rounded-md p-3">
|
||||
{currentEntry?.episodeStillImageId ? (
|
||||
<img
|
||||
src={imageUrl(currentEntry.episodeStillImageId)}
|
||||
alt=""
|
||||
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
|
||||
/>
|
||||
) : currentEntry?.showPosterImageId ? (
|
||||
<img
|
||||
src={imageUrl(currentEntry.showPosterImageId)}
|
||||
alt=""
|
||||
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge>{t('air.now')}</Badge>
|
||||
<span className="font-medium">{current.showName}</span>
|
||||
</div>
|
||||
{currentEntry?.episodeTitle && (
|
||||
<span className="text-sm">{currentEntry.episodeTitle}</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTime(current.startsAtUtc)} – {formatTime(current.endsAtUtc)}
|
||||
</span>
|
||||
{currentEntry?.episodeOverview && (
|
||||
<p className="line-clamp-3 text-xs text-muted-foreground">
|
||||
{currentEntry.episodeOverview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{upcoming.length > 0 && (
|
||||
<div className="crt-panel rounded-md">
|
||||
<div className="border-b border-border px-4 py-2 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{t('air.next')}
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{upcoming.slice(0, 6).map((block) => (
|
||||
<li key={block.key} className="flex items-center gap-3 px-4 py-2 text-sm">
|
||||
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
||||
{formatTime(block.startsAtUtc)} – {formatTime(block.endsAtUtc)}
|
||||
</span>
|
||||
<span>{block.showName}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type GuideBlock = {
|
||||
key: string
|
||||
showId: string | null
|
||||
showName: string
|
||||
startsAtUtc: string
|
||||
endsAtUtc: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит телегид: рекламу/заставки не показываем, а подряд идущие серии одного шоу склеиваем в один
|
||||
* блок с диапазоном «с – по». Отдельно возвращаем текущую серию (для метаданных карточки «сейчас»).
|
||||
*/
|
||||
function buildGuide(entries: PublicEpgEntryDto[]): {
|
||||
current?: GuideBlock
|
||||
upcoming: GuideBlock[]
|
||||
currentEntry?: PublicEpgEntryDto
|
||||
} {
|
||||
const blocks: GuideBlock[] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.kind !== 'Program') continue
|
||||
const last = blocks[blocks.length - 1]
|
||||
if (last && last.showId === entry.showId) {
|
||||
last.endsAtUtc = entry.endsAtUtc
|
||||
} else {
|
||||
blocks.push({
|
||||
key: entry.startsAtUtc,
|
||||
showId: entry.showId,
|
||||
showName: entry.showName ?? '—',
|
||||
startsAtUtc: entry.startsAtUtc,
|
||||
endsAtUtc: entry.endsAtUtc,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const active = (start: string, end: string) =>
|
||||
new Date(start).getTime() <= now && new Date(end).getTime() > now
|
||||
const current = blocks.find((b) => active(b.startsAtUtc, b.endsAtUtc))
|
||||
const upcoming = blocks.filter((b) => new Date(b.startsAtUtc).getTime() > now)
|
||||
const currentEntry = entries.find(
|
||||
(e) => e.kind === 'Program' && active(e.startsAtUtc, e.endsAtUtc),
|
||||
)
|
||||
return { current, upcoming, currentEntry }
|
||||
}
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Radio, RotateCw } from 'lucide-react'
|
||||
import type { PublicEpgEntryDto } from '@/shared/api/types'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { ChannelPlayer } from './ChannelPlayer'
|
||||
import { getEpg, getViewerFeatures, imageUrl, listChannels, watchChannel } from './api'
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export function AirPage() {
|
||||
const { t } = useTranslation()
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [watchReady, setWatchReady] = useState(false)
|
||||
const [playerError, setPlayerError] = useState(false)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const [flash, setFlash] = useState(false)
|
||||
|
||||
const handleUnavailable = useCallback(() => setPlayerError(true), [])
|
||||
const retry = () => {
|
||||
setPlayerError(false)
|
||||
setAttempt((a) => a + 1)
|
||||
}
|
||||
|
||||
const { data: channels, isLoading } = useQuery({
|
||||
queryKey: ['air', 'channels'],
|
||||
queryFn: listChannels,
|
||||
})
|
||||
const { data: features } = useQuery({ queryKey: ['air', 'features'], queryFn: getViewerFeatures })
|
||||
|
||||
const numbersEnabled = features?.channelNumbersEnabled ?? false
|
||||
const currentChannel = channels?.find((c) => c.slug === selected)
|
||||
|
||||
/**
|
||||
* Переключение по номерам: список уже отсортирован сервером, поэтому «вверх-вниз» — это шаг
|
||||
* по нему. Короткий чёрный кадр с номером ставится сразу, до готовности потока.
|
||||
*/
|
||||
const step = useCallback(
|
||||
(delta: number) => {
|
||||
if (!channels || channels.length === 0) return
|
||||
const index = channels.findIndex((c) => c.slug === selected)
|
||||
const next = channels[(index + delta + channels.length) % channels.length]
|
||||
if (!next || next.slug === selected) return
|
||||
setFlash(true)
|
||||
setSelected(next.slug)
|
||||
},
|
||||
[channels, selected],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!flash) return
|
||||
const id = window.setTimeout(() => setFlash(false), 900)
|
||||
return () => window.clearTimeout(id)
|
||||
}, [flash, selected])
|
||||
|
||||
useEffect(() => {
|
||||
if (!numbersEnabled) return
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
// Не перехватываем стрелки, пока фокус в поле ввода: там они двигают каретку.
|
||||
const target = event.target as HTMLElement | null
|
||||
if (target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return
|
||||
if (event.key === 'ArrowUp' || event.key === 'PageUp') {
|
||||
event.preventDefault()
|
||||
step(-1)
|
||||
} else if (event.key === 'ArrowDown' || event.key === 'PageDown') {
|
||||
event.preventDefault()
|
||||
step(1)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [numbersEnabled, step])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected && channels && channels.length > 0) setSelected(channels[0].slug)
|
||||
}, [channels, selected])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return
|
||||
setWatchReady(false)
|
||||
setPlayerError(false)
|
||||
let cancelled = false
|
||||
void watchChannel(selected)
|
||||
.then(() => {
|
||||
if (!cancelled) setWatchReady(true)
|
||||
})
|
||||
.catch(() => {
|
||||
// Выдача stream-cookie не удалась (403/500/сеть) — показываем offline-панель с кнопкой ретрая,
|
||||
// а не бесконечный скелетон. Ретрай (attempt) заново дёрнет watchChannel.
|
||||
if (!cancelled) {
|
||||
setWatchReady(true)
|
||||
setPlayerError(true)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selected, attempt])
|
||||
|
||||
// Stream-cookie короткоживущий (TTL на сервере ~30 мин) — периодически перевыпускаем, пока смотрим,
|
||||
// иначе плейлист/сегменты начнут отдавать 401 посреди эфира. Тихо: ошибку словит перезагрузка плейлиста.
|
||||
useEffect(() => {
|
||||
if (!selected || playerError) return
|
||||
const id = window.setInterval(
|
||||
() => {
|
||||
void watchChannel(selected).catch(() => undefined)
|
||||
},
|
||||
20 * 60_000,
|
||||
)
|
||||
return () => window.clearInterval(id)
|
||||
}, [selected, playerError])
|
||||
|
||||
const { data: epg } = useQuery({
|
||||
queryKey: ['air', 'epg', selected],
|
||||
queryFn: () =>
|
||||
getEpg(
|
||||
selected!,
|
||||
new Date(Date.now() - 30 * 60_000),
|
||||
new Date(Date.now() + 3 * 60 * 60_000),
|
||||
),
|
||||
enabled: !!selected,
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
const { current, upcoming, currentEntry } = useMemo(() => buildGuide(epg ?? []), [epg])
|
||||
|
||||
// Плашка «Далее» — только на исходе программы: висеть весь эфир ей незачем.
|
||||
const nextUp =
|
||||
current && upcoming.length > 0 && new Date(current.endsAtUtc).getTime() - Date.now() < 60_000
|
||||
? upcoming[0].showName
|
||||
: null
|
||||
|
||||
if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
|
||||
if (!channels || channels.length === 0)
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
|
||||
<p className="text-muted-foreground">{t('air.noChannels')}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
|
||||
{numbersEnabled && (
|
||||
<span className="text-xs text-muted-foreground">{t('air.numbersHint')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
|
||||
<aside className="flex gap-2 overflow-x-auto md:flex-col md:overflow-visible">
|
||||
{channels.map((channel) => (
|
||||
<button
|
||||
key={channel.id}
|
||||
onClick={() => setSelected(channel.slug)}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-2 rounded-sm border border-border px-3 py-2 text-left text-sm hover:bg-muted md:shrink',
|
||||
selected === channel.slug && 'border-primary text-primary',
|
||||
)}
|
||||
>
|
||||
{numbersEnabled && channel.number !== null && (
|
||||
<span className="w-6 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
|
||||
{channel.number}
|
||||
</span>
|
||||
)}
|
||||
{channel.currentShowPosterImageId ? (
|
||||
<img
|
||||
src={imageUrl(channel.currentShowPosterImageId)}
|
||||
alt=""
|
||||
className="h-10 w-7 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Radio className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{channel.name}</span>
|
||||
{channel.currentShowName && (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{channel.currentShowName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{selected && watchReady ? (
|
||||
playerError ? (
|
||||
<div className="crt-panel flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-md text-center">
|
||||
<Radio className="h-10 w-10 text-muted-foreground" strokeWidth={1} />
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium">{t('air.offline')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('air.offlineHint')}</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={retry}>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
{t('air.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ChannelPlayer
|
||||
key={`${selected}-${attempt}`}
|
||||
slug={selected}
|
||||
channel={currentChannel}
|
||||
nextUp={nextUp}
|
||||
flash={flash}
|
||||
onUnavailable={handleUnavailable}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{current && (
|
||||
<div className="crt-panel flex gap-3 rounded-md p-3">
|
||||
{currentEntry?.episodeStillImageId ? (
|
||||
<img
|
||||
src={imageUrl(currentEntry.episodeStillImageId)}
|
||||
alt=""
|
||||
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
|
||||
/>
|
||||
) : currentEntry?.showPosterImageId ? (
|
||||
<img
|
||||
src={imageUrl(currentEntry.showPosterImageId)}
|
||||
alt=""
|
||||
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge>{t('air.now')}</Badge>
|
||||
<span className="font-medium">{current.showName}</span>
|
||||
</div>
|
||||
{currentEntry?.episodeTitle && (
|
||||
<span className="text-sm">{currentEntry.episodeTitle}</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTime(current.startsAtUtc)} – {formatTime(current.endsAtUtc)}
|
||||
</span>
|
||||
{currentEntry?.episodeOverview && (
|
||||
<p className="line-clamp-3 text-xs text-muted-foreground">
|
||||
{currentEntry.episodeOverview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{upcoming.length > 0 && (
|
||||
<div className="crt-panel rounded-md">
|
||||
<div className="border-b border-border px-4 py-2 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{t('air.next')}
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{upcoming.slice(0, 6).map((block) => (
|
||||
<li key={block.key} className="flex items-center gap-3 px-4 py-2 text-sm">
|
||||
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
||||
{formatTime(block.startsAtUtc)} – {formatTime(block.endsAtUtc)}
|
||||
</span>
|
||||
<span>{block.showName}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type GuideBlock = {
|
||||
key: string
|
||||
showId: string | null
|
||||
showName: string
|
||||
startsAtUtc: string
|
||||
endsAtUtc: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит телегид: рекламу/заставки не показываем, а подряд идущие серии одного шоу склеиваем в один
|
||||
* блок с диапазоном «с – по». Отдельно возвращаем текущую серию (для метаданных карточки «сейчас»).
|
||||
*/
|
||||
function buildGuide(entries: PublicEpgEntryDto[]): {
|
||||
current?: GuideBlock
|
||||
upcoming: GuideBlock[]
|
||||
currentEntry?: PublicEpgEntryDto
|
||||
} {
|
||||
const blocks: GuideBlock[] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.kind !== 'Program') continue
|
||||
const last = blocks[blocks.length - 1]
|
||||
if (last && last.showId === entry.showId) {
|
||||
last.endsAtUtc = entry.endsAtUtc
|
||||
} else {
|
||||
blocks.push({
|
||||
key: entry.startsAtUtc,
|
||||
showId: entry.showId,
|
||||
showName: entry.showName ?? '—',
|
||||
startsAtUtc: entry.startsAtUtc,
|
||||
endsAtUtc: entry.endsAtUtc,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const active = (start: string, end: string) =>
|
||||
new Date(start).getTime() <= now && new Date(end).getTime() > now
|
||||
const current = blocks.find((b) => active(b.startsAtUtc, b.endsAtUtc))
|
||||
const upcoming = blocks.filter((b) => new Date(b.startsAtUtc).getTime() > now)
|
||||
const currentEntry = entries.find(
|
||||
(e) => e.kind === 'Program' && active(e.startsAtUtc, e.endsAtUtc),
|
||||
)
|
||||
return { current, upcoming, currentEntry }
|
||||
}
|
||||
|
||||
@@ -1,241 +1,279 @@
|
||||
import Hls from 'hls.js'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Maximize, Volume2, VolumeX } from 'lucide-react'
|
||||
|
||||
const STORAGE_KEY = 'tw:player'
|
||||
|
||||
/** Читает сохранённые громкость/mute из localStorage (с валидацией и дефолтами). */
|
||||
function readStoredAudio(): { volume: number; muted: boolean } {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as { volume?: unknown; muted?: unknown }
|
||||
const volume =
|
||||
typeof parsed.volume === 'number' ? Math.min(1, Math.max(0, parsed.volume)) : 1
|
||||
const muted = typeof parsed.muted === 'boolean' ? parsed.muted : true
|
||||
return { volume, muted }
|
||||
}
|
||||
} catch {
|
||||
/* недоступен/битый localStorage — дефолты */
|
||||
}
|
||||
return { volume: 1, muted: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* HLS-плеер линейного канала. Это живой эфир: ни перемотки, ни паузы — только звук, громкость и
|
||||
* полноэкранный режим. Cookie tw_stream уже выдана к монтированию.
|
||||
*/
|
||||
export function ChannelPlayer({
|
||||
slug,
|
||||
onUnavailable,
|
||||
}: {
|
||||
slug: string
|
||||
onUnavailable?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const hlsRef = useRef<Hls | null>(null)
|
||||
const [muted, setMuted] = useState(() => readStoredAudio().muted)
|
||||
const [volume, setVolume] = useState(() => readStoredAudio().volume)
|
||||
|
||||
// Последние настройки звука без пересоздания HLS-эффекта + сохранение в localStorage.
|
||||
const audioRef = useRef({ volume, muted })
|
||||
useEffect(() => {
|
||||
audioRef.current = { volume, muted }
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ volume, muted }))
|
||||
} catch {
|
||||
/* localStorage недоступен — не критично */
|
||||
}
|
||||
}, [volume, muted])
|
||||
const [controlsVisible, setControlsVisible] = useState(true)
|
||||
const hideTimerRef = useRef<number | null>(null)
|
||||
const overControlsRef = useRef(false)
|
||||
|
||||
const clearHideTimer = useCallback(() => {
|
||||
if (hideTimerRef.current !== null) {
|
||||
window.clearTimeout(hideTimerRef.current)
|
||||
hideTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Прячем панель и курсор после бездействия — но не когда курсор на панели или видео на паузе.
|
||||
const scheduleHide = useCallback(() => {
|
||||
clearHideTimer()
|
||||
hideTimerRef.current = window.setTimeout(() => {
|
||||
if (!overControlsRef.current && videoRef.current && !videoRef.current.paused) {
|
||||
setControlsVisible(false)
|
||||
}
|
||||
}, 2500)
|
||||
}, [clearHideTimer])
|
||||
|
||||
const revealControls = useCallback(() => {
|
||||
setControlsVisible(true)
|
||||
scheduleHide()
|
||||
}, [scheduleHide])
|
||||
|
||||
useEffect(() => clearHideTimer, [clearHideTimer])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const src = `/api/channels/${slug}/live.m3u8`
|
||||
let hls: Hls | null = null
|
||||
|
||||
// Восстанавливаем сохранённую громкость/mute и запускаем; если браузер блокирует автоплей со
|
||||
// звуком — откатываемся на воспроизведение без звука.
|
||||
const startPlayback = () => {
|
||||
const audio = audioRef.current
|
||||
video.volume = audio.volume
|
||||
video.muted = audio.muted
|
||||
video.play().catch(() => {
|
||||
video.muted = true
|
||||
setMuted(true)
|
||||
void video.play().catch(() => undefined)
|
||||
})
|
||||
}
|
||||
|
||||
// Слушатели нативной ветки — держим ссылки, чтобы снять их в cleanup (симметрично hls.destroy()).
|
||||
const onNativeError = () => onUnavailable?.()
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false })
|
||||
hlsRef.current = hls
|
||||
hls.loadSource(src)
|
||||
hls.attachMedia(video)
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, startPlayback)
|
||||
|
||||
// Живой эфир: краткий сетевой сбой сегмента или media-ошибку сперва пробуем восстановить
|
||||
// (hls.js рекомендует startLoad / recoverMediaError), и только исчерпав попытки — уходим в offline.
|
||||
let recoverAttempts = 0
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (!data.fatal) return
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && recoverAttempts < 3) {
|
||||
recoverAttempts += 1
|
||||
hls?.startLoad()
|
||||
return
|
||||
}
|
||||
if (data.type === Hls.ErrorTypes.MEDIA_ERROR && recoverAttempts < 3) {
|
||||
recoverAttempts += 1
|
||||
hls?.recoverMediaError()
|
||||
return
|
||||
}
|
||||
hls?.destroy()
|
||||
hls = null
|
||||
hlsRef.current = null
|
||||
onUnavailable?.()
|
||||
})
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = src
|
||||
video.addEventListener('loadedmetadata', startPlayback)
|
||||
video.addEventListener('error', onNativeError)
|
||||
} else {
|
||||
onUnavailable?.()
|
||||
}
|
||||
|
||||
return () => {
|
||||
hls?.destroy()
|
||||
hlsRef.current = null
|
||||
video.removeEventListener('loadedmetadata', startPlayback)
|
||||
video.removeEventListener('error', onNativeError)
|
||||
}
|
||||
}, [slug, onUnavailable])
|
||||
|
||||
// Установить громкость (0..1); 0 = mute, >0 запоминаем как последний уровень для «размьютить».
|
||||
const applyVolume = (value: number) => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const clamped = Math.min(1, Math.max(0, value))
|
||||
video.volume = clamped
|
||||
video.muted = clamped === 0
|
||||
setMuted(clamped === 0)
|
||||
if (clamped > 0) setVolume(clamped)
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (video.muted || video.volume === 0) {
|
||||
applyVolume(volume > 0 ? volume : 0.5)
|
||||
} else {
|
||||
video.muted = true
|
||||
setMuted(true)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
if (document.fullscreenElement) void document.exitFullscreen()
|
||||
else void container.requestFullscreen().catch(() => undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseMove={revealControls}
|
||||
onMouseLeave={() => {
|
||||
clearHideTimer()
|
||||
if (videoRef.current && !videoRef.current.paused) setControlsVisible(false)
|
||||
}}
|
||||
className={`relative aspect-video w-full overflow-hidden rounded-md border border-border bg-black ${
|
||||
controlsVisible ? '' : 'cursor-none'
|
||||
}`}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
playsInline
|
||||
muted
|
||||
className="h-full w-full"
|
||||
onDoubleClick={toggleFullscreen}
|
||||
onPlay={scheduleHide}
|
||||
onPause={() => {
|
||||
clearHideTimer()
|
||||
setControlsVisible(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
onMouseEnter={() => {
|
||||
overControlsRef.current = true
|
||||
clearHideTimer()
|
||||
setControlsVisible(true)
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
overControlsRef.current = false
|
||||
scheduleHide()
|
||||
}}
|
||||
className={`absolute inset-x-0 bottom-0 flex items-center gap-3 bg-gradient-to-t from-black/70 to-transparent px-3 py-2 text-white transition-opacity ${
|
||||
controlsVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={toggleMute} aria-label="mute">
|
||||
{muted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={(e) => applyVolume(Number(e.target.value))}
|
||||
aria-label={t('air.volume')}
|
||||
className="h-1 w-20 cursor-pointer accent-emerald-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="ml-auto flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-red-500">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
|
||||
{t('air.live')}
|
||||
</span>
|
||||
|
||||
<button type="button" onClick={toggleFullscreen} aria-label="fullscreen">
|
||||
<Maximize className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import Hls from 'hls.js'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Maximize, Volume2, VolumeX } from 'lucide-react'
|
||||
import type { PublicChannelDto } from '@/shared/api/types'
|
||||
import {
|
||||
AnalogFilter,
|
||||
ChannelFlash,
|
||||
ChannelLogo,
|
||||
NextUpBanner,
|
||||
ScreenClock,
|
||||
} from './PlayerOverlays'
|
||||
|
||||
const STORAGE_KEY = 'tw:player'
|
||||
|
||||
/** Читает сохранённые громкость/mute из localStorage (с валидацией и дефолтами). */
|
||||
function readStoredAudio(): { volume: number; muted: boolean } {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as { volume?: unknown; muted?: unknown }
|
||||
const volume =
|
||||
typeof parsed.volume === 'number' ? Math.min(1, Math.max(0, parsed.volume)) : 1
|
||||
const muted = typeof parsed.muted === 'boolean' ? parsed.muted : true
|
||||
return { volume, muted }
|
||||
}
|
||||
} catch {
|
||||
/* недоступен/битый localStorage — дефолты */
|
||||
}
|
||||
return { volume: 1, muted: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* HLS-плеер линейного канала. Это живой эфир: ни перемотки, ни паузы — только звук, громкость и
|
||||
* полноэкранный режим. Cookie tw_stream уже выдана к монтированию.
|
||||
*/
|
||||
export function ChannelPlayer({
|
||||
slug,
|
||||
channel,
|
||||
nextUp,
|
||||
flash,
|
||||
onUnavailable,
|
||||
}: {
|
||||
slug: string
|
||||
/** Канал, чьи оверлеи рисуем. Всё опционально: канал без логотипа и без шума — норма. */
|
||||
channel?: PublicChannelDto
|
||||
/** Название следующей программы, когда до неё осталось меньше минуты. */
|
||||
nextUp?: string | null
|
||||
/** Показать чёрный кадр с номером — переключение по номерам, как на телевизоре. */
|
||||
flash?: boolean
|
||||
onUnavailable?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const hlsRef = useRef<Hls | null>(null)
|
||||
const [muted, setMuted] = useState(() => readStoredAudio().muted)
|
||||
const [volume, setVolume] = useState(() => readStoredAudio().volume)
|
||||
|
||||
// Последние настройки звука без пересоздания HLS-эффекта + сохранение в localStorage.
|
||||
const audioRef = useRef({ volume, muted })
|
||||
useEffect(() => {
|
||||
audioRef.current = { volume, muted }
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ volume, muted }))
|
||||
} catch {
|
||||
/* localStorage недоступен — не критично */
|
||||
}
|
||||
}, [volume, muted])
|
||||
const [controlsVisible, setControlsVisible] = useState(true)
|
||||
const hideTimerRef = useRef<number | null>(null)
|
||||
const overControlsRef = useRef(false)
|
||||
|
||||
const clearHideTimer = useCallback(() => {
|
||||
if (hideTimerRef.current !== null) {
|
||||
window.clearTimeout(hideTimerRef.current)
|
||||
hideTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Прячем панель и курсор после бездействия — но не когда курсор на панели или видео на паузе.
|
||||
const scheduleHide = useCallback(() => {
|
||||
clearHideTimer()
|
||||
hideTimerRef.current = window.setTimeout(() => {
|
||||
if (!overControlsRef.current && videoRef.current && !videoRef.current.paused) {
|
||||
setControlsVisible(false)
|
||||
}
|
||||
}, 2500)
|
||||
}, [clearHideTimer])
|
||||
|
||||
const revealControls = useCallback(() => {
|
||||
setControlsVisible(true)
|
||||
scheduleHide()
|
||||
}, [scheduleHide])
|
||||
|
||||
useEffect(() => clearHideTimer, [clearHideTimer])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const src = `/api/channels/${slug}/live.m3u8`
|
||||
let hls: Hls | null = null
|
||||
|
||||
// Восстанавливаем сохранённую громкость/mute и запускаем; если браузер блокирует автоплей со
|
||||
// звуком — откатываемся на воспроизведение без звука.
|
||||
const startPlayback = () => {
|
||||
const audio = audioRef.current
|
||||
video.volume = audio.volume
|
||||
video.muted = audio.muted
|
||||
video.play().catch(() => {
|
||||
video.muted = true
|
||||
setMuted(true)
|
||||
void video.play().catch(() => undefined)
|
||||
})
|
||||
}
|
||||
|
||||
// Слушатели нативной ветки — держим ссылки, чтобы снять их в cleanup (симметрично hls.destroy()).
|
||||
const onNativeError = () => onUnavailable?.()
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false })
|
||||
hlsRef.current = hls
|
||||
hls.loadSource(src)
|
||||
hls.attachMedia(video)
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, startPlayback)
|
||||
|
||||
// Живой эфир: краткий сетевой сбой сегмента или media-ошибку сперва пробуем восстановить
|
||||
// (hls.js рекомендует startLoad / recoverMediaError), и только исчерпав попытки — уходим в offline.
|
||||
let recoverAttempts = 0
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (!data.fatal) return
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && recoverAttempts < 3) {
|
||||
recoverAttempts += 1
|
||||
hls?.startLoad()
|
||||
return
|
||||
}
|
||||
if (data.type === Hls.ErrorTypes.MEDIA_ERROR && recoverAttempts < 3) {
|
||||
recoverAttempts += 1
|
||||
hls?.recoverMediaError()
|
||||
return
|
||||
}
|
||||
hls?.destroy()
|
||||
hls = null
|
||||
hlsRef.current = null
|
||||
onUnavailable?.()
|
||||
})
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = src
|
||||
video.addEventListener('loadedmetadata', startPlayback)
|
||||
video.addEventListener('error', onNativeError)
|
||||
} else {
|
||||
onUnavailable?.()
|
||||
}
|
||||
|
||||
return () => {
|
||||
hls?.destroy()
|
||||
hlsRef.current = null
|
||||
video.removeEventListener('loadedmetadata', startPlayback)
|
||||
video.removeEventListener('error', onNativeError)
|
||||
}
|
||||
}, [slug, onUnavailable])
|
||||
|
||||
// Установить громкость (0..1); 0 = mute, >0 запоминаем как последний уровень для «размьютить».
|
||||
const applyVolume = (value: number) => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const clamped = Math.min(1, Math.max(0, value))
|
||||
video.volume = clamped
|
||||
video.muted = clamped === 0
|
||||
setMuted(clamped === 0)
|
||||
if (clamped > 0) setVolume(clamped)
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (video.muted || video.volume === 0) {
|
||||
applyVolume(volume > 0 ? volume : 0.5)
|
||||
} else {
|
||||
video.muted = true
|
||||
setMuted(true)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
if (document.fullscreenElement) void document.exitFullscreen()
|
||||
else void container.requestFullscreen().catch(() => undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseMove={revealControls}
|
||||
onMouseLeave={() => {
|
||||
clearHideTimer()
|
||||
if (videoRef.current && !videoRef.current.paused) setControlsVisible(false)
|
||||
}}
|
||||
className={`relative aspect-video w-full overflow-hidden rounded-md border border-border bg-black ${
|
||||
controlsVisible ? '' : 'cursor-none'
|
||||
}`}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
playsInline
|
||||
muted
|
||||
className="h-full w-full"
|
||||
style={
|
||||
channel && channel.analogFilterStrength > 0
|
||||
? {
|
||||
filter: `saturate(${1 + channel.analogFilterStrength * 0.4}) contrast(${1 + channel.analogFilterStrength * 0.15}) blur(${channel.analogFilterStrength * 0.6}px)`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
onPlay={scheduleHide}
|
||||
onPause={() => {
|
||||
clearHideTimer()
|
||||
setControlsVisible(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
{channel && channel.analogFilterStrength > 0 && (
|
||||
<AnalogFilter strength={channel.analogFilterStrength} />
|
||||
)}
|
||||
{channel?.logoImageId && (
|
||||
<ChannelLogo
|
||||
imageId={channel.logoImageId}
|
||||
corner={channel.logoCorner}
|
||||
opacity={channel.logoOpacity}
|
||||
/>
|
||||
)}
|
||||
{channel?.showClock && <ScreenClock />}
|
||||
{nextUp && <NextUpBanner title={nextUp} />}
|
||||
{flash && channel && <ChannelFlash number={channel.number} name={channel.name} />}
|
||||
|
||||
<div
|
||||
onMouseEnter={() => {
|
||||
overControlsRef.current = true
|
||||
clearHideTimer()
|
||||
setControlsVisible(true)
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
overControlsRef.current = false
|
||||
scheduleHide()
|
||||
}}
|
||||
className={`absolute inset-x-0 bottom-0 flex items-center gap-3 bg-gradient-to-t from-black/70 to-transparent px-3 py-2 text-white transition-opacity ${
|
||||
controlsVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={toggleMute} aria-label="mute">
|
||||
{muted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={(e) => applyVolume(Number(e.target.value))}
|
||||
aria-label={t('air.volume')}
|
||||
className="h-1 w-20 cursor-pointer accent-emerald-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="ml-auto flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-red-500">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
|
||||
{t('air.live')}
|
||||
</span>
|
||||
|
||||
<button type="button" onClick={toggleFullscreen} aria-label="fullscreen">
|
||||
<Maximize className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { LogoCorner } from '@/shared/api/types'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { imageUrl } from './api'
|
||||
|
||||
const CORNER_CLASS: Record<LogoCorner, string> = {
|
||||
TopLeft: 'left-3 top-3',
|
||||
TopRight: 'right-3 top-3',
|
||||
BottomLeft: 'left-3 bottom-14',
|
||||
BottomRight: 'right-3 bottom-14',
|
||||
}
|
||||
|
||||
/**
|
||||
* Логотип канала поверх картинки. Настоящий вещательный логотип вжигается при кодировании; для нас
|
||||
* это означало бы перекодирование всей библиотеки при смене логотипа, поэтому только оверлей.
|
||||
*/
|
||||
export function ChannelLogo({
|
||||
imageId,
|
||||
corner,
|
||||
opacity,
|
||||
}: {
|
||||
imageId: string
|
||||
corner: LogoCorner
|
||||
opacity: number
|
||||
}) {
|
||||
return (
|
||||
<img
|
||||
src={imageUrl(imageId)}
|
||||
alt=""
|
||||
style={{ opacity }}
|
||||
className={cn('pointer-events-none absolute h-10 w-auto max-w-24 object-contain', CORNER_CLASS[corner])}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Часы поверх картинки — опция канала, а не общее украшение плеера. */
|
||||
export function ScreenClock() {
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
|
||||
useEffect(() => {
|
||||
// Тик раз в 10 секунд: минуты меняются реже, а секунды на часах в углу никому не нужны.
|
||||
const id = window.setInterval(() => setNow(new Date()), 10_000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<span className="pointer-events-none absolute right-3 top-3 rounded bg-black/40 px-2 py-0.5 text-sm tabular-nums text-white/90">
|
||||
{now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Плашка «Далее: …» — данные уже есть в EPG, отдельного запроса не нужно. */
|
||||
export function NextUpBanner({ title }: { title: string }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<span className="pointer-events-none absolute bottom-14 left-3 rounded bg-black/60 px-2 py-1 text-sm text-white">
|
||||
{t('air.next')}: {title}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Аналоговый фильтр: лёгкий VHS-шум, дрожание и размытие краёв. Переборщить очень легко, поэтому
|
||||
* сила регулируется, а вклад каждого слоя от неё убывает нелинейно.
|
||||
*/
|
||||
export function AnalogFilter({ strength }: { strength: number }) {
|
||||
const s = Math.min(1, Math.max(0, strength))
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 mix-blend-overlay"
|
||||
style={{
|
||||
opacity: s * 0.35,
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(0deg, rgba(255,255,255,.12) 0 1px, transparent 1px 3px)',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
opacity: s * 0.5,
|
||||
boxShadow: `inset 0 0 ${40 + s * 60}px rgba(0,0,0,.75)`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Короткий чёрный кадр с номером канала — как при переключении на телевизоре. */
|
||||
export function ChannelFlash({ number, name }: { number: number | null; name: string }) {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-start justify-end bg-black">
|
||||
<span className="m-6 flex items-baseline gap-2 text-white">
|
||||
{number !== null && <span className="text-5xl font-bold tabular-nums">{number}</span>}
|
||||
<span className="text-lg">{name}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,29 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { PublicChannelDto, PublicEpgEntryDto } from '@/shared/api/types'
|
||||
|
||||
/** Ссылка на изображение общего реестра (постеры/кадры) — по id из публичных DTO. */
|
||||
export function imageUrl(imageId: string) {
|
||||
return `/api/images/${imageId}`
|
||||
}
|
||||
|
||||
export function listChannels() {
|
||||
return apiRequest<PublicChannelDto[]>('/channels')
|
||||
}
|
||||
|
||||
/** Выдаёт httpOnly-cookie tw_stream — после этого <video> сможет грузить плейлист и сегменты. */
|
||||
export function watchChannel(slug: string) {
|
||||
return apiRequest<void>(`/channels/${slug}/watch`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getEpg(slug: string, from?: Date, to?: Date) {
|
||||
const query = new URLSearchParams()
|
||||
if (from) query.set('from', from.toISOString())
|
||||
if (to) query.set('to', to.toISOString())
|
||||
const qs = query.toString()
|
||||
return apiRequest<PublicEpgEntryDto[]>(`/channels/${slug}/epg${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { PublicChannelDto, PublicEpgEntryDto } from '@/shared/api/types'
|
||||
|
||||
/** Ссылка на изображение общего реестра (постеры/кадры) — по id из публичных DTO. */
|
||||
export function imageUrl(imageId: string) {
|
||||
return `/api/images/${imageId}`
|
||||
}
|
||||
|
||||
export function listChannels() {
|
||||
return apiRequest<PublicChannelDto[]>('/channels')
|
||||
}
|
||||
|
||||
/** Что включено глобально на стороне зрителя (сейчас — переключение по номерам). */
|
||||
export function getViewerFeatures() {
|
||||
return apiRequest<{ channelNumbersEnabled: boolean }>('/channels/features')
|
||||
}
|
||||
|
||||
/** Выдаёт httpOnly-cookie tw_stream — после этого <video> сможет грузить плейлист и сегменты. */
|
||||
export function watchChannel(slug: string) {
|
||||
return apiRequest<void>(`/channels/${slug}/watch`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getEpg(slug: string, from?: Date, to?: Date) {
|
||||
const query = new URLSearchParams()
|
||||
if (from) query.set('from', from.toISOString())
|
||||
if (to) query.set('to', to.toISOString())
|
||||
const qs = query.toString()
|
||||
return apiRequest<PublicEpgEntryDto[]>(`/channels/${slug}/epg${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,24 @@ export type AuthResponse = {
|
||||
|
||||
export type RegistrationStatus = { enabled: boolean }
|
||||
|
||||
export type SiteSettings = { registrationEnabled: boolean; preferredAudioLanguages: string }
|
||||
export type SiteSettings = {
|
||||
registrationEnabled: boolean
|
||||
preferredAudioLanguages: string
|
||||
/** Переключение каналов по номерам у зрителя; сетка каналов остаётся всегда. */
|
||||
channelNumbersEnabled: boolean
|
||||
}
|
||||
|
||||
/** Угол экрана для логотипа-оверлея. */
|
||||
export type LogoCorner = 'TopLeft' | 'TopRight' | 'BottomLeft' | 'BottomRight'
|
||||
|
||||
/** Как канал выглядит у зрителя — всё опционально и по умолчанию выключено (см. 6.8). */
|
||||
export type ViewerSettings = {
|
||||
logoImageId: string | null
|
||||
logoCorner: LogoCorner
|
||||
logoOpacity: number
|
||||
showClock: boolean
|
||||
analogFilterStrength: number
|
||||
}
|
||||
|
||||
export type RoleDto = {
|
||||
id: string
|
||||
@@ -360,6 +377,7 @@ export type ChannelDto = {
|
||||
bumper: BumperSettings
|
||||
bumperTemplates: BumperTemplateDto[]
|
||||
fillerAssetId: string | null
|
||||
viewer: ViewerSettings
|
||||
}
|
||||
|
||||
// ── Сетка канала (шаблон → слои → слоты) ──────────────────────────────────
|
||||
@@ -466,6 +484,10 @@ export type AudienceWindow = { from: string; to: string; maxAudience: ShowAudien
|
||||
export type PlanningRules = {
|
||||
maxAudienceByTime?: AudienceWindow[] | null
|
||||
maxRepeatsInWindow?: { windowDays: number; max: number } | null
|
||||
/** Пост-проверки: считаются по готовой ленте и только предупреждают. */
|
||||
maxBreakMinutesPerHour?: number | null
|
||||
maxGenreSharePercent?: number | null
|
||||
maxFallbackSharePercent?: number | null
|
||||
}
|
||||
|
||||
export type ScheduleTemplateDto = {
|
||||
@@ -493,6 +515,9 @@ export type PlanningWarningKind =
|
||||
| 'RepeatSourceEmpty'
|
||||
| 'FallbackEmpty'
|
||||
| 'CandidatesFiltered'
|
||||
| 'BreakLimitExceeded'
|
||||
| 'GenreShareExceeded'
|
||||
| 'FallbackShareExceeded'
|
||||
|
||||
export type PlanningWarningDto = {
|
||||
kind: PlanningWarningKind
|
||||
@@ -502,6 +527,72 @@ export type PlanningWarningDto = {
|
||||
|
||||
export type ApplyResultDto = { added: number; warnings: PlanningWarningDto[] }
|
||||
|
||||
/** Что изменится в эфире, если применить правила сейчас (см. 6.6). */
|
||||
export type ScheduleChangeDto = {
|
||||
startsAtUtc: string
|
||||
before: string | null
|
||||
after: string | null
|
||||
/** Попадает в ближайшие сутки — подсвечивается отдельно. */
|
||||
soon: boolean
|
||||
}
|
||||
|
||||
export type ScheduleDiffDto = {
|
||||
total: number
|
||||
changed: number
|
||||
changedSoon: number
|
||||
changes: ScheduleChangeDto[]
|
||||
}
|
||||
|
||||
/** Цепочка происхождения записи — «почему это здесь» (см. 6.5). */
|
||||
export type EntryTraceDto = {
|
||||
entryId: string
|
||||
startsAtUtc: string
|
||||
endsAtUtc: string
|
||||
showName: string | null
|
||||
episodeIndex: number | null
|
||||
layerName: string | null
|
||||
layerPriority: number | null
|
||||
slotTitle: string | null
|
||||
slotKind: SlotKind | null
|
||||
slotWeekday: number | null
|
||||
slotTargetStart: string | null
|
||||
slotDurationMinutes: number | null
|
||||
groupName: string | null
|
||||
groupItemCount: number | null
|
||||
strategy: SlotStrategyType | null
|
||||
cooldownDays: number | null
|
||||
candidatesAfterCooldown: number | null
|
||||
driftMinutes: number
|
||||
snapped: boolean
|
||||
junctionName: string | null
|
||||
}
|
||||
|
||||
export type CopyTemplateResultDto = {
|
||||
layers: number
|
||||
slots: number
|
||||
junctions: number
|
||||
/** Врезки-заставки, для которых на канале-приёмнике не нашлось блока с таким же именем. */
|
||||
droppedBumperRefs: number
|
||||
}
|
||||
|
||||
/** Проверки сетки по правилам, до генерации (см. 5.1). */
|
||||
export type TemplateIssueKind =
|
||||
| 'GroupEmpty'
|
||||
| 'GroupTooSmall'
|
||||
| 'GridGap'
|
||||
| 'SlotOverlap'
|
||||
| 'CooldownUnreachable'
|
||||
| 'AudienceConflict'
|
||||
| 'GroupMissing'
|
||||
|
||||
export type TemplateIssueDto = {
|
||||
kind: TemplateIssueKind
|
||||
severity: 'Warning' | 'Error'
|
||||
layerId: string | null
|
||||
slotId: string | null
|
||||
details: string
|
||||
}
|
||||
|
||||
/** Что попало в ленту предпросмотра. `Bumper` приходит без ассета — он рендерится при применении. */
|
||||
export type PlannedItemKind = 'Program' | 'Fallback' | 'SignOff' | 'Ad' | 'Promo' | 'Bumper'
|
||||
|
||||
@@ -543,9 +634,16 @@ export type PublicChannelDto = {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
/** Номер канала для переключения по номерам или null. */
|
||||
number: number | null
|
||||
currentShowId: string | null
|
||||
currentShowName: string | null
|
||||
currentShowPosterImageId: string | null
|
||||
logoImageId: string | null
|
||||
logoCorner: LogoCorner
|
||||
logoOpacity: number
|
||||
showClock: boolean
|
||||
analogFilterStrength: number
|
||||
}
|
||||
|
||||
export type PublicEpgEntryDto = {
|
||||
|
||||
@@ -66,6 +66,7 @@ const resources = {
|
||||
noChannels: 'Пока нет доступных каналов. Загляните позже.',
|
||||
offline: 'Канал сейчас не в эфире',
|
||||
offlineHint: 'Нет расписания или контента. Загляните позже.',
|
||||
numbersHint: '↑ / ↓ — переключение каналов по номерам',
|
||||
retry: 'Повторить',
|
||||
},
|
||||
settings: {
|
||||
@@ -378,7 +379,65 @@ const resources = {
|
||||
previewDays_one: '{{count}} сутки',
|
||||
previewDays_few: '{{count}} суток',
|
||||
previewDays_many: '{{count}} суток',
|
||||
previewTabs: { programme: 'Программа', tape: 'Лента' },
|
||||
previewTabs: { programme: 'Программа', tape: 'Лента', problems: 'Проблемы' },
|
||||
noProblems: 'Проблем нет',
|
||||
andMore: 'и ещё {{count}}',
|
||||
heatmap: 'Повторы: шоу × сутки',
|
||||
heatmapTotal: 'всего',
|
||||
issues: 'Проверки: ошибок {{errors}}, предупреждений {{warnings}}',
|
||||
goToSlot: 'к слоту',
|
||||
issueKinds: {
|
||||
GroupEmpty: 'Пустая группа',
|
||||
GroupTooSmall: 'Мало контента',
|
||||
GridGap: 'Дыра в сетке',
|
||||
SlotOverlap: 'Слоты пересекаются',
|
||||
CooldownUnreachable: 'Недостижимое остывание',
|
||||
AudienceConflict: 'Возрастной конфликт',
|
||||
GroupMissing: 'Группа не выбрана',
|
||||
},
|
||||
viewer: 'Как выглядит у зрителя',
|
||||
viewerHint:
|
||||
'Оверлеи рисуются поверх картинки на клиенте — видео не перекодируется. Всё по умолчанию выключено.',
|
||||
logo: 'Логотип',
|
||||
noLogo: 'нет',
|
||||
pickLogo: 'Выбрать логотип',
|
||||
logoCorner: 'Угол',
|
||||
logoOpacity: 'Прозрачность',
|
||||
corners: {
|
||||
TopLeft: 'Слева вверху',
|
||||
TopRight: 'Справа вверху',
|
||||
BottomLeft: 'Слева внизу',
|
||||
BottomRight: 'Справа внизу',
|
||||
},
|
||||
showClock: 'Показывать часы',
|
||||
analogFilter: 'Аналоговый фильтр',
|
||||
analogFilterHint: 'Сила 0..1. Ноль — выключен; переборщить очень легко.',
|
||||
whyHere: 'Почему это здесь',
|
||||
priority: 'приоритет',
|
||||
traceLayer: 'Слой',
|
||||
traceSlot: 'Слот',
|
||||
traceGroup: 'Группа',
|
||||
traceStrategy: 'Стратегия',
|
||||
traceJunction: 'Врезки',
|
||||
traceDrift: 'дрейф {{minutes}} мин',
|
||||
traceSnapped: 'старт округлён',
|
||||
traceCooldown: 'остывание {{days}} дн.',
|
||||
traceCandidates: 'кандидатов после остывания: {{count}}',
|
||||
diffSummary: 'Затронет {{total}} записей, изменятся {{changed}}',
|
||||
diffSoon: 'В ближайшие сутки изменится записей: {{count}}',
|
||||
diffNoChanges: 'Эфир не изменится',
|
||||
copyTemplate: 'Копировать сетку',
|
||||
pickTargetChannel: 'Выберите канал',
|
||||
copyTemplateHint:
|
||||
'Слои, слоты, стыки и правила уедут на выбранный канал, его прежняя сетка заменится. Группы общие и не копируются.',
|
||||
templateCopied: 'Скопировано: слоёв {{layers}}, слотов {{slots}}',
|
||||
copyDroppedBumpers: 'Врезок без блока заставки: {{count}} — донастройте руками',
|
||||
postChecks: 'Пост-проверки',
|
||||
breakLimit: 'Потолок врезок в час, мин',
|
||||
genreShare: 'Потолок доли жанра за сутки, %',
|
||||
fallbackShare: 'Потолок доли фона, %',
|
||||
postChecksHint:
|
||||
'Пост-проверки считаются по готовой ленте и только предупреждают — ничего не переигрывается.',
|
||||
previewKinds: {
|
||||
Program: 'Программа',
|
||||
Fallback: 'Фон',
|
||||
@@ -454,6 +513,9 @@ const resources = {
|
||||
RepeatSourceEmpty: 'Нечего повторять',
|
||||
FallbackEmpty: 'Нечем закрыть паузы',
|
||||
CandidatesFiltered: 'Возрастной потолок отсёк всех',
|
||||
BreakLimitExceeded: 'Врезок в часе больше потолка',
|
||||
GenreShareExceeded: 'Доля жанра выше нормы',
|
||||
FallbackShareExceeded: 'Фона в эфире больше нормы',
|
||||
},
|
||||
|
||||
title: 'Каналы',
|
||||
@@ -565,6 +627,9 @@ const resources = {
|
||||
'Когда выключено — новые пользователи не могут регистрироваться сами, учётки заводит только администратор.',
|
||||
registrationLabel: 'Разрешить регистрацию на сайте',
|
||||
preferredAudio: 'Предпочитаемые озвучки',
|
||||
channelNumbers: 'Переключение каналов по номерам',
|
||||
channelNumbersHint:
|
||||
'Зритель переключает каналы стрелками, как на телевизоре. Сетка каналов остаётся всегда.',
|
||||
preferredAudioHint:
|
||||
'Коды языков через запятую в порядке приоритета (напр. «rus, eng»). Если в файле есть дорожка с таким языком — при обработке выбирается она (по порядку); иначе — выбор ffmpeg по умолчанию. Применяется к новым обработкам.',
|
||||
},
|
||||
@@ -974,7 +1039,65 @@ const resources = {
|
||||
previewHint: 'A run against the current rules: nothing is written, slot cursors do not move.',
|
||||
previewDays_one: '{{count}} day',
|
||||
previewDays_other: '{{count}} days',
|
||||
previewTabs: { programme: 'Programme', tape: 'Tape' },
|
||||
previewTabs: { programme: 'Programme', tape: 'Tape', problems: 'Problems' },
|
||||
noProblems: 'No problems',
|
||||
andMore: 'and {{count}} more',
|
||||
heatmap: 'Repeats: show × day',
|
||||
heatmapTotal: 'total',
|
||||
issues: 'Checks: {{errors}} errors, {{warnings}} warnings',
|
||||
goToSlot: 'to slot',
|
||||
issueKinds: {
|
||||
GroupEmpty: 'Empty group',
|
||||
GroupTooSmall: 'Too little content',
|
||||
GridGap: 'Gap in the grid',
|
||||
SlotOverlap: 'Slots overlap',
|
||||
CooldownUnreachable: 'Unreachable cooldown',
|
||||
AudienceConflict: 'Age conflict',
|
||||
GroupMissing: 'No group selected',
|
||||
},
|
||||
viewer: 'How viewers see it',
|
||||
viewerHint:
|
||||
'Overlays are drawn on the client on top of the picture — the video is not re-encoded. Everything is off by default.',
|
||||
logo: 'Logo',
|
||||
noLogo: 'none',
|
||||
pickLogo: 'Pick a logo',
|
||||
logoCorner: 'Corner',
|
||||
logoOpacity: 'Opacity',
|
||||
corners: {
|
||||
TopLeft: 'Top left',
|
||||
TopRight: 'Top right',
|
||||
BottomLeft: 'Bottom left',
|
||||
BottomRight: 'Bottom right',
|
||||
},
|
||||
showClock: 'Show a clock',
|
||||
analogFilter: 'Analog filter',
|
||||
analogFilterHint: 'Strength 0..1. Zero is off; it is very easy to overdo.',
|
||||
whyHere: 'Why is this here',
|
||||
priority: 'priority',
|
||||
traceLayer: 'Layer',
|
||||
traceSlot: 'Slot',
|
||||
traceGroup: 'Group',
|
||||
traceStrategy: 'Strategy',
|
||||
traceJunction: 'Breaks',
|
||||
traceDrift: 'drift {{minutes}} min',
|
||||
traceSnapped: 'start snapped',
|
||||
traceCooldown: 'cooldown {{days}} d.',
|
||||
traceCandidates: 'candidates after cooldown: {{count}}',
|
||||
diffSummary: 'Affects {{total}} entries, {{changed}} will change',
|
||||
diffSoon: 'Entries changing within 24 hours: {{count}}',
|
||||
diffNoChanges: 'The air will not change',
|
||||
copyTemplate: 'Copy grid',
|
||||
pickTargetChannel: 'Pick a channel',
|
||||
copyTemplateHint:
|
||||
'Layers, slots, junctions and rules move to the chosen channel, replacing its grid. Groups are shared and not copied.',
|
||||
templateCopied: 'Copied: {{layers}} layers, {{slots}} slots',
|
||||
copyDroppedBumpers: 'Breaks left without a bumper block: {{count}} — set them up by hand',
|
||||
postChecks: 'Post-checks',
|
||||
breakLimit: 'Breaks per hour cap, min',
|
||||
genreShare: 'Genre share per day cap, %',
|
||||
fallbackShare: 'Background share cap, %',
|
||||
postChecksHint:
|
||||
'Post-checks run against the finished tape and only warn — nothing is replanned.',
|
||||
previewKinds: {
|
||||
Program: 'Programme',
|
||||
Fallback: 'Background',
|
||||
@@ -1045,6 +1168,9 @@ const resources = {
|
||||
RepeatSourceEmpty: 'Nothing to repeat',
|
||||
FallbackEmpty: 'Nothing to fill pauses with',
|
||||
CandidatesFiltered: 'The age cap ruled out every candidate',
|
||||
BreakLimitExceeded: 'Breaks in an hour exceed the cap',
|
||||
GenreShareExceeded: 'Genre share above the norm',
|
||||
FallbackShareExceeded: 'Background share above the norm',
|
||||
},
|
||||
|
||||
title: 'Channels',
|
||||
@@ -1155,6 +1281,9 @@ const resources = {
|
||||
registrationHint:
|
||||
'When off, new users cannot sign up themselves — only an administrator can create accounts.',
|
||||
registrationLabel: 'Allow public registration',
|
||||
channelNumbers: 'Switch channels by number',
|
||||
channelNumbersHint:
|
||||
'Viewers switch channels with the arrow keys, like on a TV set. The channel grid stays available regardless.',
|
||||
preferredAudio: 'Preferred audio tracks',
|
||||
preferredAudioHint:
|
||||
'Comma-separated language codes in priority order (e.g. "rus, eng"). If a file has a track in one of these languages, it is picked during processing (by order); otherwise ffmpeg default. Applies to new processing.',
|
||||
|
||||
Reference in New Issue
Block a user