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 (
+ {
+ setDay(index)
+ setPage(1)
+ }}
+ >
+ {index === 0 ? t('admin.channels.airToday') : label}
+
+ )
+ })}
+
+
+ {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 (
- {entries.slice(0, 40).map((e) => (
+ {entries.map((e) => (
{formatTime(e.startsAtUtc)}
diff --git a/frontend/src/features/admin/media/MediaImportDialog.tsx b/frontend/src/features/admin/media/MediaImportDialog.tsx
index 316efaf..a27f941 100644
--- a/frontend/src/features/admin/media/MediaImportDialog.tsx
+++ b/frontend/src/features/admin/media/MediaImportDialog.tsx
@@ -11,19 +11,22 @@ import { ManualInboxPanel } from './ManualInboxPanel'
import { MovieImportPanel } from './MovieImportPanel'
import { UploadToShowPanel } from './UploadToShowPanel'
-/** Способы пополнить библиотеку: фильмы пачкой, серии из manual/, загрузка файлов в шоу. */
-type Tab = 'movies' | 'manual' | 'toShow'
+/** Способы пополнить библиотеку: серии из manual/, фильмы пачкой, загрузка файлов в шоу. */
+type Tab = 'manual' | 'movies' | 'toShow'
/**
- * Одно окно на все способы завести медиа: фильмы пачкой, серии из `manual/` в шоу и загрузка
+ * Одно окно на все способы завести медиа: серии из `manual/` в шоу, фильмы пачкой и загрузка
* выбранных файлов в шоу. Раньше это были три кнопки и три окна — а выбор между ними делается
* один раз и по одному признаку: что за контент кладём.
+ *
+ * Первой открывается вкладка серий: сериалами библиотека пополняется чаще, и попасть в неё сразу
+ * важнее, чем в разбор фильмов, который делают раз на пачку.
*/
export function MediaImportDialog({ onClose }: Readonly<{ onClose: () => void }>) {
const { t } = useTranslation()
- const [tab, setTab] = useState('movies')
+ const [tab, setTab] = useState('manual')
- const tabs: Tab[] = ['movies', 'manual', 'toShow']
+ const tabs: Tab[] = ['manual', 'movies', 'toShow']
return (
!open && onClose()}>
diff --git a/frontend/src/shared/api/query-keys.ts b/frontend/src/shared/api/query-keys.ts
index 1ea76a2..e780f53 100644
--- a/frontend/src/shared/api/query-keys.ts
+++ b/frontend/src/shared/api/query-keys.ts
@@ -20,6 +20,7 @@ export const qk = {
detail: (id: string) => ['admin', 'channels', id] as const,
template: (id: string) => ['admin', 'channels', id, 'template'] as const,
schedule: (id: string) => ['admin', 'channels', id, 'schedule'] as const,
+ scheduleDay: (id: string, from: string) => ['admin', 'channels', id, 'schedule', from] as const,
issues: (id: string) => ['admin', 'channels', id, 'issues'] as const,
diff: (id: string) => ['admin', 'channels', id, 'diff'] as const,
preview: (id: string, days: number) => ['admin', 'channels', id, 'preview', days] as const,
diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts
index 0b08729..e10ba9d 100644
--- a/frontend/src/shared/lib/locales/en.ts
+++ b/frontend/src/shared/lib/locales/en.ts
@@ -281,7 +281,7 @@ export const en = {
importButton: 'Import',
importTitle: 'Media import',
importHint:
- 'Three ways to fill the library: movies in bulk, episodes from the manual folder into a show, and uploading picked files into a show.',
+ 'Three ways to fill the library: episodes from the manual folder into a show, movies in bulk, and uploading picked files into a show.',
importTabs: {
movies: 'Movies',
manual: 'Episodes from manual',
@@ -733,6 +733,8 @@ export const en = {
filler: 'Filler',
noFiller: 'No filler',
noSchedule: 'Schedule not built yet',
+ airToday: 'Today',
+ airCount: 'Entries for the day: {{count}}',
},
junctions: {
title: 'Junctions',
diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts
index 0f31435..1540276 100644
--- a/frontend/src/shared/lib/locales/ru.ts
+++ b/frontend/src/shared/lib/locales/ru.ts
@@ -282,7 +282,7 @@ export const ru = {
importButton: 'Импорт',
importTitle: 'Импорт медиа',
importHint:
- 'Три способа пополнить библиотеку: фильмы пачкой, серии из папки manual в шоу и загрузка выбранных файлов в шоу.',
+ 'Три способа пополнить библиотеку: серии из папки manual в шоу, фильмы пачкой и загрузка выбранных файлов в шоу.',
importTabs: { movies: 'Фильмы', manual: 'Серии из manual', toShow: 'Файлы в шоу' },
moviesButton: 'Фильмы',
manualTitle: 'Ручной разбор папки manual',
@@ -728,6 +728,8 @@ export const ru = {
filler: 'Заглушка',
noFiller: 'Без заглушки',
noSchedule: 'Расписание ещё не построено',
+ airToday: 'Сегодня',
+ airCount: 'Записей за сутки: {{count}}',
},
junctions: {
title: 'Стыки',