Normalize line endings to LF via .gitattributes

Репозиторий хранил фронтенд в CRLF, а часть бэкенда — вперемешку, хотя CI и Docker-сборка
работают под Linux. Прибиваем LF атрибутом `* text=auto eol=lf` и разово нормализуем дерево,
чтобы форматтеры не переписывали файлы целиком на каждом прогоне.

Коммит чисто механический: изменений содержимого нет, только концы строк.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-07-27 01:37:33 +03:00
co-authored by Claude Opus 5
parent 9d1c6d2fc3
commit 0442056367
109 changed files with 13469 additions and 13459 deletions
@@ -1,85 +1,85 @@
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'
/** Что стоит в строке расписания: реклама, заставка-переход или программа с номером серии. */
function EntryLabel({ entry }: Readonly<{ entry: ScheduleEntryDto }>) {
const { t } = useTranslation()
if (entry.kind === 'Ad') return <Badge variant="muted">{t('air.ad')}</Badge>
if (entry.kind === 'Bumper')
return (
<span className="flex min-w-0 items-center gap-2">
<Badge variant="muted" className="shrink-0">
{t('air.bumper')}
</Badge>
{(entry.bumperName || entry.bumperText) && (
<span className="min-w-0 truncate text-muted-foreground">
{entry.bumperName}
{entry.bumperName && entry.bumperText ? ' · ' : ''}
{entry.bumperText}
</span>
)}
</span>
)
return (
<span>
{entry.showName ?? '—'}
<EpisodeSuffix entry={entry} />
</span>
)
}
/** «· S02E05» либо «· серия N» — что удалось распознать; ничего, если ни того ни другого нет. */
function EpisodeSuffix({ entry }: Readonly<{ entry: ScheduleEntryDto }>) {
const { t } = useTranslation()
if (entry.seasonEpisode)
return <span className="text-muted-foreground"> · {entry.seasonEpisode}</span>
if (entry.episodeIndex == null) return null
return (
<span className="text-muted-foreground">
{' '}
· {t('air.episode')} {entry.episodeIndex + 1}
</span>
)
}
export function SchedulePreview({
entries,
onShowTrace,
}: Readonly<{
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>
<EntryLabel entry={e} />
<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>
)
}
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'
/** Что стоит в строке расписания: реклама, заставка-переход или программа с номером серии. */
function EntryLabel({ entry }: Readonly<{ entry: ScheduleEntryDto }>) {
const { t } = useTranslation()
if (entry.kind === 'Ad') return <Badge variant="muted">{t('air.ad')}</Badge>
if (entry.kind === 'Bumper')
return (
<span className="flex min-w-0 items-center gap-2">
<Badge variant="muted" className="shrink-0">
{t('air.bumper')}
</Badge>
{(entry.bumperName || entry.bumperText) && (
<span className="min-w-0 truncate text-muted-foreground">
{entry.bumperName}
{entry.bumperName && entry.bumperText ? ' · ' : ''}
{entry.bumperText}
</span>
)}
</span>
)
return (
<span>
{entry.showName ?? '—'}
<EpisodeSuffix entry={entry} />
</span>
)
}
/** «· S02E05» либо «· серия N» — что удалось распознать; ничего, если ни того ни другого нет. */
function EpisodeSuffix({ entry }: Readonly<{ entry: ScheduleEntryDto }>) {
const { t } = useTranslation()
if (entry.seasonEpisode)
return <span className="text-muted-foreground"> · {entry.seasonEpisode}</span>
if (entry.episodeIndex == null) return null
return (
<span className="text-muted-foreground">
{' '}
· {t('air.episode')} {entry.episodeIndex + 1}
</span>
)
}
export function SchedulePreview({
entries,
onShowTrace,
}: Readonly<{
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>
<EntryLabel entry={e} />
<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>
)
}
@@ -1,451 +1,451 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listGroups } from '@/features/admin/groups/api'
import type {
Daypart,
OverflowPolicy,
SlotBlockMode,
SlotDto,
SlotKind,
SlotStrategyType,
} from '@/shared/api/types'
import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import {
createSlot,
deleteSlot,
listJunctions,
toSlotBody,
updateSlot,
type SlotBody,
} from '../api'
const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night']
const SLOT_KINDS: SlotKind[] = ['Content', 'Repeat', 'SignOff']
const BLOCK_MODES: SlotBlockMode[] = ['Count', 'Duration', 'FillSlot']
const OVERFLOW: OverflowPolicy[] = ['ContinueNext', 'ExtendSlot', 'SkipIfNotFits']
const STRATEGIES: SlotStrategyType[] = ['Sequential', 'RandomWithCooldown', 'Fixed']
const SNAP_OPTIONS = [0, 5, 10, 15, 30]
/** Черновик слота: новый (layerId + предзаполненные время/день) либо существующий. */
export type SlotDraft = { layerId: string; slot: SlotDto | null; defaults?: Partial<SlotBody> }
function emptyBody(defaults?: Partial<SlotBody>): SlotBody {
return {
title: '',
weekday: null,
targetStart: '20:00:00',
targetDurationMinutes: 60,
daypart: 'Day',
slotKind: 'Content',
groupId: null,
strategy: {
type: 'Sequential',
restartOnEnd: true,
cooldownDays: 0,
fallback: 'OldestFirst',
},
repeatSource: null,
blockMode: 'FillSlot',
blockValue: 1,
overflowPolicy: 'ContinueNext',
isAnchor: false,
maxDriftMinutes: 5,
snapToMinutes: null,
junctionBetweenId: null,
junctionAfterId: null,
...defaults,
}
}
export function SlotInspector({
channelId,
draft,
onClose,
onChanged,
}: Readonly<{
channelId: string
draft: SlotDraft
onClose: () => void
onChanged: () => void
}>) {
const { t } = useTranslation()
const [body, setBody] = useState<SlotBody>(() =>
draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults),
)
useEffect(() => {
setBody(draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults))
}, [draft])
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
const { data: junctions } = useQuery({
queryKey: qk.channels.junctions(channelId),
queryFn: () => listJunctions(channelId),
})
const onError = useApiError()
const save = useMutation({
mutationFn: async () => {
if (draft.slot) await updateSlot(draft.slot.id, body)
else await createSlot(draft.layerId, body)
},
onSuccess: () => {
onChanged()
onClose()
},
onError,
})
const remove = useMutation({
mutationFn: () => deleteSlot(draft.slot!.id),
onSuccess: () => {
onChanged()
onClose()
},
onError,
})
const patch = (part: Partial<SlotBody>) => setBody((prev) => ({ ...prev, ...part }))
const isContent = body.slotKind === 'Content'
return (
<div className="crt-panel flex flex-col gap-3 rounded-md p-4 text-sm">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold uppercase tracking-wide">
{draft.slot ? t('admin.channels.editSlot') : t('admin.channels.newSlot')}
</h3>
<Button size="sm" variant="ghost" onClick={onClose}>
×
</Button>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.slotTitle')}</Label>
<Input value={body.title} onChange={(e) => patch({ title: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.slotStart')}</Label>
<Input
type="time"
value={body.targetStart.slice(0, 5)}
onChange={(e) => patch({ targetStart: `${e.target.value}:00` })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.slotDuration')}</Label>
<Input
type="number"
min={1}
value={body.targetDurationMinutes}
onChange={(e) => patch({ targetDurationMinutes: Number(e.target.value) })}
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.weekday')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.weekday ?? 'any'}
onChange={(e) =>
patch({ weekday: e.target.value === 'any' ? null : Number(e.target.value) })
}
>
<option value="any">{t('admin.channels.everyDay')}</option>
{[1, 2, 3, 4, 5, 6, 0].map((day) => (
<option key={day} value={day}>
{t(`admin.channels.weekdays.${day}`)}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.daypart')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.daypart}
onChange={(e) => patch({ daypart: e.target.value as Daypart })}
>
{DAYPARTS.map((value) => (
<option key={value} value={value}>
{t(`admin.channels.dayparts.${value}`)}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.slotKind')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.slotKind}
onChange={(e) => {
const slotKind = e.target.value as SlotKind
patch({
slotKind,
// Повтору нужен источник, конец вещания не берёт контент вовсе.
repeatSource:
slotKind === 'Repeat'
? (body.repeatSource ?? { daysAgo: 1, time: '20:00:00', durationMinutes: 90 })
: null,
})
}}
>
{SLOT_KINDS.map((value) => (
<option key={value} value={value}>
{t(`admin.channels.slotKinds.${value}`)}
</option>
))}
</select>
</div>
</div>
{isContent && (
<>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.group')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.groupId ?? ''}
onChange={(e) => patch({ groupId: e.target.value || null })}
>
<option value="">{t('admin.channels.pickGroup')}</option>
{(groups ?? []).map((group) => (
<option key={group.id} value={group.id}>
{group.name} · {group.itemCount}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.strategy')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.strategy?.type ?? 'Sequential'}
onChange={(e) =>
patch({
strategy: {
...(body.strategy ?? {
restartOnEnd: true,
cooldownDays: 0,
fallback: 'OldestFirst',
type: 'Sequential',
}),
type: e.target.value as SlotStrategyType,
},
})
}
>
{STRATEGIES.map((value) => (
<option key={value} value={value}>
{t(`admin.channels.strategies.${value}`)}
</option>
))}
</select>
</div>
{body.strategy?.type === 'RandomWithCooldown' && (
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.cooldownDays')}</Label>
<Input
type="number"
min={0}
value={body.strategy.cooldownDays}
onChange={(e) =>
patch({
strategy: { ...body.strategy!, cooldownDays: Number(e.target.value) },
})
}
/>
<p className="text-xs text-muted-foreground">{t('admin.channels.cooldownHint')}</p>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.blockMode')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.blockMode}
onChange={(e) => patch({ blockMode: e.target.value as SlotBlockMode })}
>
{BLOCK_MODES.map((value) => (
<option key={value} value={value}>
{t(`admin.channels.blockModes.${value}`)}
</option>
))}
</select>
</div>
{body.blockMode !== 'FillSlot' && (
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.blockValue')}</Label>
<Input
type="number"
min={1}
value={body.blockValue}
onChange={(e) => patch({ blockValue: Number(e.target.value) })}
/>
</div>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.overflow')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.overflowPolicy}
onChange={(e) => patch({ overflowPolicy: e.target.value as OverflowPolicy })}
>
{OVERFLOW.map((value) => (
<option key={value} value={value}>
{t(`admin.channels.overflows.${value}`)}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">{t('admin.channels.overflowHint')}</p>
</div>
</>
)}
{body.slotKind === 'Repeat' && body.repeatSource && (
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.repeatDaysAgo')}</Label>
<Input
type="number"
min={1}
value={body.repeatSource.daysAgo}
onChange={(e) =>
patch({
repeatSource: { ...body.repeatSource!, daysAgo: Number(e.target.value) },
})
}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.repeatTime')}</Label>
<Input
type="time"
value={body.repeatSource.time.slice(0, 5)}
onChange={(e) =>
patch({ repeatSource: { ...body.repeatSource!, time: `${e.target.value}:00` } })
}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.repeatDuration')}</Label>
<Input
type="number"
min={1}
value={body.repeatSource.durationMinutes}
onChange={(e) =>
patch({
repeatSource: {
...body.repeatSource!,
durationMinutes: Number(e.target.value),
},
})
}
/>
</div>
</div>
)}
<div className="flex flex-wrap items-end gap-3">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={body.isAnchor}
onChange={(e) => patch({ isAnchor: e.target.checked })}
/>
{t('admin.channels.anchor')}
</label>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.maxDrift')}</Label>
<Input
type="number"
min={0}
className="w-24"
value={body.maxDriftMinutes}
onChange={(e) => patch({ maxDriftMinutes: Number(e.target.value) })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.snap')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.snapToMinutes ?? 0}
onChange={(e) =>
patch({ snapToMinutes: Number(e.target.value) === 0 ? null : Number(e.target.value) })
}
>
{SNAP_OPTIONS.map((value) => (
<option key={value} value={value}>
{value === 0 ? t('admin.channels.snapOff') : `${value}`}
</option>
))}
</select>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('admin.channels.anchorHint')}</p>
{/* Стыки: внутри слота — между единицами, после слота — на переходе к следующему. */}
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.junctionBetween')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.junctionBetweenId ?? ''}
onChange={(e) => patch({ junctionBetweenId: e.target.value || null })}
>
<option value="">{t('admin.channels.junctionDefault')}</option>
{(junctions ?? []).map((junction) => (
<option key={junction.id} value={junction.id}>
{junction.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.junctionAfter')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.junctionAfterId ?? ''}
onChange={(e) => patch({ junctionAfterId: e.target.value || null })}
>
<option value="">{t('admin.channels.junctionDefault')}</option>
{(junctions ?? []).map((junction) => (
<option key={junction.id} value={junction.id}>
{junction.name}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center justify-between gap-2">
{draft.slot ? (
<Button size="sm" variant="destructive" onClick={() => remove.mutate()}>
<Trash2 className="h-4 w-4" />
</Button>
) : (
<span />
)}
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</div>
)
}
import { useMutation, useQuery } from '@tanstack/react-query'
import { Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listGroups } from '@/features/admin/groups/api'
import type {
Daypart,
OverflowPolicy,
SlotBlockMode,
SlotDto,
SlotKind,
SlotStrategyType,
} from '@/shared/api/types'
import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import {
createSlot,
deleteSlot,
listJunctions,
toSlotBody,
updateSlot,
type SlotBody,
} from '../api'
const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night']
const SLOT_KINDS: SlotKind[] = ['Content', 'Repeat', 'SignOff']
const BLOCK_MODES: SlotBlockMode[] = ['Count', 'Duration', 'FillSlot']
const OVERFLOW: OverflowPolicy[] = ['ContinueNext', 'ExtendSlot', 'SkipIfNotFits']
const STRATEGIES: SlotStrategyType[] = ['Sequential', 'RandomWithCooldown', 'Fixed']
const SNAP_OPTIONS = [0, 5, 10, 15, 30]
/** Черновик слота: новый (layerId + предзаполненные время/день) либо существующий. */
export type SlotDraft = { layerId: string; slot: SlotDto | null; defaults?: Partial<SlotBody> }
function emptyBody(defaults?: Partial<SlotBody>): SlotBody {
return {
title: '',
weekday: null,
targetStart: '20:00:00',
targetDurationMinutes: 60,
daypart: 'Day',
slotKind: 'Content',
groupId: null,
strategy: {
type: 'Sequential',
restartOnEnd: true,
cooldownDays: 0,
fallback: 'OldestFirst',
},
repeatSource: null,
blockMode: 'FillSlot',
blockValue: 1,
overflowPolicy: 'ContinueNext',
isAnchor: false,
maxDriftMinutes: 5,
snapToMinutes: null,
junctionBetweenId: null,
junctionAfterId: null,
...defaults,
}
}
export function SlotInspector({
channelId,
draft,
onClose,
onChanged,
}: Readonly<{
channelId: string
draft: SlotDraft
onClose: () => void
onChanged: () => void
}>) {
const { t } = useTranslation()
const [body, setBody] = useState<SlotBody>(() =>
draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults),
)
useEffect(() => {
setBody(draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults))
}, [draft])
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
const { data: junctions } = useQuery({
queryKey: qk.channels.junctions(channelId),
queryFn: () => listJunctions(channelId),
})
const onError = useApiError()
const save = useMutation({
mutationFn: async () => {
if (draft.slot) await updateSlot(draft.slot.id, body)
else await createSlot(draft.layerId, body)
},
onSuccess: () => {
onChanged()
onClose()
},
onError,
})
const remove = useMutation({
mutationFn: () => deleteSlot(draft.slot!.id),
onSuccess: () => {
onChanged()
onClose()
},
onError,
})
const patch = (part: Partial<SlotBody>) => setBody((prev) => ({ ...prev, ...part }))
const isContent = body.slotKind === 'Content'
return (
<div className="crt-panel flex flex-col gap-3 rounded-md p-4 text-sm">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold uppercase tracking-wide">
{draft.slot ? t('admin.channels.editSlot') : t('admin.channels.newSlot')}
</h3>
<Button size="sm" variant="ghost" onClick={onClose}>
×
</Button>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.slotTitle')}</Label>
<Input value={body.title} onChange={(e) => patch({ title: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.slotStart')}</Label>
<Input
type="time"
value={body.targetStart.slice(0, 5)}
onChange={(e) => patch({ targetStart: `${e.target.value}:00` })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.slotDuration')}</Label>
<Input
type="number"
min={1}
value={body.targetDurationMinutes}
onChange={(e) => patch({ targetDurationMinutes: Number(e.target.value) })}
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.weekday')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.weekday ?? 'any'}
onChange={(e) =>
patch({ weekday: e.target.value === 'any' ? null : Number(e.target.value) })
}
>
<option value="any">{t('admin.channels.everyDay')}</option>
{[1, 2, 3, 4, 5, 6, 0].map((day) => (
<option key={day} value={day}>
{t(`admin.channels.weekdays.${day}`)}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.daypart')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.daypart}
onChange={(e) => patch({ daypart: e.target.value as Daypart })}
>
{DAYPARTS.map((value) => (
<option key={value} value={value}>
{t(`admin.channels.dayparts.${value}`)}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.slotKind')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.slotKind}
onChange={(e) => {
const slotKind = e.target.value as SlotKind
patch({
slotKind,
// Повтору нужен источник, конец вещания не берёт контент вовсе.
repeatSource:
slotKind === 'Repeat'
? (body.repeatSource ?? { daysAgo: 1, time: '20:00:00', durationMinutes: 90 })
: null,
})
}}
>
{SLOT_KINDS.map((value) => (
<option key={value} value={value}>
{t(`admin.channels.slotKinds.${value}`)}
</option>
))}
</select>
</div>
</div>
{isContent && (
<>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.group')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.groupId ?? ''}
onChange={(e) => patch({ groupId: e.target.value || null })}
>
<option value="">{t('admin.channels.pickGroup')}</option>
{(groups ?? []).map((group) => (
<option key={group.id} value={group.id}>
{group.name} · {group.itemCount}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.strategy')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.strategy?.type ?? 'Sequential'}
onChange={(e) =>
patch({
strategy: {
...(body.strategy ?? {
restartOnEnd: true,
cooldownDays: 0,
fallback: 'OldestFirst',
type: 'Sequential',
}),
type: e.target.value as SlotStrategyType,
},
})
}
>
{STRATEGIES.map((value) => (
<option key={value} value={value}>
{t(`admin.channels.strategies.${value}`)}
</option>
))}
</select>
</div>
{body.strategy?.type === 'RandomWithCooldown' && (
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.cooldownDays')}</Label>
<Input
type="number"
min={0}
value={body.strategy.cooldownDays}
onChange={(e) =>
patch({
strategy: { ...body.strategy!, cooldownDays: Number(e.target.value) },
})
}
/>
<p className="text-xs text-muted-foreground">{t('admin.channels.cooldownHint')}</p>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.blockMode')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.blockMode}
onChange={(e) => patch({ blockMode: e.target.value as SlotBlockMode })}
>
{BLOCK_MODES.map((value) => (
<option key={value} value={value}>
{t(`admin.channels.blockModes.${value}`)}
</option>
))}
</select>
</div>
{body.blockMode !== 'FillSlot' && (
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.blockValue')}</Label>
<Input
type="number"
min={1}
value={body.blockValue}
onChange={(e) => patch({ blockValue: Number(e.target.value) })}
/>
</div>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.overflow')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.overflowPolicy}
onChange={(e) => patch({ overflowPolicy: e.target.value as OverflowPolicy })}
>
{OVERFLOW.map((value) => (
<option key={value} value={value}>
{t(`admin.channels.overflows.${value}`)}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">{t('admin.channels.overflowHint')}</p>
</div>
</>
)}
{body.slotKind === 'Repeat' && body.repeatSource && (
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.repeatDaysAgo')}</Label>
<Input
type="number"
min={1}
value={body.repeatSource.daysAgo}
onChange={(e) =>
patch({
repeatSource: { ...body.repeatSource!, daysAgo: Number(e.target.value) },
})
}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.repeatTime')}</Label>
<Input
type="time"
value={body.repeatSource.time.slice(0, 5)}
onChange={(e) =>
patch({ repeatSource: { ...body.repeatSource!, time: `${e.target.value}:00` } })
}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.repeatDuration')}</Label>
<Input
type="number"
min={1}
value={body.repeatSource.durationMinutes}
onChange={(e) =>
patch({
repeatSource: {
...body.repeatSource!,
durationMinutes: Number(e.target.value),
},
})
}
/>
</div>
</div>
)}
<div className="flex flex-wrap items-end gap-3">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={body.isAnchor}
onChange={(e) => patch({ isAnchor: e.target.checked })}
/>
{t('admin.channels.anchor')}
</label>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.maxDrift')}</Label>
<Input
type="number"
min={0}
className="w-24"
value={body.maxDriftMinutes}
onChange={(e) => patch({ maxDriftMinutes: Number(e.target.value) })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.snap')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.snapToMinutes ?? 0}
onChange={(e) =>
patch({ snapToMinutes: Number(e.target.value) === 0 ? null : Number(e.target.value) })
}
>
{SNAP_OPTIONS.map((value) => (
<option key={value} value={value}>
{value === 0 ? t('admin.channels.snapOff') : `${value}`}
</option>
))}
</select>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('admin.channels.anchorHint')}</p>
{/* Стыки: внутри слота — между единицами, после слота — на переходе к следующему. */}
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.junctionBetween')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.junctionBetweenId ?? ''}
onChange={(e) => patch({ junctionBetweenId: e.target.value || null })}
>
<option value="">{t('admin.channels.junctionDefault')}</option>
{(junctions ?? []).map((junction) => (
<option key={junction.id} value={junction.id}>
{junction.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.junctionAfter')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={body.junctionAfterId ?? ''}
onChange={(e) => patch({ junctionAfterId: e.target.value || null })}
>
<option value="">{t('admin.channels.junctionDefault')}</option>
{(junctions ?? []).map((junction) => (
<option key={junction.id} value={junction.id}>
{junction.name}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center justify-between gap-2">
{draft.slot ? (
<Button size="sm" variant="destructive" onClick={() => remove.mutate()}>
<Trash2 className="h-4 w-4" />
</Button>
) : (
<span />
)}
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</div>
)
}
@@ -1,148 +1,148 @@
import { useMutation } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { imageUrl } from '@/features/admin/images/api'
import { ImageGallery } from '@/features/admin/images/ImageGallery'
import type { ChannelDto, LogoCorner, ViewerSettings } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { updateViewerSettings } from '../api'
import { CollapsibleCard } from './CollapsibleCard'
const CORNERS: LogoCorner[] = ['TopLeft', 'TopRight', 'BottomLeft', 'BottomRight']
/**
* Как канал выглядит у зрителя (см. 6.8): логотип, часы, аналоговый фильтр. Всё рисуется на клиенте
* поверх видео и по умолчанию выключено — канал без логотипа и без шума остаётся нормальным каналом.
*/
export function ViewerCard({
channel,
bare,
onSaved,
onError,
}: Readonly<{
channel: ChannelDto
bare?: boolean
onSaved: () => void
onError: (error: unknown) => void
}>) {
const { t } = useTranslation()
const [viewer, setViewer] = useState<ViewerSettings>(channel.viewer)
const [galleryOpen, setGalleryOpen] = useState(false)
useEffect(() => setViewer(channel.viewer), [channel])
const patch = (part: Partial<ViewerSettings>) => setViewer((prev) => ({ ...prev, ...part }))
const save = useMutation({
mutationFn: () => updateViewerSettings(channel.id, viewer),
onSuccess: onSaved,
onError,
})
return (
<CollapsibleCard title={t('admin.channels.viewer')} bare={bare}>
<div className="flex flex-col gap-4 text-sm">
<p className="text-xs text-muted-foreground">{t('admin.channels.viewerHint')}</p>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.logo')}</Label>
<div className="flex h-16 w-24 items-center justify-center rounded-md border border-border bg-muted/30">
{viewer.logoImageId ? (
<img
src={imageUrl(viewer.logoImageId)}
alt=""
className="h-full w-full object-contain"
/>
) : (
<span className="text-xs text-muted-foreground">{t('admin.channels.noLogo')}</span>
)}
</div>
</div>
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
{t('admin.channels.pickLogo')}
</Button>
{viewer.logoImageId && (
<Button size="sm" variant="ghost" onClick={() => patch({ logoImageId: null })}>
{t('common.delete')}
</Button>
)}
<ImageGallery
open={galleryOpen}
onOpenChange={setGalleryOpen}
category="Library"
onSelect={(image) => patch({ logoImageId: image.id })}
/>
</div>
{viewer.logoImageId && (
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.logoCorner')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={viewer.logoCorner}
onChange={(e) => patch({ logoCorner: e.target.value as LogoCorner })}
>
{CORNERS.map((corner) => (
<option key={corner} value={corner}>
{t(`admin.channels.corners.${corner}`)}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.logoOpacity')}</Label>
<Input
type="number"
min={0}
max={1}
step={0.05}
className="w-28"
value={viewer.logoOpacity}
onChange={(e) => patch({ logoOpacity: clamp01(e.target.value) })}
/>
</div>
</div>
)}
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={viewer.showClock}
onChange={(e) => patch({ showClock: e.target.checked })}
/>
{t('admin.channels.showClock')}
</label>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.analogFilter')}</Label>
<Input
type="number"
min={0}
max={1}
step={0.1}
className="w-28"
value={viewer.analogFilterStrength}
onChange={(e) => patch({ analogFilterStrength: clamp01(e.target.value) })}
/>
<p className="text-xs text-muted-foreground">{t('admin.channels.analogFilterHint')}</p>
</div>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</div>
</CollapsibleCard>
)
}
/** Сила и прозрачность живут в 0..1: пустой ввод трактуем как ноль, а не как NaN. */
function clamp01(value: string): number {
const n = Number(value)
return Number.isNaN(n) ? 0 : Math.min(1, Math.max(0, n))
}
import { useMutation } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { imageUrl } from '@/features/admin/images/api'
import { ImageGallery } from '@/features/admin/images/ImageGallery'
import type { ChannelDto, LogoCorner, ViewerSettings } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { updateViewerSettings } from '../api'
import { CollapsibleCard } from './CollapsibleCard'
const CORNERS: LogoCorner[] = ['TopLeft', 'TopRight', 'BottomLeft', 'BottomRight']
/**
* Как канал выглядит у зрителя (см. 6.8): логотип, часы, аналоговый фильтр. Всё рисуется на клиенте
* поверх видео и по умолчанию выключено — канал без логотипа и без шума остаётся нормальным каналом.
*/
export function ViewerCard({
channel,
bare,
onSaved,
onError,
}: Readonly<{
channel: ChannelDto
bare?: boolean
onSaved: () => void
onError: (error: unknown) => void
}>) {
const { t } = useTranslation()
const [viewer, setViewer] = useState<ViewerSettings>(channel.viewer)
const [galleryOpen, setGalleryOpen] = useState(false)
useEffect(() => setViewer(channel.viewer), [channel])
const patch = (part: Partial<ViewerSettings>) => setViewer((prev) => ({ ...prev, ...part }))
const save = useMutation({
mutationFn: () => updateViewerSettings(channel.id, viewer),
onSuccess: onSaved,
onError,
})
return (
<CollapsibleCard title={t('admin.channels.viewer')} bare={bare}>
<div className="flex flex-col gap-4 text-sm">
<p className="text-xs text-muted-foreground">{t('admin.channels.viewerHint')}</p>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.logo')}</Label>
<div className="flex h-16 w-24 items-center justify-center rounded-md border border-border bg-muted/30">
{viewer.logoImageId ? (
<img
src={imageUrl(viewer.logoImageId)}
alt=""
className="h-full w-full object-contain"
/>
) : (
<span className="text-xs text-muted-foreground">{t('admin.channels.noLogo')}</span>
)}
</div>
</div>
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
{t('admin.channels.pickLogo')}
</Button>
{viewer.logoImageId && (
<Button size="sm" variant="ghost" onClick={() => patch({ logoImageId: null })}>
{t('common.delete')}
</Button>
)}
<ImageGallery
open={galleryOpen}
onOpenChange={setGalleryOpen}
category="Library"
onSelect={(image) => patch({ logoImageId: image.id })}
/>
</div>
{viewer.logoImageId && (
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.logoCorner')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={viewer.logoCorner}
onChange={(e) => patch({ logoCorner: e.target.value as LogoCorner })}
>
{CORNERS.map((corner) => (
<option key={corner} value={corner}>
{t(`admin.channels.corners.${corner}`)}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.logoOpacity')}</Label>
<Input
type="number"
min={0}
max={1}
step={0.05}
className="w-28"
value={viewer.logoOpacity}
onChange={(e) => patch({ logoOpacity: clamp01(e.target.value) })}
/>
</div>
</div>
)}
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={viewer.showClock}
onChange={(e) => patch({ showClock: e.target.checked })}
/>
{t('admin.channels.showClock')}
</label>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.analogFilter')}</Label>
<Input
type="number"
min={0}
max={1}
step={0.1}
className="w-28"
value={viewer.analogFilterStrength}
onChange={(e) => patch({ analogFilterStrength: clamp01(e.target.value) })}
/>
<p className="text-xs text-muted-foreground">{t('admin.channels.analogFilterHint')}</p>
</div>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</div>
</CollapsibleCard>
)
}
/** Сила и прозрачность живут в 0..1: пустой ввод трактуем как ноль, а не как NaN. */
function clamp01(value: string): number {
const n = Number(value)
return Number.isNaN(n) ? 0 : Math.min(1, Math.max(0, n))
}