diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index 52b0238..1f3ad52 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -11,19 +11,13 @@ import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' import { Card, CardContent } from '@/shared/ui/card' import { toast } from '@/shared/ui/toast-store' -import { - applyChannelTemplate, - getChannel, - getChannelTemplate, - getSchedule, - restoreChannelTemplate, -} from './api' +import { applyChannelTemplate, getChannel, getChannelTemplate, restoreChannelTemplate } from './api' +import { AirSchedule } from './components/AirSchedule' import { ApplyDialog } from './components/ApplyDialog' import { EntryTraceDialog } from './components/EntryTraceDialog' import { GridTab } from './components/GridTab' import { JunctionsCard } from './components/JunctionsCard' import { RulesCard } from './components/RulesCard' -import { SchedulePreview } from './components/SchedulePreview' import { SettingsCard } from './components/SettingsCard' import { ViewerCard } from './components/ViewerCard' @@ -50,13 +44,11 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) { queryKey: qk.media.ready, queryFn: () => listAllMedia({ statuses: ['Ready'] }), }) - const { data: schedule } = useQuery({ - queryKey: qk.channels.schedule(channelId), - queryFn: () => getSchedule(channelId, new Date(), new Date(Date.now() + 12 * 3_600_000)), - }) - const invalidate = () => { void queryClient.invalidateQueries({ queryKey: qk.channels.detail(channelId) }) + // Применение пересобирает хвост ленты — открытые сутки эфира надо перечитать, иначе на + // вкладке останется то, что уже заменено. + void queryClient.invalidateQueries({ queryKey: qk.channels.schedule(channelId) }) } const onError = useApiError() @@ -190,7 +182,12 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) { {tab === 'air' && ( - + )} diff --git a/frontend/src/features/admin/channels/components/AirSchedule.tsx b/frontend/src/features/admin/channels/components/AirSchedule.tsx new file mode 100644 index 0000000..4b0740b --- /dev/null +++ b/frontend/src/features/admin/channels/components/AirSchedule.tsx @@ -0,0 +1,113 @@ +import { useQuery } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { qk } from '@/shared/api/query-keys' +import { Button } from '@/shared/ui/button' +import { Pager } from '@/shared/ui/pager' +import { getSchedule } from '../api' +import { SchedulePreview } from './SchedulePreview' + +/** Сколько вещательных суток показываем: столько же, на сколько по умолчанию строится лента. */ +const DAYS = 7 + +const PAGE_SIZE = 50 + +/** «06:00:00» → минуты от полуночи. */ +function minutesOf(time: string): number { + const [hours, minutes] = time.split(':').map(Number) + return (hours || 0) * 60 + (minutes || 0) +} + +/** + * Начало вещательных суток номер `index`, считая от текущих, в UTC. Сутки канала начинаются + * не в полночь, поэтому «сегодня» до `dayStartTime` — это ещё вчерашний эфир. + */ +function dayStartUtc(index: number, utcOffsetMinutes: number, dayStartTime: string): Date { + const offset = utcOffsetMinutes * 60_000 + const local = new Date(Date.now() + offset) + + const midnight = Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate()) + const start = midnight + minutesOf(dayStartTime) * 60_000 + const today = local.getTime() >= start ? start : start - 24 * 3_600_000 + + return new Date(today + index * 24 * 3_600_000 - offset) +} + +/** + * Эфир канала: вещательные сутки выбираются кнопками, внутри суток лента листается страницами. + * + * Грузим сутки, а не всю неделю разом: неделя эфира — это тысячи записей вместе с рекламой + * и заставками, а смотрят их всегда по дням, как программу передач. + */ +export function AirSchedule({ + channelId, + utcOffsetMinutes, + dayStartTime, + onShowTrace, +}: Readonly<{ + channelId: string + utcOffsetMinutes: number + /** Начало вещательных суток во времени канала («06:00:00»). */ + dayStartTime: string + onShowTrace: (entryId: string) => void +}>) { + const { t } = useTranslation() + const [day, setDay] = useState(0) + const [page, setPage] = useState(1) + + const range = useMemo(() => { + const from = dayStartUtc(day, utcOffsetMinutes, dayStartTime) + return { from, to: new Date(from.getTime() + 24 * 3_600_000) } + }, [day, utcOffsetMinutes, dayStartTime]) + + const { data, isLoading } = useQuery({ + queryKey: qk.channels.scheduleDay(channelId, range.from.toISOString()), + queryFn: () => getSchedule(channelId, range.from, range.to), + }) + + const entries = data ?? [] + const totalPages = Math.max(1, Math.ceil(entries.length / PAGE_SIZE)) + const pageItems = entries.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) + + return ( +
+
+ {Array.from({ length: DAYS }, (_, index) => { + const start = dayStartUtc(index, utcOffsetMinutes, dayStartTime) + // Подпись — дата во времени канала: сетка задаётся в нём, и часовой пояс админа тут + // только сбивал бы. + const label = new Date(start.getTime() + utcOffsetMinutes * 60_000).toLocaleDateString( + [], + { weekday: 'short', day: '2-digit', month: '2-digit', timeZone: 'UTC' }, + ) + + return ( + + ) + })} +
+ + {isLoading ? ( +

{t('common.loading')}

+ ) : ( + <> + +
+ {t('admin.channels.airCount', { count: entries.length })} + +
+ + )} +
+ ) +} diff --git a/frontend/src/features/admin/channels/components/SchedulePreview.tsx b/frontend/src/features/admin/channels/components/SchedulePreview.tsx index 4eb0dc9..70b324c 100644 --- a/frontend/src/features/admin/channels/components/SchedulePreview.tsx +++ b/frontend/src/features/admin/channels/components/SchedulePreview.tsx @@ -64,7 +64,7 @@ export function SchedulePreview({ return (