Refactor ChannelDetail and MediaImportDialog components for improved functionality and clarity
Consolidated imports in ChannelDetail and replaced the SchedulePreview component with AirSchedule for enhanced scheduling display. Updated the MediaImportDialog to prioritize the manual tab and adjusted localization strings for clarity in both English and Russian. These changes aim to streamline user interactions and improve the overall user experience in managing channels and media imports.
This commit is contained in:
@@ -11,19 +11,13 @@ import { Badge } from '@/shared/ui/badge'
|
|||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Card, CardContent } from '@/shared/ui/card'
|
import { Card, CardContent } from '@/shared/ui/card'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import {
|
import { applyChannelTemplate, getChannel, getChannelTemplate, restoreChannelTemplate } from './api'
|
||||||
applyChannelTemplate,
|
import { AirSchedule } from './components/AirSchedule'
|
||||||
getChannel,
|
|
||||||
getChannelTemplate,
|
|
||||||
getSchedule,
|
|
||||||
restoreChannelTemplate,
|
|
||||||
} from './api'
|
|
||||||
import { ApplyDialog } from './components/ApplyDialog'
|
import { ApplyDialog } from './components/ApplyDialog'
|
||||||
import { EntryTraceDialog } from './components/EntryTraceDialog'
|
import { EntryTraceDialog } from './components/EntryTraceDialog'
|
||||||
import { GridTab } from './components/GridTab'
|
import { GridTab } from './components/GridTab'
|
||||||
import { JunctionsCard } from './components/JunctionsCard'
|
import { JunctionsCard } from './components/JunctionsCard'
|
||||||
import { RulesCard } from './components/RulesCard'
|
import { RulesCard } from './components/RulesCard'
|
||||||
import { SchedulePreview } from './components/SchedulePreview'
|
|
||||||
import { SettingsCard } from './components/SettingsCard'
|
import { SettingsCard } from './components/SettingsCard'
|
||||||
import { ViewerCard } from './components/ViewerCard'
|
import { ViewerCard } from './components/ViewerCard'
|
||||||
|
|
||||||
@@ -50,13 +44,11 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) {
|
|||||||
queryKey: qk.media.ready,
|
queryKey: qk.media.ready,
|
||||||
queryFn: () => listAllMedia({ statuses: ['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 = () => {
|
const invalidate = () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: qk.channels.detail(channelId) })
|
void queryClient.invalidateQueries({ queryKey: qk.channels.detail(channelId) })
|
||||||
|
// Применение пересобирает хвост ленты — открытые сутки эфира надо перечитать, иначе на
|
||||||
|
// вкладке останется то, что уже заменено.
|
||||||
|
void queryClient.invalidateQueries({ queryKey: qk.channels.schedule(channelId) })
|
||||||
}
|
}
|
||||||
const onError = useApiError()
|
const onError = useApiError()
|
||||||
|
|
||||||
@@ -190,7 +182,12 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) {
|
|||||||
{tab === 'air' && (
|
{tab === 'air' && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<SchedulePreview entries={schedule ?? []} onShowTrace={setTraceEntryId} />
|
<AirSchedule
|
||||||
|
channelId={channelId}
|
||||||
|
utcOffsetMinutes={channel.utcOffsetMinutes}
|
||||||
|
dayStartTime={channel.dayStartTime}
|
||||||
|
onShowTrace={setTraceEntryId}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{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 (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
size="sm"
|
||||||
|
variant={index === day ? 'default' : 'outline'}
|
||||||
|
onClick={() => {
|
||||||
|
setDay(index)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{index === 0 ? t('admin.channels.airToday') : label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-muted-foreground">{t('common.loading')}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SchedulePreview entries={pageItems} onShowTrace={onShowTrace} />
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{t('admin.channels.airCount', { count: entries.length })}</span>
|
||||||
|
<Pager page={page} totalPages={totalPages} onChange={setPage} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ export function SchedulePreview({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="flex flex-col divide-y divide-border text-sm">
|
<ul className="flex flex-col divide-y divide-border text-sm">
|
||||||
{entries.slice(0, 40).map((e) => (
|
{entries.map((e) => (
|
||||||
<li key={e.id} className="group flex items-center gap-3 py-1.5">
|
<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">
|
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
||||||
{formatTime(e.startsAtUtc)}
|
{formatTime(e.startsAtUtc)}
|
||||||
|
|||||||
@@ -11,19 +11,22 @@ import { ManualInboxPanel } from './ManualInboxPanel'
|
|||||||
import { MovieImportPanel } from './MovieImportPanel'
|
import { MovieImportPanel } from './MovieImportPanel'
|
||||||
import { UploadToShowPanel } from './UploadToShowPanel'
|
import { UploadToShowPanel } from './UploadToShowPanel'
|
||||||
|
|
||||||
/** Способы пополнить библиотеку: фильмы пачкой, серии из manual/, загрузка файлов в шоу. */
|
/** Способы пополнить библиотеку: серии из manual/, фильмы пачкой, загрузка файлов в шоу. */
|
||||||
type Tab = 'movies' | 'manual' | 'toShow'
|
type Tab = 'manual' | 'movies' | 'toShow'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Одно окно на все способы завести медиа: фильмы пачкой, серии из `manual/` в шоу и загрузка
|
* Одно окно на все способы завести медиа: серии из `manual/` в шоу, фильмы пачкой и загрузка
|
||||||
* выбранных файлов в шоу. Раньше это были три кнопки и три окна — а выбор между ними делается
|
* выбранных файлов в шоу. Раньше это были три кнопки и три окна — а выбор между ними делается
|
||||||
* один раз и по одному признаку: что за контент кладём.
|
* один раз и по одному признаку: что за контент кладём.
|
||||||
|
*
|
||||||
|
* Первой открывается вкладка серий: сериалами библиотека пополняется чаще, и попасть в неё сразу
|
||||||
|
* важнее, чем в разбор фильмов, который делают раз на пачку.
|
||||||
*/
|
*/
|
||||||
export function MediaImportDialog({ onClose }: Readonly<{ onClose: () => void }>) {
|
export function MediaImportDialog({ onClose }: Readonly<{ onClose: () => void }>) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [tab, setTab] = useState<Tab>('movies')
|
const [tab, setTab] = useState<Tab>('manual')
|
||||||
|
|
||||||
const tabs: Tab[] = ['movies', 'manual', 'toShow']
|
const tabs: Tab[] = ['manual', 'movies', 'toShow']
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const qk = {
|
|||||||
detail: (id: string) => ['admin', 'channels', id] as const,
|
detail: (id: string) => ['admin', 'channels', id] as const,
|
||||||
template: (id: string) => ['admin', 'channels', id, 'template'] as const,
|
template: (id: string) => ['admin', 'channels', id, 'template'] as const,
|
||||||
schedule: (id: string) => ['admin', 'channels', id, 'schedule'] 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,
|
issues: (id: string) => ['admin', 'channels', id, 'issues'] as const,
|
||||||
diff: (id: string) => ['admin', 'channels', id, 'diff'] as const,
|
diff: (id: string) => ['admin', 'channels', id, 'diff'] as const,
|
||||||
preview: (id: string, days: number) => ['admin', 'channels', id, 'preview', days] as const,
|
preview: (id: string, days: number) => ['admin', 'channels', id, 'preview', days] as const,
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ export const en = {
|
|||||||
importButton: 'Import',
|
importButton: 'Import',
|
||||||
importTitle: 'Media import',
|
importTitle: 'Media import',
|
||||||
importHint:
|
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: {
|
importTabs: {
|
||||||
movies: 'Movies',
|
movies: 'Movies',
|
||||||
manual: 'Episodes from manual',
|
manual: 'Episodes from manual',
|
||||||
@@ -733,6 +733,8 @@ export const en = {
|
|||||||
filler: 'Filler',
|
filler: 'Filler',
|
||||||
noFiller: 'No filler',
|
noFiller: 'No filler',
|
||||||
noSchedule: 'Schedule not built yet',
|
noSchedule: 'Schedule not built yet',
|
||||||
|
airToday: 'Today',
|
||||||
|
airCount: 'Entries for the day: {{count}}',
|
||||||
},
|
},
|
||||||
junctions: {
|
junctions: {
|
||||||
title: 'Junctions',
|
title: 'Junctions',
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ export const ru = {
|
|||||||
importButton: 'Импорт',
|
importButton: 'Импорт',
|
||||||
importTitle: 'Импорт медиа',
|
importTitle: 'Импорт медиа',
|
||||||
importHint:
|
importHint:
|
||||||
'Три способа пополнить библиотеку: фильмы пачкой, серии из папки manual в шоу и загрузка выбранных файлов в шоу.',
|
'Три способа пополнить библиотеку: серии из папки manual в шоу, фильмы пачкой и загрузка выбранных файлов в шоу.',
|
||||||
importTabs: { movies: 'Фильмы', manual: 'Серии из manual', toShow: 'Файлы в шоу' },
|
importTabs: { movies: 'Фильмы', manual: 'Серии из manual', toShow: 'Файлы в шоу' },
|
||||||
moviesButton: 'Фильмы',
|
moviesButton: 'Фильмы',
|
||||||
manualTitle: 'Ручной разбор папки manual',
|
manualTitle: 'Ручной разбор папки manual',
|
||||||
@@ -728,6 +728,8 @@ export const ru = {
|
|||||||
filler: 'Заглушка',
|
filler: 'Заглушка',
|
||||||
noFiller: 'Без заглушки',
|
noFiller: 'Без заглушки',
|
||||||
noSchedule: 'Расписание ещё не построено',
|
noSchedule: 'Расписание ещё не построено',
|
||||||
|
airToday: 'Сегодня',
|
||||||
|
airCount: 'Записей за сутки: {{count}}',
|
||||||
},
|
},
|
||||||
junctions: {
|
junctions: {
|
||||||
title: 'Стыки',
|
title: 'Стыки',
|
||||||
|
|||||||
Reference in New Issue
Block a user