Add Prettier to the frontend and gate formatting in CI
Форматтера у фронтенда не было: стиль держался вручную и успел разъехаться в 50 файлах. Ставим Prettier с настройками под уже сложившийся стиль (без точек с запятой, одинарные кавычки, ширина 100 — подобрана замером: при 100 расходится меньше файлов, чем при 96 или 110) и прогоняем его по коду. `src/routeTree.gen.ts` исключён — его переписывает плагин роутера. Чтобы форматирование больше не расходилось незаметно, добавлены проверки в CI: `csharpier check` для бэкенда (его отсутствие и позволило накопиться 79 неотформатированным файлам) и `prettier --check` для фронтенда. Версии форматтеров прибиты точно, без кареток: минорка меняет вывод и красит CI на файлах, которых никто не трогал. `.editorconfig` задаёт редакторам те же отступы и LF ещё до форматтера; значения совпадают с настройками csharpier и Prettier намеренно — оба его читают. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0442056367
commit
0606ea3e6e
@@ -11,12 +11,7 @@ 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,
|
||||
} from './api'
|
||||
import { applyChannelTemplate, getChannel, getChannelTemplate, getSchedule } from './api'
|
||||
import { ApplyDialog } from './components/ApplyDialog'
|
||||
import { BumperCard } from './components/BumperCard'
|
||||
import { EntryTraceDialog } from './components/EntryTraceDialog'
|
||||
|
||||
@@ -1,371 +1,375 @@
|
||||
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
ApplyResultDto,
|
||||
BumperSettings,
|
||||
BumperTextKind,
|
||||
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() {
|
||||
return apiRequest<ChannelSummaryDto[]>('/admin/channels')
|
||||
}
|
||||
|
||||
export function getChannel(id: string) {
|
||||
return apiRequest<ChannelDto>(`/admin/channels/${id}`)
|
||||
}
|
||||
|
||||
export function createChannel(body: { name: string; slug: string }) {
|
||||
return apiRequest<CreatedIdResponse>('/admin/channels', { method: 'POST', body })
|
||||
}
|
||||
|
||||
type ChannelSettingsBody = {
|
||||
name: string
|
||||
isEnabled: boolean
|
||||
bumpersEnabled: boolean
|
||||
bumper: BumperSettings
|
||||
fillerAssetId: string | null
|
||||
}
|
||||
|
||||
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,
|
||||
body: { number: number | null; utcOffsetMinutes: number; dayStartTime: string },
|
||||
) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/time`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
// ── Сетка канала ──────────────────────────────────────────────────────────
|
||||
|
||||
export function getChannelTemplate(channelId: string) {
|
||||
return apiRequest<ScheduleTemplateDto>(`/admin/channels/${channelId}/template`)
|
||||
}
|
||||
|
||||
/** Заводит каналу сетку, если её нет: у каналов из старой ротации шаблона может не быть. */
|
||||
export function createChannelTemplate(channelId: string) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${channelId}/template`, { method: 'POST' })
|
||||
}
|
||||
|
||||
/** Применяет правила к эфиру: пересобирает будущий хвост. Правка слотов эфир не двигает. */
|
||||
export function applyChannelTemplate(channelId: string) {
|
||||
return apiRequest<ApplyResultDto>(`/admin/channels/${channelId}/template/apply`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/** Проверки сетки по правилам — считаются по шаблону, без прогона генератора. */
|
||||
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) })
|
||||
return apiRequest<SchedulePreviewDto>(
|
||||
`/admin/channels/${channelId}/template/preview?${query.toString()}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function updateTemplate(
|
||||
templateId: string,
|
||||
body: {
|
||||
name: string
|
||||
fallbackGroupId: string | null
|
||||
defaultJunctionId: string | null
|
||||
rules: PlanningRules | null
|
||||
},
|
||||
) {
|
||||
return apiRequest<void>(`/admin/templates/${templateId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function createLayer(templateId: string, body: { name: string; priority: number }) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/templates/${templateId}/layers`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateLayer(
|
||||
layerId: string,
|
||||
body: {
|
||||
name: string
|
||||
priority: number
|
||||
applicability: LayerApplicability | null
|
||||
isEnabled: boolean
|
||||
},
|
||||
) {
|
||||
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function deleteLayer(layerId: string) {
|
||||
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Тело слота: то же для создания и правки (см. SlotInput на сервере). */
|
||||
export type SlotBody = Omit<SlotDto, 'id' | 'layerId' | 'groupName'>
|
||||
|
||||
/** Слот из ответа сервера → тело запроса: отбрасываем то, что сервер проставляет сам. */
|
||||
export function toSlotBody(slot: SlotDto): SlotBody {
|
||||
const { id: _id, layerId: _layerId, groupName: _groupName, ...body } = slot
|
||||
return body
|
||||
}
|
||||
|
||||
export function createSlot(layerId: string, body: SlotBody) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/layers/${layerId}/slots`, { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateSlot(slotId: string, body: SlotBody) {
|
||||
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function deleteSlot(slotId: string) {
|
||||
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ── Стыки канала ──────────────────────────────────────────────────────────
|
||||
|
||||
export function listJunctions(channelId: string) {
|
||||
return apiRequest<JunctionTemplateDto[]>(`/admin/channels/${channelId}/junctions`)
|
||||
}
|
||||
|
||||
export function createJunction(channelId: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${channelId}/junctions`, {
|
||||
method: 'POST',
|
||||
body: { name },
|
||||
})
|
||||
}
|
||||
|
||||
export function renameJunction(junctionId: string, name: string) {
|
||||
return apiRequest<void>(`/admin/junctions/${junctionId}`, { method: 'PUT', body: { name } })
|
||||
}
|
||||
|
||||
export function deleteJunction(junctionId: string) {
|
||||
return apiRequest<void>(`/admin/junctions/${junctionId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function addJunctionElement(junctionId: string, kind: JunctionElementKind) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/junctions/${junctionId}/elements`, {
|
||||
method: 'POST',
|
||||
body: { kind },
|
||||
})
|
||||
}
|
||||
|
||||
/** Тело врезки: то же для любого типа — лишние поля сервер обнуляет сам (см. JunctionElement.Update). */
|
||||
export type JunctionElementBody = {
|
||||
kind: JunctionElementKind
|
||||
groupId: string | null
|
||||
bumperTemplateId: string | null
|
||||
amountMode: JunctionAmountMode
|
||||
amountValue: number
|
||||
isRequired: boolean
|
||||
conditions: JunctionConditions | null
|
||||
}
|
||||
|
||||
export function updateJunctionElement(
|
||||
junctionId: string,
|
||||
elementId: string,
|
||||
body: JunctionElementBody,
|
||||
) {
|
||||
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function removeJunctionElement(junctionId: string, elementId: string) {
|
||||
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Порядок врезок: не упомянутые остаются после перечисленных. */
|
||||
export function reorderJunction(junctionId: string, elementIdsInOrder: string[]) {
|
||||
return apiRequest<void>(`/admin/junctions/${junctionId}/order`, {
|
||||
method: 'PUT',
|
||||
body: { elementIdsInOrder },
|
||||
})
|
||||
}
|
||||
|
||||
type BumperTemplateStyleBody = {
|
||||
name: string
|
||||
backgroundColor: string
|
||||
backgroundColor2: string
|
||||
accentColor: string
|
||||
textColor: string
|
||||
}
|
||||
|
||||
export function addBumperTemplate(id: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/bumper/templates`, {
|
||||
method: 'POST',
|
||||
body: { name },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateBumperTemplate(id: string, templateId: string, body: BumperTemplateStyleBody) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function removeBumperTemplate(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */
|
||||
function uploadBumperTemplateFile(
|
||||
id: string,
|
||||
templateId: string,
|
||||
kind: 'audio' | 'background',
|
||||
file: File,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const query = new URLSearchParams({ fileName: file.name })
|
||||
xhr.open(
|
||||
'PUT',
|
||||
`/api/admin/channels/${id}/bumper/templates/${templateId}/${kind}?${query.toString()}`,
|
||||
)
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve()
|
||||
} else {
|
||||
let detail = `HTTP ${xhr.status}`
|
||||
try {
|
||||
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
|
||||
detail = problem.detail ?? problem.title ?? detail
|
||||
} catch {
|
||||
/* пусто */
|
||||
}
|
||||
reject(new HttpError({ detail }, xhr.status))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) {
|
||||
return uploadBumperTemplateFile(id, templateId, 'audio', file)
|
||||
}
|
||||
|
||||
type BumperVariantBody = {
|
||||
name: string
|
||||
kind: BumperTextKind
|
||||
nowLabel: string
|
||||
nextLabel: string
|
||||
line1: string
|
||||
line2: string
|
||||
trigger: BumperTrigger
|
||||
weight: number
|
||||
}
|
||||
|
||||
export function addBumperVariant(id: string, templateId: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants`,
|
||||
{ method: 'POST', body: { name } },
|
||||
)
|
||||
}
|
||||
|
||||
export function updateBumperVariant(
|
||||
id: string,
|
||||
templateId: string,
|
||||
variantId: string,
|
||||
body: BumperVariantBody,
|
||||
) {
|
||||
return apiRequest<void>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||
{ method: 'PUT', body },
|
||||
)
|
||||
}
|
||||
|
||||
export function removeBumperVariant(id: string, templateId: string, variantId: string) {
|
||||
return apiRequest<void>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
}
|
||||
|
||||
/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */
|
||||
export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||
method: 'PUT',
|
||||
body: { imageId },
|
||||
})
|
||||
}
|
||||
|
||||
export function clearBumperTemplateAudio(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function clearBumperTemplateBackground(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */
|
||||
export function renderBumperPreviews(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function bumperPreviewPlaylistUrl(id: string, templateId: string, variantId: string) {
|
||||
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/${variantId}/index.m3u8`
|
||||
}
|
||||
|
||||
export function getSchedule(id: string, from: Date, to: Date) {
|
||||
const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() })
|
||||
return apiRequest<ScheduleEntryDto[]>(`/admin/channels/${id}/schedule?${query.toString()}`)
|
||||
}
|
||||
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
ApplyResultDto,
|
||||
BumperSettings,
|
||||
BumperTextKind,
|
||||
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() {
|
||||
return apiRequest<ChannelSummaryDto[]>('/admin/channels')
|
||||
}
|
||||
|
||||
export function getChannel(id: string) {
|
||||
return apiRequest<ChannelDto>(`/admin/channels/${id}`)
|
||||
}
|
||||
|
||||
export function createChannel(body: { name: string; slug: string }) {
|
||||
return apiRequest<CreatedIdResponse>('/admin/channels', { method: 'POST', body })
|
||||
}
|
||||
|
||||
type ChannelSettingsBody = {
|
||||
name: string
|
||||
isEnabled: boolean
|
||||
bumpersEnabled: boolean
|
||||
bumper: BumperSettings
|
||||
fillerAssetId: string | null
|
||||
}
|
||||
|
||||
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,
|
||||
body: { number: number | null; utcOffsetMinutes: number; dayStartTime: string },
|
||||
) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/time`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
// ── Сетка канала ──────────────────────────────────────────────────────────
|
||||
|
||||
export function getChannelTemplate(channelId: string) {
|
||||
return apiRequest<ScheduleTemplateDto>(`/admin/channels/${channelId}/template`)
|
||||
}
|
||||
|
||||
/** Заводит каналу сетку, если её нет: у каналов из старой ротации шаблона может не быть. */
|
||||
export function createChannelTemplate(channelId: string) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${channelId}/template`, { method: 'POST' })
|
||||
}
|
||||
|
||||
/** Применяет правила к эфиру: пересобирает будущий хвост. Правка слотов эфир не двигает. */
|
||||
export function applyChannelTemplate(channelId: string) {
|
||||
return apiRequest<ApplyResultDto>(`/admin/channels/${channelId}/template/apply`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/** Проверки сетки по правилам — считаются по шаблону, без прогона генератора. */
|
||||
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) })
|
||||
return apiRequest<SchedulePreviewDto>(
|
||||
`/admin/channels/${channelId}/template/preview?${query.toString()}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function updateTemplate(
|
||||
templateId: string,
|
||||
body: {
|
||||
name: string
|
||||
fallbackGroupId: string | null
|
||||
defaultJunctionId: string | null
|
||||
rules: PlanningRules | null
|
||||
},
|
||||
) {
|
||||
return apiRequest<void>(`/admin/templates/${templateId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function createLayer(templateId: string, body: { name: string; priority: number }) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/templates/${templateId}/layers`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateLayer(
|
||||
layerId: string,
|
||||
body: {
|
||||
name: string
|
||||
priority: number
|
||||
applicability: LayerApplicability | null
|
||||
isEnabled: boolean
|
||||
},
|
||||
) {
|
||||
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function deleteLayer(layerId: string) {
|
||||
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Тело слота: то же для создания и правки (см. SlotInput на сервере). */
|
||||
export type SlotBody = Omit<SlotDto, 'id' | 'layerId' | 'groupName'>
|
||||
|
||||
/** Слот из ответа сервера → тело запроса: отбрасываем то, что сервер проставляет сам. */
|
||||
export function toSlotBody(slot: SlotDto): SlotBody {
|
||||
const { id: _id, layerId: _layerId, groupName: _groupName, ...body } = slot
|
||||
return body
|
||||
}
|
||||
|
||||
export function createSlot(layerId: string, body: SlotBody) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/layers/${layerId}/slots`, { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateSlot(slotId: string, body: SlotBody) {
|
||||
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function deleteSlot(slotId: string) {
|
||||
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ── Стыки канала ──────────────────────────────────────────────────────────
|
||||
|
||||
export function listJunctions(channelId: string) {
|
||||
return apiRequest<JunctionTemplateDto[]>(`/admin/channels/${channelId}/junctions`)
|
||||
}
|
||||
|
||||
export function createJunction(channelId: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${channelId}/junctions`, {
|
||||
method: 'POST',
|
||||
body: { name },
|
||||
})
|
||||
}
|
||||
|
||||
export function renameJunction(junctionId: string, name: string) {
|
||||
return apiRequest<void>(`/admin/junctions/${junctionId}`, { method: 'PUT', body: { name } })
|
||||
}
|
||||
|
||||
export function deleteJunction(junctionId: string) {
|
||||
return apiRequest<void>(`/admin/junctions/${junctionId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function addJunctionElement(junctionId: string, kind: JunctionElementKind) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/junctions/${junctionId}/elements`, {
|
||||
method: 'POST',
|
||||
body: { kind },
|
||||
})
|
||||
}
|
||||
|
||||
/** Тело врезки: то же для любого типа — лишние поля сервер обнуляет сам (см. JunctionElement.Update). */
|
||||
export type JunctionElementBody = {
|
||||
kind: JunctionElementKind
|
||||
groupId: string | null
|
||||
bumperTemplateId: string | null
|
||||
amountMode: JunctionAmountMode
|
||||
amountValue: number
|
||||
isRequired: boolean
|
||||
conditions: JunctionConditions | null
|
||||
}
|
||||
|
||||
export function updateJunctionElement(
|
||||
junctionId: string,
|
||||
elementId: string,
|
||||
body: JunctionElementBody,
|
||||
) {
|
||||
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function removeJunctionElement(junctionId: string, elementId: string) {
|
||||
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Порядок врезок: не упомянутые остаются после перечисленных. */
|
||||
export function reorderJunction(junctionId: string, elementIdsInOrder: string[]) {
|
||||
return apiRequest<void>(`/admin/junctions/${junctionId}/order`, {
|
||||
method: 'PUT',
|
||||
body: { elementIdsInOrder },
|
||||
})
|
||||
}
|
||||
|
||||
type BumperTemplateStyleBody = {
|
||||
name: string
|
||||
backgroundColor: string
|
||||
backgroundColor2: string
|
||||
accentColor: string
|
||||
textColor: string
|
||||
}
|
||||
|
||||
export function addBumperTemplate(id: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/bumper/templates`, {
|
||||
method: 'POST',
|
||||
body: { name },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateBumperTemplate(
|
||||
id: string,
|
||||
templateId: string,
|
||||
body: BumperTemplateStyleBody,
|
||||
) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function removeBumperTemplate(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */
|
||||
function uploadBumperTemplateFile(
|
||||
id: string,
|
||||
templateId: string,
|
||||
kind: 'audio' | 'background',
|
||||
file: File,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const query = new URLSearchParams({ fileName: file.name })
|
||||
xhr.open(
|
||||
'PUT',
|
||||
`/api/admin/channels/${id}/bumper/templates/${templateId}/${kind}?${query.toString()}`,
|
||||
)
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve()
|
||||
} else {
|
||||
let detail = `HTTP ${xhr.status}`
|
||||
try {
|
||||
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
|
||||
detail = problem.detail ?? problem.title ?? detail
|
||||
} catch {
|
||||
/* пусто */
|
||||
}
|
||||
reject(new HttpError({ detail }, xhr.status))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) {
|
||||
return uploadBumperTemplateFile(id, templateId, 'audio', file)
|
||||
}
|
||||
|
||||
type BumperVariantBody = {
|
||||
name: string
|
||||
kind: BumperTextKind
|
||||
nowLabel: string
|
||||
nextLabel: string
|
||||
line1: string
|
||||
line2: string
|
||||
trigger: BumperTrigger
|
||||
weight: number
|
||||
}
|
||||
|
||||
export function addBumperVariant(id: string, templateId: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants`,
|
||||
{ method: 'POST', body: { name } },
|
||||
)
|
||||
}
|
||||
|
||||
export function updateBumperVariant(
|
||||
id: string,
|
||||
templateId: string,
|
||||
variantId: string,
|
||||
body: BumperVariantBody,
|
||||
) {
|
||||
return apiRequest<void>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||
{ method: 'PUT', body },
|
||||
)
|
||||
}
|
||||
|
||||
export function removeBumperVariant(id: string, templateId: string, variantId: string) {
|
||||
return apiRequest<void>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
}
|
||||
|
||||
/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */
|
||||
export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||
method: 'PUT',
|
||||
body: { imageId },
|
||||
})
|
||||
}
|
||||
|
||||
export function clearBumperTemplateAudio(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function clearBumperTemplateBackground(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */
|
||||
export function renderBumperPreviews(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function bumperPreviewPlaylistUrl(id: string, templateId: string, variantId: string) {
|
||||
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/${variantId}/index.m3u8`
|
||||
}
|
||||
|
||||
export function getSchedule(id: string, from: Date, to: Date) {
|
||||
const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() })
|
||||
return apiRequest<ScheduleEntryDto[]>(`/admin/channels/${id}/schedule?${query.toString()}`)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,9 @@ export function BumperBackgroundField({
|
||||
: `· ${t('admin.channels.bumperFileDefault')}`}
|
||||
</span>
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">{t('admin.channels.bumperBackgroundHint')}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperBackgroundHint')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{backgroundImageId && (
|
||||
<img
|
||||
@@ -57,7 +59,12 @@ export function BumperBackgroundField({
|
||||
{t('admin.channels.bumperBackgroundPick')}
|
||||
</Button>
|
||||
{backgroundImageId && (
|
||||
<Button size="sm" variant="ghost" disabled={clearBg.isPending} onClick={() => clearBg.mutate()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={clearBg.isPending}
|
||||
onClick={() => clearBg.mutate()}
|
||||
>
|
||||
{t('admin.channels.bumperReset')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,145 +1,153 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { addBumperTemplate, updateChannelSettings } from '../api'
|
||||
import { BumperTemplateEditor } from './BumperTemplateEditor'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
export function BumperCard({
|
||||
channel,
|
||||
bare,
|
||||
onSaved,
|
||||
onError,
|
||||
}: Readonly<{
|
||||
channel: ChannelDto
|
||||
bare?: boolean
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
|
||||
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
|
||||
|
||||
const setField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
|
||||
setBumper((prev) => ({ ...prev, [key]: value }))
|
||||
|
||||
useEffect(() => {
|
||||
setBumpersEnabled(channel.bumpersEnabled)
|
||||
setBumper(channel.bumper)
|
||||
}, [channel])
|
||||
|
||||
// Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные
|
||||
// поля берём из канала без изменений (они правятся в своей карточке).
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelSettings(channel.id, {
|
||||
name: channel.name,
|
||||
isEnabled: channel.isEnabled,
|
||||
bumpersEnabled,
|
||||
bumper,
|
||||
fillerAssetId: channel.fillerAssetId,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const addTemplate = useMutation({
|
||||
mutationFn: () => addBumperTemplate(channel.id, ''),
|
||||
onSuccess: onSaved,
|
||||
onError,
|
||||
})
|
||||
|
||||
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
|
||||
|
||||
return (
|
||||
<CollapsibleCard
|
||||
title={t('admin.channels.bumpers')}
|
||||
bare={bare}
|
||||
contentClassName="flex flex-col gap-4"
|
||||
>
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={bumpersEnabled}
|
||||
onChange={(e) => setBumpersEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t('admin.channels.bumpersLabel')}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumpersHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Общие настройки. Как часто ставить заставку — не здесь: это условие элемента стыка. */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperSelection')}</Label>
|
||||
<Select value={bumper.selection} onValueChange={(v) => setField('selection', v as BumperSelection)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
|
||||
<SelectItem value="WeightedRandom">
|
||||
{t('admin.channels.bumperSelectionWeighted')}
|
||||
</SelectItem>
|
||||
<SelectItem value="AlwaysFirst">
|
||||
{t('admin.channels.bumperSelectionAlwaysFirst')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperFont')}</Label>
|
||||
<Select value={bumper.font} onValueChange={(v) => setField('font', v as BumperFont)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Sans">{t('admin.channels.bumperFontSans')}</SelectItem>
|
||||
<SelectItem value="Serif">{t('admin.channels.bumperFontSerif')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperConditionsHint')}</p>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Блоки заставок */}
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{templates.map((template) => (
|
||||
<BumperTemplateEditor
|
||||
key={template.id}
|
||||
channelId={channel.id}
|
||||
template={template}
|
||||
onChanged={onSaved}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button size="sm" variant="outline" disabled={addTemplate.isPending} onClick={() => addTemplate.mutate()}>
|
||||
{t('admin.channels.bumperAddTemplate')}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { addBumperTemplate, updateChannelSettings } from '../api'
|
||||
import { BumperTemplateEditor } from './BumperTemplateEditor'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
export function BumperCard({
|
||||
channel,
|
||||
bare,
|
||||
onSaved,
|
||||
onError,
|
||||
}: Readonly<{
|
||||
channel: ChannelDto
|
||||
bare?: boolean
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
|
||||
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
|
||||
|
||||
const setField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
|
||||
setBumper((prev) => ({ ...prev, [key]: value }))
|
||||
|
||||
useEffect(() => {
|
||||
setBumpersEnabled(channel.bumpersEnabled)
|
||||
setBumper(channel.bumper)
|
||||
}, [channel])
|
||||
|
||||
// Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные
|
||||
// поля берём из канала без изменений (они правятся в своей карточке).
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelSettings(channel.id, {
|
||||
name: channel.name,
|
||||
isEnabled: channel.isEnabled,
|
||||
bumpersEnabled,
|
||||
bumper,
|
||||
fillerAssetId: channel.fillerAssetId,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const addTemplate = useMutation({
|
||||
mutationFn: () => addBumperTemplate(channel.id, ''),
|
||||
onSuccess: onSaved,
|
||||
onError,
|
||||
})
|
||||
|
||||
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
|
||||
|
||||
return (
|
||||
<CollapsibleCard
|
||||
title={t('admin.channels.bumpers')}
|
||||
bare={bare}
|
||||
contentClassName="flex flex-col gap-4"
|
||||
>
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={bumpersEnabled}
|
||||
onChange={(e) => setBumpersEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t('admin.channels.bumpersLabel')}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumpersHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Общие настройки. Как часто ставить заставку — не здесь: это условие элемента стыка. */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperSelection')}</Label>
|
||||
<Select
|
||||
value={bumper.selection}
|
||||
onValueChange={(v) => setField('selection', v as BumperSelection)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
|
||||
<SelectItem value="WeightedRandom">
|
||||
{t('admin.channels.bumperSelectionWeighted')}
|
||||
</SelectItem>
|
||||
<SelectItem value="AlwaysFirst">
|
||||
{t('admin.channels.bumperSelectionAlwaysFirst')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperFont')}</Label>
|
||||
<Select value={bumper.font} onValueChange={(v) => setField('font', v as BumperFont)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Sans">{t('admin.channels.bumperFontSans')}</SelectItem>
|
||||
<SelectItem value="Serif">{t('admin.channels.bumperFontSerif')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperConditionsHint')}</p>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Блоки заставок */}
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{templates.map((template) => (
|
||||
<BumperTemplateEditor
|
||||
key={template.id}
|
||||
channelId={channel.id}
|
||||
template={template}
|
||||
onChanged={onSaved}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={addTemplate.isPending}
|
||||
onClick={() => addTemplate.mutate()}
|
||||
>
|
||||
{t('admin.channels.bumperAddTemplate')}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,12 +33,19 @@ export function BumperPreviewPlayer({
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="outline" disabled={render.isPending} onClick={() => render.mutate()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={render.isPending}
|
||||
onClick={() => render.mutate()}
|
||||
>
|
||||
{render.isPending
|
||||
? t('admin.channels.bumperPreviewRendering')
|
||||
: t('admin.channels.bumperPreview')}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">{t('admin.channels.bumperPreviewHint')}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperPreviewHint')}
|
||||
</span>
|
||||
</div>
|
||||
{ready && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
@@ -47,7 +54,9 @@ export function BumperPreviewPlayer({
|
||||
.map((v) => (
|
||||
<div key={v.id} className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">{v.name}</span>
|
||||
<HlsVideo src={`${bumperPreviewPlaylistUrl(channelId, templateId, v.id)}?t=${bust}`} />
|
||||
<HlsVideo
|
||||
src={`${bumperPreviewPlaylistUrl(channelId, templateId, v.id)}?t=${bust}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,8 @@ export function BumperTemplateEditor({
|
||||
}, [template])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => updateBumperTemplate(channelId, template.id, { name: name.trim(), ...colors }),
|
||||
mutationFn: () =>
|
||||
updateBumperTemplate(channelId, template.id, { name: name.trim(), ...colors }),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onChanged()
|
||||
@@ -163,7 +164,9 @@ export function BumperTemplateEditor({
|
||||
{/* Подблоки (текст-варианты) */}
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||
<p className="text-sm font-medium">{t('admin.channels.bumperVariants')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperVariantsHint')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperVariantsHint')}
|
||||
</p>
|
||||
{[...template.variants]
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((variant) => (
|
||||
|
||||
@@ -142,11 +142,19 @@ export function BumperVariantEditor({
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperLine1')}</Label>
|
||||
<Input value={form.line1} maxLength={120} onChange={(e) => set('line1', e.target.value)} />
|
||||
<Input
|
||||
value={form.line1}
|
||||
maxLength={120}
|
||||
onChange={(e) => set('line1', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperLine2')}</Label>
|
||||
<Input value={form.line2} maxLength={120} onChange={(e) => set('line2', e.target.value)} />
|
||||
<Input
|
||||
value={form.line2}
|
||||
maxLength={120}
|
||||
onChange={(e) => set('line2', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -154,7 +162,12 @@ export function BumperVariantEditor({
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{canRemove && (
|
||||
<Button size="sm" variant="ghost" disabled={remove.isPending} onClick={() => remove.mutate()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => remove.mutate()}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,109 +1,105 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { EntryTraceDto } from '@/shared/api/types'
|
||||
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,
|
||||
}: Readonly<{
|
||||
entryId: string
|
||||
utcOffsetMinutes: number
|
||||
onClose: () => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const { data } = useQuery({
|
||||
queryKey: qk.entries.trace(entryId),
|
||||
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')}>{layerSummary(data, t)}</Row>
|
||||
<Row label={t('admin.channels.traceSlot')}>{slotSummary(data, t)}</Row>
|
||||
<Row label={t('admin.channels.traceGroup')}>{groupSummary(data)}</Row>
|
||||
<Row label={t('admin.channels.traceCollection')}>{data.collectionName}</Row>
|
||||
<Row label={t('admin.channels.traceStrategy')}>{strategySummary(data, t)}</Row>
|
||||
<Row label={t('admin.channels.traceJunction')}>{data.junctionName}</Row>
|
||||
</dl>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t']
|
||||
|
||||
/** Склейка непустых частей строки трейса; пусто — значит строка не заполнена (покажем «—»). */
|
||||
const joinParts = (parts: (string | null | undefined)[]) => parts.filter(Boolean).join(' · ') || null
|
||||
|
||||
function layerSummary(data: EntryTraceDto, t: Translate) {
|
||||
if (!data.layerName) return null
|
||||
const priority =
|
||||
data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''
|
||||
return `${data.layerName}${priority}`
|
||||
}
|
||||
|
||||
function slotSummary(data: EntryTraceDto, t: Translate) {
|
||||
if (!data.slotTitle) return null
|
||||
return joinParts([
|
||||
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,
|
||||
])
|
||||
}
|
||||
|
||||
function groupSummary(data: EntryTraceDto) {
|
||||
if (!data.groupName) return null
|
||||
const count = data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''
|
||||
return `${data.groupName}${count}`
|
||||
}
|
||||
|
||||
function strategySummary(data: EntryTraceDto, t: Translate) {
|
||||
if (!data.strategy) return null
|
||||
return joinParts([
|
||||
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,
|
||||
])
|
||||
}
|
||||
|
||||
function Row({ label, children }: Readonly<{ label: string; children: React.ReactNode }>) {
|
||||
return (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd>{children || '—'}</dd>
|
||||
</>
|
||||
)
|
||||
}
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { EntryTraceDto } from '@/shared/api/types'
|
||||
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,
|
||||
}: Readonly<{
|
||||
entryId: string
|
||||
utcOffsetMinutes: number
|
||||
onClose: () => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const { data } = useQuery({
|
||||
queryKey: qk.entries.trace(entryId),
|
||||
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')}>{layerSummary(data, t)}</Row>
|
||||
<Row label={t('admin.channels.traceSlot')}>{slotSummary(data, t)}</Row>
|
||||
<Row label={t('admin.channels.traceGroup')}>{groupSummary(data)}</Row>
|
||||
<Row label={t('admin.channels.traceCollection')}>{data.collectionName}</Row>
|
||||
<Row label={t('admin.channels.traceStrategy')}>{strategySummary(data, t)}</Row>
|
||||
<Row label={t('admin.channels.traceJunction')}>{data.junctionName}</Row>
|
||||
</dl>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t']
|
||||
|
||||
/** Склейка непустых частей строки трейса; пусто — значит строка не заполнена (покажем «—»). */
|
||||
const joinParts = (parts: (string | null | undefined)[]) =>
|
||||
parts.filter(Boolean).join(' · ') || null
|
||||
|
||||
function layerSummary(data: EntryTraceDto, t: Translate) {
|
||||
if (!data.layerName) return null
|
||||
const priority =
|
||||
data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''
|
||||
return `${data.layerName}${priority}`
|
||||
}
|
||||
|
||||
function slotSummary(data: EntryTraceDto, t: Translate) {
|
||||
if (!data.slotTitle) return null
|
||||
return joinParts([
|
||||
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,
|
||||
])
|
||||
}
|
||||
|
||||
function groupSummary(data: EntryTraceDto) {
|
||||
if (!data.groupName) return null
|
||||
const count = data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''
|
||||
return `${data.groupName}${count}`
|
||||
}
|
||||
|
||||
function strategySummary(data: EntryTraceDto, t: Translate) {
|
||||
if (!data.strategy) return null
|
||||
return joinParts([
|
||||
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,
|
||||
])
|
||||
}
|
||||
|
||||
function Row({ label, children }: Readonly<{ label: string; children: React.ReactNode }>) {
|
||||
return (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd>{children || '—'}</dd>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,13 +10,7 @@ import type {
|
||||
} from '@/shared/api/types'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { removeJunctionElement, updateJunctionElement, type JunctionElementBody } from '../api'
|
||||
|
||||
@@ -1,337 +1,339 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { ChevronRight, Plus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listGroups } from '@/features/admin/groups/api'
|
||||
import { formatClock } from '@/features/admin/interstitials/format'
|
||||
import type {
|
||||
ChannelDto,
|
||||
GroupSummaryDto,
|
||||
JunctionElementDto,
|
||||
JunctionElementKind,
|
||||
JunctionTemplateDto,
|
||||
ScheduleTemplateDto,
|
||||
} from '@/shared/api/types'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import {
|
||||
addJunctionElement,
|
||||
createJunction,
|
||||
deleteJunction,
|
||||
listJunctions,
|
||||
renameJunction,
|
||||
reorderJunction,
|
||||
updateTemplate,
|
||||
} from '../api'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
import { JunctionElementDialog } from './JunctionElementDialog'
|
||||
|
||||
/** Заставка без загруженного звука синтезируется фиксированной длины — та же цифра, что на сервере. */
|
||||
const DEFAULT_BUMPER_SECONDS = 8
|
||||
|
||||
const KIND_COLORS: Record<JunctionElementKind, string> = {
|
||||
Ad: 'bg-amber-500/70',
|
||||
Promo: 'bg-sky-500/70',
|
||||
Bumper: 'bg-violet-500/70',
|
||||
Filler: 'bg-muted-foreground/40',
|
||||
}
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t']
|
||||
|
||||
/** Что уточняет подпись врезки: имя блока заставки либо объём («×3» / «×5 мин»). */
|
||||
function elementSuffix(element: JunctionElementDto, t: Translate) {
|
||||
if (element.kind === 'Bumper')
|
||||
return element.bumperTemplateName ? ` · ${element.bumperTemplateName}` : ''
|
||||
|
||||
const units = element.amountMode === 'Duration' ? t('admin.channels.minutesShort') : ''
|
||||
return ` ×${element.amountValue}${units}`
|
||||
}
|
||||
|
||||
/** Подсказка сегмента линейки: вид врезки и её оценочная длительность. */
|
||||
function elementTitle(element: JunctionElementDto, seconds: number, t: Translate) {
|
||||
const kind = t(`admin.channels.junctionKinds.${element.kind}`)
|
||||
return `${kind} · ${formatClock(seconds)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Прикидка длины врезки для линейки. По длительности — точная, по количеству — среднее единицы
|
||||
* группы: в смешанной группе «одна единица» это то ли ролик, то ли блок, поэтому число заведомо
|
||||
* приблизительное и помечается как оценка.
|
||||
*/
|
||||
function estimateSeconds(
|
||||
element: JunctionElementDto,
|
||||
groups: GroupSummaryDto[] | undefined,
|
||||
channel: ChannelDto,
|
||||
): { seconds: number; exact: boolean } {
|
||||
if (element.kind === 'Bumper') {
|
||||
const template = channel.bumperTemplates.find((b) => b.id === element.bumperTemplateId)
|
||||
return { seconds: template?.audioDurationSeconds ?? DEFAULT_BUMPER_SECONDS, exact: true }
|
||||
}
|
||||
if (element.amountMode === 'Duration') return { seconds: element.amountValue * 60, exact: true }
|
||||
|
||||
const group = groups?.find((g) => g.id === element.groupId)
|
||||
if (!group || group.unitCount === 0) return { seconds: 0, exact: false }
|
||||
return {
|
||||
seconds: (element.amountValue * group.totalDurationSeconds) / group.unitCount,
|
||||
exact: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function JunctionsCard({
|
||||
channel,
|
||||
template,
|
||||
bare,
|
||||
onChanged,
|
||||
onError,
|
||||
}: Readonly<{
|
||||
channel: ChannelDto
|
||||
template: ScheduleTemplateDto | undefined
|
||||
bare?: boolean
|
||||
onChanged: () => void
|
||||
onError: (error: unknown) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [newName, setNewName] = useState('')
|
||||
|
||||
const { data: junctions } = useQuery({
|
||||
queryKey: qk.channels.junctions(channel.id),
|
||||
queryFn: () => listJunctions(channel.id),
|
||||
})
|
||||
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createJunction(channel.id, newName.trim()),
|
||||
onSuccess: () => {
|
||||
setNewName('')
|
||||
onChanged()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const defaultMutation = useMutation({
|
||||
mutationFn: (junctionId: string | null) =>
|
||||
updateTemplate(template!.id, {
|
||||
name: template!.name,
|
||||
fallbackGroupId: template!.fallbackGroupId,
|
||||
defaultJunctionId: junctionId,
|
||||
rules: template!.rules,
|
||||
}),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<CollapsibleCard title={t('admin.channels.junctions')} bare={bare}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.junctionsHint')}</p>
|
||||
|
||||
{template && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.defaultJunction')}</Label>
|
||||
<select
|
||||
className="h-9 max-w-xs rounded-md border border-border bg-transparent px-2 text-sm"
|
||||
value={template.defaultJunctionId ?? ''}
|
||||
onChange={(e) => defaultMutation.mutate(e.target.value || null)}
|
||||
>
|
||||
<option value="">{t('admin.channels.noJunction')}</option>
|
||||
{(junctions ?? []).map((junction) => (
|
||||
<option key={junction.id} value={junction.id}>
|
||||
{junction.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(junctions ?? []).map((junction) => (
|
||||
<JunctionChain
|
||||
key={junction.id}
|
||||
junction={junction}
|
||||
channel={channel}
|
||||
groups={groups}
|
||||
onChanged={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="flex max-w-md gap-2">
|
||||
<Input
|
||||
placeholder={t('admin.channels.newJunctionName')}
|
||||
value={newName}
|
||||
maxLength={128}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!newName.trim() || createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> {t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
|
||||
const ADDABLE: JunctionElementKind[] = ['Ad', 'Promo', 'Bumper', 'Filler']
|
||||
|
||||
function JunctionChain({
|
||||
junction,
|
||||
channel,
|
||||
groups,
|
||||
onChanged,
|
||||
onError,
|
||||
}: Readonly<{
|
||||
junction: JunctionTemplateDto
|
||||
channel: ChannelDto
|
||||
groups: GroupSummaryDto[] | undefined
|
||||
onChanged: () => void
|
||||
onError: (error: unknown) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState<string | null>(null)
|
||||
const [dragged, setDragged] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState<JunctionElementDto | null>(null)
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: (value: string) => renameJunction(junction.id, value),
|
||||
onSuccess: () => {
|
||||
setName(null)
|
||||
onChanged()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteJunction(junction.id),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (kind: JunctionElementKind) => addJunctionElement(junction.id, kind),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (order: string[]) => reorderJunction(junction.id, order),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
const elements = [...junction.elements].sort((a, b) => a.position - b.position)
|
||||
const estimates = elements.map((element) => estimateSeconds(element, groups, channel))
|
||||
const total = estimates.reduce((sum, e) => sum + e.seconds, 0)
|
||||
const exact = estimates.every((e) => e.exact)
|
||||
|
||||
const dropOn = (targetId: string) => {
|
||||
if (!dragged || dragged === targetId) return
|
||||
const order = elements.map((e) => e.id).filter((id) => id !== dragged)
|
||||
order.splice(order.indexOf(targetId), 0, dragged)
|
||||
setDragged(null)
|
||||
reorderMutation.mutate(order)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="crt-panel flex flex-col gap-2 rounded-md p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="h-8 max-w-56"
|
||||
value={name ?? junction.name}
|
||||
maxLength={128}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={() =>
|
||||
name !== null && name.trim() && name !== junction.name
|
||||
? renameMutation.mutate(name.trim())
|
||||
: setName(null)
|
||||
}
|
||||
/>
|
||||
<select
|
||||
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
||||
value=""
|
||||
onChange={(e) => e.target.value && addMutation.mutate(e.target.value as JunctionElementKind)}
|
||||
>
|
||||
<option value="">{t('admin.channels.addJunctionElement')}</option>
|
||||
{ADDABLE.map((kind) => (
|
||||
<option key={kind} value={kind}>
|
||||
{t(`admin.channels.junctionKinds.${kind}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{exact ? '' : '≈ '}
|
||||
{formatClock(total)}
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => deleteMutation.mutate()}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Цепочка: что играет между концом одной программы и началом следующей. */}
|
||||
<div className="flex flex-wrap items-center gap-1 text-xs">
|
||||
<span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground">
|
||||
{t('admin.channels.junctionFrom')}
|
||||
</span>
|
||||
{elements.length === 0 && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{t('admin.channels.junctionEmpty')}</span>
|
||||
</>
|
||||
)}
|
||||
{elements.map((element) => (
|
||||
<span key={element.id} className="flex items-center gap-1">
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={() => setDragged(element.id)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => dropOn(element.id)}
|
||||
onClick={() => setEditing(element)}
|
||||
className={cn(
|
||||
'cursor-grab rounded border border-border px-2 py-1 hover:border-primary',
|
||||
element.isRequired && 'border-primary/70',
|
||||
)}
|
||||
>
|
||||
{t(`admin.channels.junctionKinds.${element.kind}`)}
|
||||
{elementSuffix(element, t)}
|
||||
{element.isRequired && ' *'}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground">
|
||||
{t('admin.channels.junctionTo')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Линейка: доля каждой врезки в стыке. Пустые (без группы) в неё не попадают. */}
|
||||
{total > 0 && (
|
||||
<div className="flex h-2 overflow-hidden rounded-full bg-muted/40">
|
||||
{elements.map((element, index) => (
|
||||
<div
|
||||
key={element.id}
|
||||
className={KIND_COLORS[element.kind]}
|
||||
style={{ width: `${(estimates[index].seconds / total) * 100}%` }}
|
||||
title={elementTitle(element, estimates[index].seconds, t)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<JunctionElementDialog
|
||||
junctionId={junction.id}
|
||||
element={editing}
|
||||
bumperTemplates={channel.bumperTemplates}
|
||||
onClose={() => setEditing(null)}
|
||||
onChanged={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { ChevronRight, Plus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listGroups } from '@/features/admin/groups/api'
|
||||
import { formatClock } from '@/features/admin/interstitials/format'
|
||||
import type {
|
||||
ChannelDto,
|
||||
GroupSummaryDto,
|
||||
JunctionElementDto,
|
||||
JunctionElementKind,
|
||||
JunctionTemplateDto,
|
||||
ScheduleTemplateDto,
|
||||
} from '@/shared/api/types'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import {
|
||||
addJunctionElement,
|
||||
createJunction,
|
||||
deleteJunction,
|
||||
listJunctions,
|
||||
renameJunction,
|
||||
reorderJunction,
|
||||
updateTemplate,
|
||||
} from '../api'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
import { JunctionElementDialog } from './JunctionElementDialog'
|
||||
|
||||
/** Заставка без загруженного звука синтезируется фиксированной длины — та же цифра, что на сервере. */
|
||||
const DEFAULT_BUMPER_SECONDS = 8
|
||||
|
||||
const KIND_COLORS: Record<JunctionElementKind, string> = {
|
||||
Ad: 'bg-amber-500/70',
|
||||
Promo: 'bg-sky-500/70',
|
||||
Bumper: 'bg-violet-500/70',
|
||||
Filler: 'bg-muted-foreground/40',
|
||||
}
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t']
|
||||
|
||||
/** Что уточняет подпись врезки: имя блока заставки либо объём («×3» / «×5 мин»). */
|
||||
function elementSuffix(element: JunctionElementDto, t: Translate) {
|
||||
if (element.kind === 'Bumper')
|
||||
return element.bumperTemplateName ? ` · ${element.bumperTemplateName}` : ''
|
||||
|
||||
const units = element.amountMode === 'Duration' ? t('admin.channels.minutesShort') : ''
|
||||
return ` ×${element.amountValue}${units}`
|
||||
}
|
||||
|
||||
/** Подсказка сегмента линейки: вид врезки и её оценочная длительность. */
|
||||
function elementTitle(element: JunctionElementDto, seconds: number, t: Translate) {
|
||||
const kind = t(`admin.channels.junctionKinds.${element.kind}`)
|
||||
return `${kind} · ${formatClock(seconds)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Прикидка длины врезки для линейки. По длительности — точная, по количеству — среднее единицы
|
||||
* группы: в смешанной группе «одна единица» это то ли ролик, то ли блок, поэтому число заведомо
|
||||
* приблизительное и помечается как оценка.
|
||||
*/
|
||||
function estimateSeconds(
|
||||
element: JunctionElementDto,
|
||||
groups: GroupSummaryDto[] | undefined,
|
||||
channel: ChannelDto,
|
||||
): { seconds: number; exact: boolean } {
|
||||
if (element.kind === 'Bumper') {
|
||||
const template = channel.bumperTemplates.find((b) => b.id === element.bumperTemplateId)
|
||||
return { seconds: template?.audioDurationSeconds ?? DEFAULT_BUMPER_SECONDS, exact: true }
|
||||
}
|
||||
if (element.amountMode === 'Duration') return { seconds: element.amountValue * 60, exact: true }
|
||||
|
||||
const group = groups?.find((g) => g.id === element.groupId)
|
||||
if (!group || group.unitCount === 0) return { seconds: 0, exact: false }
|
||||
return {
|
||||
seconds: (element.amountValue * group.totalDurationSeconds) / group.unitCount,
|
||||
exact: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function JunctionsCard({
|
||||
channel,
|
||||
template,
|
||||
bare,
|
||||
onChanged,
|
||||
onError,
|
||||
}: Readonly<{
|
||||
channel: ChannelDto
|
||||
template: ScheduleTemplateDto | undefined
|
||||
bare?: boolean
|
||||
onChanged: () => void
|
||||
onError: (error: unknown) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [newName, setNewName] = useState('')
|
||||
|
||||
const { data: junctions } = useQuery({
|
||||
queryKey: qk.channels.junctions(channel.id),
|
||||
queryFn: () => listJunctions(channel.id),
|
||||
})
|
||||
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createJunction(channel.id, newName.trim()),
|
||||
onSuccess: () => {
|
||||
setNewName('')
|
||||
onChanged()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const defaultMutation = useMutation({
|
||||
mutationFn: (junctionId: string | null) =>
|
||||
updateTemplate(template!.id, {
|
||||
name: template!.name,
|
||||
fallbackGroupId: template!.fallbackGroupId,
|
||||
defaultJunctionId: junctionId,
|
||||
rules: template!.rules,
|
||||
}),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<CollapsibleCard title={t('admin.channels.junctions')} bare={bare}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.junctionsHint')}</p>
|
||||
|
||||
{template && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.defaultJunction')}</Label>
|
||||
<select
|
||||
className="h-9 max-w-xs rounded-md border border-border bg-transparent px-2 text-sm"
|
||||
value={template.defaultJunctionId ?? ''}
|
||||
onChange={(e) => defaultMutation.mutate(e.target.value || null)}
|
||||
>
|
||||
<option value="">{t('admin.channels.noJunction')}</option>
|
||||
{(junctions ?? []).map((junction) => (
|
||||
<option key={junction.id} value={junction.id}>
|
||||
{junction.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(junctions ?? []).map((junction) => (
|
||||
<JunctionChain
|
||||
key={junction.id}
|
||||
junction={junction}
|
||||
channel={channel}
|
||||
groups={groups}
|
||||
onChanged={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="flex max-w-md gap-2">
|
||||
<Input
|
||||
placeholder={t('admin.channels.newJunctionName')}
|
||||
value={newName}
|
||||
maxLength={128}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!newName.trim() || createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> {t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
|
||||
const ADDABLE: JunctionElementKind[] = ['Ad', 'Promo', 'Bumper', 'Filler']
|
||||
|
||||
function JunctionChain({
|
||||
junction,
|
||||
channel,
|
||||
groups,
|
||||
onChanged,
|
||||
onError,
|
||||
}: Readonly<{
|
||||
junction: JunctionTemplateDto
|
||||
channel: ChannelDto
|
||||
groups: GroupSummaryDto[] | undefined
|
||||
onChanged: () => void
|
||||
onError: (error: unknown) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState<string | null>(null)
|
||||
const [dragged, setDragged] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState<JunctionElementDto | null>(null)
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: (value: string) => renameJunction(junction.id, value),
|
||||
onSuccess: () => {
|
||||
setName(null)
|
||||
onChanged()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteJunction(junction.id),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (kind: JunctionElementKind) => addJunctionElement(junction.id, kind),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (order: string[]) => reorderJunction(junction.id, order),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
const elements = [...junction.elements].sort((a, b) => a.position - b.position)
|
||||
const estimates = elements.map((element) => estimateSeconds(element, groups, channel))
|
||||
const total = estimates.reduce((sum, e) => sum + e.seconds, 0)
|
||||
const exact = estimates.every((e) => e.exact)
|
||||
|
||||
const dropOn = (targetId: string) => {
|
||||
if (!dragged || dragged === targetId) return
|
||||
const order = elements.map((e) => e.id).filter((id) => id !== dragged)
|
||||
order.splice(order.indexOf(targetId), 0, dragged)
|
||||
setDragged(null)
|
||||
reorderMutation.mutate(order)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="crt-panel flex flex-col gap-2 rounded-md p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="h-8 max-w-56"
|
||||
value={name ?? junction.name}
|
||||
maxLength={128}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={() =>
|
||||
name !== null && name.trim() && name !== junction.name
|
||||
? renameMutation.mutate(name.trim())
|
||||
: setName(null)
|
||||
}
|
||||
/>
|
||||
<select
|
||||
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
||||
value=""
|
||||
onChange={(e) =>
|
||||
e.target.value && addMutation.mutate(e.target.value as JunctionElementKind)
|
||||
}
|
||||
>
|
||||
<option value="">{t('admin.channels.addJunctionElement')}</option>
|
||||
{ADDABLE.map((kind) => (
|
||||
<option key={kind} value={kind}>
|
||||
{t(`admin.channels.junctionKinds.${kind}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{exact ? '' : '≈ '}
|
||||
{formatClock(total)}
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => deleteMutation.mutate()}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Цепочка: что играет между концом одной программы и началом следующей. */}
|
||||
<div className="flex flex-wrap items-center gap-1 text-xs">
|
||||
<span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground">
|
||||
{t('admin.channels.junctionFrom')}
|
||||
</span>
|
||||
{elements.length === 0 && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{t('admin.channels.junctionEmpty')}</span>
|
||||
</>
|
||||
)}
|
||||
{elements.map((element) => (
|
||||
<span key={element.id} className="flex items-center gap-1">
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={() => setDragged(element.id)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => dropOn(element.id)}
|
||||
onClick={() => setEditing(element)}
|
||||
className={cn(
|
||||
'cursor-grab rounded border border-border px-2 py-1 hover:border-primary',
|
||||
element.isRequired && 'border-primary/70',
|
||||
)}
|
||||
>
|
||||
{t(`admin.channels.junctionKinds.${element.kind}`)}
|
||||
{elementSuffix(element, t)}
|
||||
{element.isRequired && ' *'}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground">
|
||||
{t('admin.channels.junctionTo')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Линейка: доля каждой врезки в стыке. Пустые (без группы) в неё не попадают. */}
|
||||
{total > 0 && (
|
||||
<div className="flex h-2 overflow-hidden rounded-full bg-muted/40">
|
||||
{elements.map((element, index) => (
|
||||
<div
|
||||
key={element.id}
|
||||
className={KIND_COLORS[element.kind]}
|
||||
style={{ width: `${(estimates[index].seconds / total) * 100}%` }}
|
||||
title={elementTitle(element, estimates[index].seconds, t)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<JunctionElementDialog
|
||||
junctionId={junction.id}
|
||||
element={editing}
|
||||
bumperTemplates={channel.bumperTemplates}
|
||||
onClose={() => setEditing(null)}
|
||||
onChanged={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -118,9 +118,7 @@ export function LayerApplicabilityDialog({
|
||||
type="date"
|
||||
className="w-40"
|
||||
value={range.from}
|
||||
onChange={(e) =>
|
||||
dateRanges.patch(key, (r) => ({ ...r, from: e.target.value }))
|
||||
}
|
||||
onChange={(e) => dateRanges.patch(key, (r) => ({ ...r, from: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
|
||||
@@ -1,259 +1,255 @@
|
||||
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: 'PG-13' }
|
||||
|
||||
/**
|
||||
* Окно со стабильным ключом. Индекс в качестве key не годится: строки удаляются из середины, и React
|
||||
* сопоставил бы уцелевшие узлы не с теми окнами — фокус и внутреннее состояние полей переехали бы
|
||||
* в соседнюю строку. Ключ живёт только на клиенте и в API не уезжает.
|
||||
*/
|
||||
type WindowRow = { key: string; window: AudienceWindow }
|
||||
|
||||
const toRows = (windows: AudienceWindow[]): WindowRow[] =>
|
||||
windows.map((window) => ({ key: crypto.randomUUID(), window }))
|
||||
|
||||
/**
|
||||
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
|
||||
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
|
||||
* воспроизводимость. Как и правка сетки, эфира сами по себе не двигают.
|
||||
*/
|
||||
export function RulesCard({
|
||||
template,
|
||||
bare,
|
||||
onChanged,
|
||||
onError,
|
||||
}: Readonly<{
|
||||
template: ScheduleTemplateDto
|
||||
bare?: boolean
|
||||
onChanged: () => void
|
||||
onError: (error: unknown) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [windows, setWindows] = useState<WindowRow[]>(() =>
|
||||
toRows(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(toRows(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.map((row) => row.window) : 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 removeWindow = (key: string) =>
|
||||
setWindows((current) => current.filter((row) => row.key !== key))
|
||||
|
||||
const patchWindow = (key: string, part: Partial<AudienceWindow>) =>
|
||||
setWindows((current) =>
|
||||
current.map((row) =>
|
||||
row.key === key ? { ...row, window: { ...row.window, ...part } } : row,
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<CollapsibleCard title={t('admin.channels.rules')} bare={bare}>
|
||||
<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, ...toRows([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(({ key, window }) => (
|
||||
<li key={key} 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(key, { 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(key, { 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(key, { 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={() => removeWindow(key)}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
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: 'PG-13' }
|
||||
|
||||
/**
|
||||
* Окно со стабильным ключом. Индекс в качестве key не годится: строки удаляются из середины, и React
|
||||
* сопоставил бы уцелевшие узлы не с теми окнами — фокус и внутреннее состояние полей переехали бы
|
||||
* в соседнюю строку. Ключ живёт только на клиенте и в API не уезжает.
|
||||
*/
|
||||
type WindowRow = { key: string; window: AudienceWindow }
|
||||
|
||||
const toRows = (windows: AudienceWindow[]): WindowRow[] =>
|
||||
windows.map((window) => ({ key: crypto.randomUUID(), window }))
|
||||
|
||||
/**
|
||||
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
|
||||
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
|
||||
* воспроизводимость. Как и правка сетки, эфира сами по себе не двигают.
|
||||
*/
|
||||
export function RulesCard({
|
||||
template,
|
||||
bare,
|
||||
onChanged,
|
||||
onError,
|
||||
}: Readonly<{
|
||||
template: ScheduleTemplateDto
|
||||
bare?: boolean
|
||||
onChanged: () => void
|
||||
onError: (error: unknown) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [windows, setWindows] = useState<WindowRow[]>(() =>
|
||||
toRows(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(toRows(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.map((row) => row.window) : 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 removeWindow = (key: string) =>
|
||||
setWindows((current) => current.filter((row) => row.key !== key))
|
||||
|
||||
const patchWindow = (key: string, part: Partial<AudienceWindow>) =>
|
||||
setWindows((current) =>
|
||||
current.map((row) =>
|
||||
row.key === key ? { ...row, window: { ...row.window, ...part } } : row,
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<CollapsibleCard title={t('admin.channels.rules')} bare={bare}>
|
||||
<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, ...toRows([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(({ key, window }) => (
|
||||
<li key={key} 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(key, { 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(key, { 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(key, { 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={() => removeWindow(key)}>
|
||||
<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,379 +1,380 @@
|
||||
import { Anchor, CalendarRange, Copy, GripVertical, Plus, Repeat } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { coversDate, isEmpty } from '../lib/applicability'
|
||||
|
||||
const HOUR_HEIGHT = 44
|
||||
|
||||
/** Шаг сетки при перетаскивании и растягивании — минуты. */
|
||||
const SNAP_MINUTES = 15
|
||||
|
||||
const snap = (minutes: number) => Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES
|
||||
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
|
||||
|
||||
/** Цвет блока — по дейпарту: сетка должна читаться одним взглядом, без легенды. */
|
||||
const DAYPART_CLASS: Record<string, string> = {
|
||||
Morning: 'bg-amber-500/20 border-amber-500/40',
|
||||
Day: 'bg-sky-500/20 border-sky-500/40',
|
||||
Prime: 'bg-violet-500/25 border-violet-500/50',
|
||||
Night: 'bg-slate-500/20 border-slate-500/40',
|
||||
}
|
||||
|
||||
function minutesOf(time: string): number {
|
||||
const [h, m] = time.split(':')
|
||||
return Number(h) * 60 + Number(m)
|
||||
}
|
||||
|
||||
/**
|
||||
* Смещение слота от начала вещательных суток. Ночной блок (00:00–06:00 при старте суток в 06:00)
|
||||
* принадлежит предыдущему дню, поэтому его смещение больше суточного, а не отрицательное.
|
||||
*/
|
||||
function offsetInDay(slotStart: string, dayStart: string): number {
|
||||
const diff = minutesOf(slotStart) - minutesOf(dayStart)
|
||||
return diff >= 0 ? diff : diff + 24 * 60
|
||||
}
|
||||
|
||||
/** Слоты, попадающие в колонку дня: слот без дня недели идёт каждый день. */
|
||||
function slotsOfDay(layers: GridLayerDto[], weekday: number) {
|
||||
return layers
|
||||
.filter((layer) => layer.isEnabled)
|
||||
.flatMap((layer) =>
|
||||
layer.slots
|
||||
.filter((slot) => slot.weekday === null || slot.weekday === weekday)
|
||||
.map((slot) => ({ slot, layer })),
|
||||
)
|
||||
}
|
||||
|
||||
export function ScheduleGrid({
|
||||
template,
|
||||
selectedSlotId,
|
||||
viewDate,
|
||||
onSelectSlot,
|
||||
onAddSlot,
|
||||
onMoveSlot,
|
||||
onResizeSlot,
|
||||
onCopyDay,
|
||||
}: Readonly<{
|
||||
template: ScheduleTemplateDto
|
||||
selectedSlotId: string | null
|
||||
/** Дата, на которую смотрим сетку («показать 25 декабря»); null — все слои разом. */
|
||||
viewDate: string | null
|
||||
onSelectSlot: (slot: SlotDto) => void
|
||||
onAddSlot: (weekday: number, startMinutes: number) => void
|
||||
/** Перенос слота: новое время старта и (для слота с днём недели) новый день. */
|
||||
onMoveSlot: (slot: SlotDto, weekday: number, startMinutes: number) => void
|
||||
onResizeSlot: (slot: SlotDto, durationMinutes: number) => void
|
||||
onCopyDay: (fromWeekday: number) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const dayStart = template.dayStartTime.slice(0, 5)
|
||||
const dayStartMinutes = minutesOf(dayStart)
|
||||
|
||||
// Подписи часов идут от начала вещательных суток, а не от полуночи.
|
||||
const hours = Array.from({ length: 24 }, (_, i) => (dayStartMinutes / 60 + i) % 24)
|
||||
|
||||
// На выбранную дату показываем только те слои, которые в этот день действуют, — иначе сетка
|
||||
// «на 25 декабря» показывала бы и обычный день, и новогодний одновременно.
|
||||
const day = viewDate ? parseIsoDate(viewDate) : null
|
||||
const applicable = day
|
||||
? template.layers.filter((layer) => coversDate(layer.applicability, day))
|
||||
: template.layers
|
||||
|
||||
// Слои отсортированы по убыванию приоритета: слот, лежащий под более приоритетным, штрихуем.
|
||||
const ordered = [...applicable].sort((a, b) => b.priority - a.priority)
|
||||
const highlightWeekday = day?.getDay() ?? null
|
||||
|
||||
const [dragged, setDragged] = useState<SlotDto | null>(null)
|
||||
const [resizing, setResizing] = useState<{ slot: SlotDto; minutes: number } | null>(null)
|
||||
|
||||
/** Позиция курсора в колонке дня — минуты суток, округлённые до шага сетки. */
|
||||
const minutesAt = (clientY: number, column: HTMLElement) => {
|
||||
const rect = column.getBoundingClientRect()
|
||||
const offset = Math.max(0, Math.min(rect.height, clientY - rect.top))
|
||||
const fromDayStart = snap((offset / HOUR_HEIGHT) * 60)
|
||||
return (dayStartMinutes + fromDayStart) % (24 * 60)
|
||||
}
|
||||
|
||||
const drop = (event: React.DragEvent<HTMLDivElement>, weekday: number) => {
|
||||
event.preventDefault()
|
||||
if (!dragged) return
|
||||
// Слот «каждый день» при переносе таким и остаётся: молча превратить его в слот одного дня
|
||||
// значило бы убрать его сразу из шести колонок.
|
||||
onMoveSlot(dragged, dragged.weekday ?? weekday, minutesAt(event.clientY, event.currentTarget))
|
||||
setDragged(null)
|
||||
}
|
||||
|
||||
/** Растягивание за нижний край: пока тянем — видно новую высоту, отпустили — сохраняем. */
|
||||
const startResize = (event: React.MouseEvent, slot: SlotDto, column: HTMLElement) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const from = offsetInDay(slot.targetStart, dayStart)
|
||||
|
||||
const move = (moveEvent: MouseEvent) => {
|
||||
const rect = column.getBoundingClientRect()
|
||||
const offset = Math.max(0, Math.min(rect.height, moveEvent.clientY - rect.top))
|
||||
const end = snap((offset / HOUR_HEIGHT) * 60)
|
||||
setResizing({ slot, minutes: Math.max(SNAP_MINUTES, end - from) })
|
||||
}
|
||||
const up = () => {
|
||||
window.removeEventListener('mousemove', move)
|
||||
window.removeEventListener('mouseup', up)
|
||||
setResizing((current) => {
|
||||
if (current && current.minutes !== slot.targetDurationMinutes)
|
||||
onResizeSlot(slot, current.minutes)
|
||||
return null
|
||||
})
|
||||
}
|
||||
window.addEventListener('mousemove', move)
|
||||
window.addEventListener('mouseup', up)
|
||||
}
|
||||
|
||||
const isCovered = (slot: SlotDto, layer: GridLayerDto, weekday: number) => {
|
||||
const from = offsetInDay(slot.targetStart, dayStart)
|
||||
const to = from + slot.targetDurationMinutes
|
||||
return ordered
|
||||
.filter((other) => other.isEnabled && other.priority > layer.priority)
|
||||
.some((other) =>
|
||||
other.slots
|
||||
.filter((s) => s.weekday === null || s.weekday === weekday)
|
||||
.some((s) => {
|
||||
const otherFrom = offsetInDay(s.targetStart, dayStart)
|
||||
return from < otherFrom + s.targetDurationMinutes && otherFrom < to
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="crt-panel overflow-x-auto rounded-md">
|
||||
<div className="min-w-[720px]">
|
||||
<div className="grid grid-cols-[56px_repeat(7,1fr)] border-b border-border text-xs text-muted-foreground">
|
||||
<div className="px-2 py-1">{dayStart}</div>
|
||||
{WEEKDAYS.map((weekday) => (
|
||||
<div
|
||||
key={weekday}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-1 px-2 py-1 font-medium',
|
||||
highlightWeekday === weekday && 'text-primary',
|
||||
)}
|
||||
>
|
||||
{t(`admin.channels.weekdays.${weekday}`)}
|
||||
<button
|
||||
type="button"
|
||||
title={t('admin.channels.copyDay')}
|
||||
className="opacity-40 hover:opacity-100"
|
||||
onClick={() => onCopyDay(weekday)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[56px_repeat(7,1fr)]">
|
||||
<div>
|
||||
{hours.map((hour) => (
|
||||
<div
|
||||
key={hour}
|
||||
className="border-b border-border/40 px-2 text-[11px] text-muted-foreground"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
>
|
||||
{hour.toString().padStart(2, '0')}:00
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{WEEKDAYS.map((weekday) => (
|
||||
<div
|
||||
key={weekday}
|
||||
className={cn(
|
||||
'relative border-l border-border',
|
||||
highlightWeekday === weekday && 'bg-primary/5',
|
||||
dragged && 'bg-primary/10',
|
||||
)}
|
||||
style={{ height: HOUR_HEIGHT * 24 }}
|
||||
onDragOver={(e) => dragged && e.preventDefault()}
|
||||
onDrop={(e) => drop(e, weekday)}
|
||||
>
|
||||
{hours.map((hour, index) => (
|
||||
<button
|
||||
key={hour}
|
||||
type="button"
|
||||
title={t('admin.channels.addSlotHere')}
|
||||
className="group absolute inset-x-0 border-b border-border/40 hover:bg-muted/30"
|
||||
style={{ top: HOUR_HEIGHT * index, height: HOUR_HEIGHT }}
|
||||
onClick={() => onAddSlot(weekday, (dayStartMinutes + index * 60) % (24 * 60))}
|
||||
>
|
||||
<Plus className="mx-auto h-3 w-3 opacity-0 group-hover:opacity-40" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{slotsOfDay(ordered, weekday).map(({ slot, layer }) => {
|
||||
const from = offsetInDay(slot.targetStart, dayStart)
|
||||
const covered = isCovered(slot, layer, weekday)
|
||||
const minutes =
|
||||
resizing?.slot.id === slot.id ? resizing.minutes : slot.targetDurationMinutes
|
||||
return (
|
||||
// Кнопка, а не div: слот выбирается кликом, и с клавиатуры это должно работать
|
||||
// тоже. Перетаскивание на кнопке сохраняется — draggable к роли не привязан.
|
||||
<button
|
||||
key={`${slot.id}-${weekday}`}
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={() => setDragged(slot)}
|
||||
onDragEnd={() => setDragged(null)}
|
||||
onClick={() => onSelectSlot(slot)}
|
||||
className={cn(
|
||||
'absolute inset-x-1 cursor-grab overflow-hidden rounded border px-1.5 py-0.5 text-left text-[11px] leading-tight',
|
||||
DAYPART_CLASS[slot.daypart] ?? DAYPART_CLASS.Day,
|
||||
selectedSlotId === slot.id && 'ring-2 ring-primary',
|
||||
dragged?.id === slot.id && 'opacity-50',
|
||||
// Перекрытый слот виден, но приглушён: он не сыграет, пока лежит под старшим слоем.
|
||||
covered && 'opacity-40 [background-image:repeating-linear-gradient(45deg,transparent,transparent_4px,rgba(0,0,0,.15)_4px,rgba(0,0,0,.15)_8px)]',
|
||||
)}
|
||||
style={{
|
||||
top: (from / 60) * HOUR_HEIGHT,
|
||||
height: Math.max(16, (minutes / 60) * HOUR_HEIGHT - 2),
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-1 font-medium">
|
||||
{slot.isAnchor && <Anchor className="h-3 w-3 shrink-0" />}
|
||||
{slot.targetStart.slice(0, 5)}
|
||||
{slot.weekday === null && <Repeat className="h-3 w-3 shrink-0 opacity-60" />}
|
||||
</span>
|
||||
<span className="block truncate">{slot.title}</span>
|
||||
{/* Ручка растягивания — исключительно мышиная: role="presentation" на элементе
|
||||
с обработчиком противоречив (роль говорит «меня нет», а элемент реагирует),
|
||||
поэтому прячем её от вспомогательных технологий. Длительность слота
|
||||
правится с клавиатуры в инспекторе — доступный путь есть. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
title={t('admin.channels.resizeSlot')}
|
||||
className="absolute inset-x-0 bottom-0 h-1.5 cursor-ns-resize hover:bg-primary/40"
|
||||
onMouseDown={(e) =>
|
||||
startResize(e, slot, e.currentTarget.parentElement!.parentElement!)
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Панель слоёв: видимость, приоритет (перетаскиванием), применимость и выбор редактируемого.
|
||||
* Выше в списке — приоритетнее; фоновый слой всегда внизу и не двигается.
|
||||
*/
|
||||
export function LayerList({
|
||||
template,
|
||||
activeLayerId,
|
||||
viewDate,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onReorder,
|
||||
onEditApplicability,
|
||||
}: Readonly<{
|
||||
template: ScheduleTemplateDto
|
||||
activeLayerId: string | null
|
||||
viewDate: string | null
|
||||
onSelect: (layer: GridLayerDto) => void
|
||||
onDelete: (layer: GridLayerDto) => void
|
||||
onToggle: (layer: GridLayerDto) => void
|
||||
onReorder: (layerIdsTopFirst: string[]) => void
|
||||
onEditApplicability: (layer: GridLayerDto) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [dragged, setDragged] = useState<string | null>(null)
|
||||
|
||||
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
|
||||
const day = viewDate ? parseIsoDate(viewDate) : null
|
||||
|
||||
const dropOn = (targetId: string) => {
|
||||
if (!dragged || dragged === targetId) return
|
||||
const movable = ordered.filter((l) => !l.isBackground).map((l) => l.id)
|
||||
const order = movable.filter((id) => id !== dragged)
|
||||
const at = order.indexOf(targetId)
|
||||
// Бросок на фоновый слой означает «в самый низ»: он в порядке не участвует.
|
||||
order.splice(at === -1 ? order.length : at, 0, dragged)
|
||||
setDragged(null)
|
||||
onReorder(order)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border text-sm">
|
||||
{ordered.map((layer) => {
|
||||
const inactiveToday = day !== null && !coversDate(layer.applicability, day)
|
||||
return (
|
||||
<li
|
||||
key={layer.id}
|
||||
draggable={!layer.isBackground}
|
||||
onDragStart={() => setDragged(layer.id)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => dropOn(layer.id)}
|
||||
className={cn('flex items-center gap-1.5 py-1.5', inactiveToday && 'opacity-40')}
|
||||
>
|
||||
{layer.isBackground ? (
|
||||
<span className="w-4 shrink-0" />
|
||||
) : (
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
className="shrink-0"
|
||||
title={t('admin.channels.layerVisible')}
|
||||
checked={layer.isEnabled}
|
||||
onChange={() => onToggle(layer)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-left',
|
||||
activeLayerId === layer.id && 'text-primary',
|
||||
)}
|
||||
onClick={() => onSelect(layer)}
|
||||
>
|
||||
{layer.name}
|
||||
</button>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{layer.isBackground ? t('admin.channels.background') : layer.slots.length}
|
||||
</span>
|
||||
{!layer.isBackground && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
title={t('admin.channels.layerApplicability')}
|
||||
onClick={() => onEditApplicability(layer)}
|
||||
>
|
||||
<CalendarRange
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
isEmpty(layer.applicability) ? 'text-muted-foreground' : 'text-primary',
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => onDelete(layer)}>
|
||||
×
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
/** «2026-12-25» → локальная дата. `new Date(iso)` разобрал бы её как UTC и сместил день. */
|
||||
function parseIsoDate(iso: string): Date {
|
||||
const [year, month, day] = iso.split('-').map(Number)
|
||||
return new Date(year, month - 1, day)
|
||||
}
|
||||
import { Anchor, CalendarRange, Copy, GripVertical, Plus, Repeat } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { coversDate, isEmpty } from '../lib/applicability'
|
||||
|
||||
const HOUR_HEIGHT = 44
|
||||
|
||||
/** Шаг сетки при перетаскивании и растягивании — минуты. */
|
||||
const SNAP_MINUTES = 15
|
||||
|
||||
const snap = (minutes: number) => Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES
|
||||
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
|
||||
|
||||
/** Цвет блока — по дейпарту: сетка должна читаться одним взглядом, без легенды. */
|
||||
const DAYPART_CLASS: Record<string, string> = {
|
||||
Morning: 'bg-amber-500/20 border-amber-500/40',
|
||||
Day: 'bg-sky-500/20 border-sky-500/40',
|
||||
Prime: 'bg-violet-500/25 border-violet-500/50',
|
||||
Night: 'bg-slate-500/20 border-slate-500/40',
|
||||
}
|
||||
|
||||
function minutesOf(time: string): number {
|
||||
const [h, m] = time.split(':')
|
||||
return Number(h) * 60 + Number(m)
|
||||
}
|
||||
|
||||
/**
|
||||
* Смещение слота от начала вещательных суток. Ночной блок (00:00–06:00 при старте суток в 06:00)
|
||||
* принадлежит предыдущему дню, поэтому его смещение больше суточного, а не отрицательное.
|
||||
*/
|
||||
function offsetInDay(slotStart: string, dayStart: string): number {
|
||||
const diff = minutesOf(slotStart) - minutesOf(dayStart)
|
||||
return diff >= 0 ? diff : diff + 24 * 60
|
||||
}
|
||||
|
||||
/** Слоты, попадающие в колонку дня: слот без дня недели идёт каждый день. */
|
||||
function slotsOfDay(layers: GridLayerDto[], weekday: number) {
|
||||
return layers
|
||||
.filter((layer) => layer.isEnabled)
|
||||
.flatMap((layer) =>
|
||||
layer.slots
|
||||
.filter((slot) => slot.weekday === null || slot.weekday === weekday)
|
||||
.map((slot) => ({ slot, layer })),
|
||||
)
|
||||
}
|
||||
|
||||
export function ScheduleGrid({
|
||||
template,
|
||||
selectedSlotId,
|
||||
viewDate,
|
||||
onSelectSlot,
|
||||
onAddSlot,
|
||||
onMoveSlot,
|
||||
onResizeSlot,
|
||||
onCopyDay,
|
||||
}: Readonly<{
|
||||
template: ScheduleTemplateDto
|
||||
selectedSlotId: string | null
|
||||
/** Дата, на которую смотрим сетку («показать 25 декабря»); null — все слои разом. */
|
||||
viewDate: string | null
|
||||
onSelectSlot: (slot: SlotDto) => void
|
||||
onAddSlot: (weekday: number, startMinutes: number) => void
|
||||
/** Перенос слота: новое время старта и (для слота с днём недели) новый день. */
|
||||
onMoveSlot: (slot: SlotDto, weekday: number, startMinutes: number) => void
|
||||
onResizeSlot: (slot: SlotDto, durationMinutes: number) => void
|
||||
onCopyDay: (fromWeekday: number) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const dayStart = template.dayStartTime.slice(0, 5)
|
||||
const dayStartMinutes = minutesOf(dayStart)
|
||||
|
||||
// Подписи часов идут от начала вещательных суток, а не от полуночи.
|
||||
const hours = Array.from({ length: 24 }, (_, i) => (dayStartMinutes / 60 + i) % 24)
|
||||
|
||||
// На выбранную дату показываем только те слои, которые в этот день действуют, — иначе сетка
|
||||
// «на 25 декабря» показывала бы и обычный день, и новогодний одновременно.
|
||||
const day = viewDate ? parseIsoDate(viewDate) : null
|
||||
const applicable = day
|
||||
? template.layers.filter((layer) => coversDate(layer.applicability, day))
|
||||
: template.layers
|
||||
|
||||
// Слои отсортированы по убыванию приоритета: слот, лежащий под более приоритетным, штрихуем.
|
||||
const ordered = [...applicable].sort((a, b) => b.priority - a.priority)
|
||||
const highlightWeekday = day?.getDay() ?? null
|
||||
|
||||
const [dragged, setDragged] = useState<SlotDto | null>(null)
|
||||
const [resizing, setResizing] = useState<{ slot: SlotDto; minutes: number } | null>(null)
|
||||
|
||||
/** Позиция курсора в колонке дня — минуты суток, округлённые до шага сетки. */
|
||||
const minutesAt = (clientY: number, column: HTMLElement) => {
|
||||
const rect = column.getBoundingClientRect()
|
||||
const offset = Math.max(0, Math.min(rect.height, clientY - rect.top))
|
||||
const fromDayStart = snap((offset / HOUR_HEIGHT) * 60)
|
||||
return (dayStartMinutes + fromDayStart) % (24 * 60)
|
||||
}
|
||||
|
||||
const drop = (event: React.DragEvent<HTMLDivElement>, weekday: number) => {
|
||||
event.preventDefault()
|
||||
if (!dragged) return
|
||||
// Слот «каждый день» при переносе таким и остаётся: молча превратить его в слот одного дня
|
||||
// значило бы убрать его сразу из шести колонок.
|
||||
onMoveSlot(dragged, dragged.weekday ?? weekday, minutesAt(event.clientY, event.currentTarget))
|
||||
setDragged(null)
|
||||
}
|
||||
|
||||
/** Растягивание за нижний край: пока тянем — видно новую высоту, отпустили — сохраняем. */
|
||||
const startResize = (event: React.MouseEvent, slot: SlotDto, column: HTMLElement) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const from = offsetInDay(slot.targetStart, dayStart)
|
||||
|
||||
const move = (moveEvent: MouseEvent) => {
|
||||
const rect = column.getBoundingClientRect()
|
||||
const offset = Math.max(0, Math.min(rect.height, moveEvent.clientY - rect.top))
|
||||
const end = snap((offset / HOUR_HEIGHT) * 60)
|
||||
setResizing({ slot, minutes: Math.max(SNAP_MINUTES, end - from) })
|
||||
}
|
||||
const up = () => {
|
||||
window.removeEventListener('mousemove', move)
|
||||
window.removeEventListener('mouseup', up)
|
||||
setResizing((current) => {
|
||||
if (current && current.minutes !== slot.targetDurationMinutes)
|
||||
onResizeSlot(slot, current.minutes)
|
||||
return null
|
||||
})
|
||||
}
|
||||
window.addEventListener('mousemove', move)
|
||||
window.addEventListener('mouseup', up)
|
||||
}
|
||||
|
||||
const isCovered = (slot: SlotDto, layer: GridLayerDto, weekday: number) => {
|
||||
const from = offsetInDay(slot.targetStart, dayStart)
|
||||
const to = from + slot.targetDurationMinutes
|
||||
return ordered
|
||||
.filter((other) => other.isEnabled && other.priority > layer.priority)
|
||||
.some((other) =>
|
||||
other.slots
|
||||
.filter((s) => s.weekday === null || s.weekday === weekday)
|
||||
.some((s) => {
|
||||
const otherFrom = offsetInDay(s.targetStart, dayStart)
|
||||
return from < otherFrom + s.targetDurationMinutes && otherFrom < to
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="crt-panel overflow-x-auto rounded-md">
|
||||
<div className="min-w-[720px]">
|
||||
<div className="grid grid-cols-[56px_repeat(7,1fr)] border-b border-border text-xs text-muted-foreground">
|
||||
<div className="px-2 py-1">{dayStart}</div>
|
||||
{WEEKDAYS.map((weekday) => (
|
||||
<div
|
||||
key={weekday}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-1 px-2 py-1 font-medium',
|
||||
highlightWeekday === weekday && 'text-primary',
|
||||
)}
|
||||
>
|
||||
{t(`admin.channels.weekdays.${weekday}`)}
|
||||
<button
|
||||
type="button"
|
||||
title={t('admin.channels.copyDay')}
|
||||
className="opacity-40 hover:opacity-100"
|
||||
onClick={() => onCopyDay(weekday)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[56px_repeat(7,1fr)]">
|
||||
<div>
|
||||
{hours.map((hour) => (
|
||||
<div
|
||||
key={hour}
|
||||
className="border-b border-border/40 px-2 text-[11px] text-muted-foreground"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
>
|
||||
{hour.toString().padStart(2, '0')}:00
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{WEEKDAYS.map((weekday) => (
|
||||
<div
|
||||
key={weekday}
|
||||
className={cn(
|
||||
'relative border-l border-border',
|
||||
highlightWeekday === weekday && 'bg-primary/5',
|
||||
dragged && 'bg-primary/10',
|
||||
)}
|
||||
style={{ height: HOUR_HEIGHT * 24 }}
|
||||
onDragOver={(e) => dragged && e.preventDefault()}
|
||||
onDrop={(e) => drop(e, weekday)}
|
||||
>
|
||||
{hours.map((hour, index) => (
|
||||
<button
|
||||
key={hour}
|
||||
type="button"
|
||||
title={t('admin.channels.addSlotHere')}
|
||||
className="group absolute inset-x-0 border-b border-border/40 hover:bg-muted/30"
|
||||
style={{ top: HOUR_HEIGHT * index, height: HOUR_HEIGHT }}
|
||||
onClick={() => onAddSlot(weekday, (dayStartMinutes + index * 60) % (24 * 60))}
|
||||
>
|
||||
<Plus className="mx-auto h-3 w-3 opacity-0 group-hover:opacity-40" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{slotsOfDay(ordered, weekday).map(({ slot, layer }) => {
|
||||
const from = offsetInDay(slot.targetStart, dayStart)
|
||||
const covered = isCovered(slot, layer, weekday)
|
||||
const minutes =
|
||||
resizing?.slot.id === slot.id ? resizing.minutes : slot.targetDurationMinutes
|
||||
return (
|
||||
// Кнопка, а не div: слот выбирается кликом, и с клавиатуры это должно работать
|
||||
// тоже. Перетаскивание на кнопке сохраняется — draggable к роли не привязан.
|
||||
<button
|
||||
key={`${slot.id}-${weekday}`}
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={() => setDragged(slot)}
|
||||
onDragEnd={() => setDragged(null)}
|
||||
onClick={() => onSelectSlot(slot)}
|
||||
className={cn(
|
||||
'absolute inset-x-1 cursor-grab overflow-hidden rounded border px-1.5 py-0.5 text-left text-[11px] leading-tight',
|
||||
DAYPART_CLASS[slot.daypart] ?? DAYPART_CLASS.Day,
|
||||
selectedSlotId === slot.id && 'ring-2 ring-primary',
|
||||
dragged?.id === slot.id && 'opacity-50',
|
||||
// Перекрытый слот виден, но приглушён: он не сыграет, пока лежит под старшим слоем.
|
||||
covered &&
|
||||
'opacity-40 [background-image:repeating-linear-gradient(45deg,transparent,transparent_4px,rgba(0,0,0,.15)_4px,rgba(0,0,0,.15)_8px)]',
|
||||
)}
|
||||
style={{
|
||||
top: (from / 60) * HOUR_HEIGHT,
|
||||
height: Math.max(16, (minutes / 60) * HOUR_HEIGHT - 2),
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-1 font-medium">
|
||||
{slot.isAnchor && <Anchor className="h-3 w-3 shrink-0" />}
|
||||
{slot.targetStart.slice(0, 5)}
|
||||
{slot.weekday === null && <Repeat className="h-3 w-3 shrink-0 opacity-60" />}
|
||||
</span>
|
||||
<span className="block truncate">{slot.title}</span>
|
||||
{/* Ручка растягивания — исключительно мышиная: role="presentation" на элементе
|
||||
с обработчиком противоречив (роль говорит «меня нет», а элемент реагирует),
|
||||
поэтому прячем её от вспомогательных технологий. Длительность слота
|
||||
правится с клавиатуры в инспекторе — доступный путь есть. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
title={t('admin.channels.resizeSlot')}
|
||||
className="absolute inset-x-0 bottom-0 h-1.5 cursor-ns-resize hover:bg-primary/40"
|
||||
onMouseDown={(e) =>
|
||||
startResize(e, slot, e.currentTarget.parentElement!.parentElement!)
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Панель слоёв: видимость, приоритет (перетаскиванием), применимость и выбор редактируемого.
|
||||
* Выше в списке — приоритетнее; фоновый слой всегда внизу и не двигается.
|
||||
*/
|
||||
export function LayerList({
|
||||
template,
|
||||
activeLayerId,
|
||||
viewDate,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onReorder,
|
||||
onEditApplicability,
|
||||
}: Readonly<{
|
||||
template: ScheduleTemplateDto
|
||||
activeLayerId: string | null
|
||||
viewDate: string | null
|
||||
onSelect: (layer: GridLayerDto) => void
|
||||
onDelete: (layer: GridLayerDto) => void
|
||||
onToggle: (layer: GridLayerDto) => void
|
||||
onReorder: (layerIdsTopFirst: string[]) => void
|
||||
onEditApplicability: (layer: GridLayerDto) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [dragged, setDragged] = useState<string | null>(null)
|
||||
|
||||
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
|
||||
const day = viewDate ? parseIsoDate(viewDate) : null
|
||||
|
||||
const dropOn = (targetId: string) => {
|
||||
if (!dragged || dragged === targetId) return
|
||||
const movable = ordered.filter((l) => !l.isBackground).map((l) => l.id)
|
||||
const order = movable.filter((id) => id !== dragged)
|
||||
const at = order.indexOf(targetId)
|
||||
// Бросок на фоновый слой означает «в самый низ»: он в порядке не участвует.
|
||||
order.splice(at === -1 ? order.length : at, 0, dragged)
|
||||
setDragged(null)
|
||||
onReorder(order)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border text-sm">
|
||||
{ordered.map((layer) => {
|
||||
const inactiveToday = day !== null && !coversDate(layer.applicability, day)
|
||||
return (
|
||||
<li
|
||||
key={layer.id}
|
||||
draggable={!layer.isBackground}
|
||||
onDragStart={() => setDragged(layer.id)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => dropOn(layer.id)}
|
||||
className={cn('flex items-center gap-1.5 py-1.5', inactiveToday && 'opacity-40')}
|
||||
>
|
||||
{layer.isBackground ? (
|
||||
<span className="w-4 shrink-0" />
|
||||
) : (
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
className="shrink-0"
|
||||
title={t('admin.channels.layerVisible')}
|
||||
checked={layer.isEnabled}
|
||||
onChange={() => onToggle(layer)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-left',
|
||||
activeLayerId === layer.id && 'text-primary',
|
||||
)}
|
||||
onClick={() => onSelect(layer)}
|
||||
>
|
||||
{layer.name}
|
||||
</button>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{layer.isBackground ? t('admin.channels.background') : layer.slots.length}
|
||||
</span>
|
||||
{!layer.isBackground && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
title={t('admin.channels.layerApplicability')}
|
||||
onClick={() => onEditApplicability(layer)}
|
||||
>
|
||||
<CalendarRange
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
isEmpty(layer.applicability) ? 'text-muted-foreground' : 'text-primary',
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => onDelete(layer)}>
|
||||
×
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
/** «2026-12-25» → локальная дата. `new Date(iso)` разобрал бы её как UTC и сместил день. */
|
||||
function parseIsoDate(iso: string): Date {
|
||||
const [year, month, day] = iso.split('-').map(Number)
|
||||
return new Date(year, month - 1, day)
|
||||
}
|
||||
|
||||
@@ -1,136 +1,140 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ChannelDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { updateChannelSettings, updateChannelTime } from '../api'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
export function SettingsCard({
|
||||
channel,
|
||||
readyAssets,
|
||||
bare,
|
||||
onSaved,
|
||||
onError,
|
||||
}: Readonly<{
|
||||
channel: ChannelDto
|
||||
readyAssets: { id: string; originalFileName: string }[]
|
||||
bare?: boolean
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState(channel.name)
|
||||
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
|
||||
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
|
||||
const [number, setNumber] = useState(channel.number?.toString() ?? '')
|
||||
const [offsetHours, setOffsetHours] = useState(channel.utcOffsetMinutes / 60)
|
||||
// Начало вещательных суток приходит как «06:00:00» — в поле нужен формат «06:00».
|
||||
const [dayStart, setDayStart] = useState(channel.dayStartTime.slice(0, 5))
|
||||
|
||||
useEffect(() => {
|
||||
setName(channel.name)
|
||||
setIsEnabled(channel.isEnabled)
|
||||
setFillerAssetId(channel.fillerAssetId ?? '')
|
||||
setNumber(channel.number?.toString() ?? '')
|
||||
setOffsetHours(channel.utcOffsetMinutes / 60)
|
||||
setDayStart(channel.dayStartTime.slice(0, 5))
|
||||
}, [channel])
|
||||
|
||||
const save = useMutation({
|
||||
// Время канала живёт отдельной командой — сохраняем обе за одно нажатие.
|
||||
mutationFn: async () => {
|
||||
await updateChannelSettings(channel.id, {
|
||||
name: name.trim(),
|
||||
isEnabled,
|
||||
// Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений.
|
||||
bumpersEnabled: channel.bumpersEnabled,
|
||||
bumper: channel.bumper,
|
||||
fillerAssetId: fillerAssetId || null,
|
||||
})
|
||||
await updateChannelTime(channel.id, {
|
||||
number: number.trim() === '' ? null : Number(number),
|
||||
utcOffsetMinutes: Math.round(offsetHours * 60),
|
||||
dayStartTime: `${dayStart}:00`,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<CollapsibleCard
|
||||
title={t('admin.channels.settings')}
|
||||
defaultOpen
|
||||
bare={bare}
|
||||
contentClassName="grid gap-4 sm:grid-cols-2"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.name')}</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.number')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={number}
|
||||
placeholder={t('admin.channels.numberPlaceholder')}
|
||||
onChange={(e) => setNumber(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.utcOffset')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={-12}
|
||||
max={14}
|
||||
step={1}
|
||||
value={offsetHours}
|
||||
onChange={(e) => setOffsetHours(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.utcOffsetHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.dayStart')}</Label>
|
||||
<Input type="time" value={dayStart} onChange={(e) => setDayStart(e.target.value)} />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.dayStartHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.filler')}</Label>
|
||||
<Select
|
||||
value={fillerAssetId || 'none'}
|
||||
onValueChange={(v) => setFillerAssetId(v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('admin.channels.noFiller')}</SelectItem>
|
||||
{readyAssets.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.originalFileName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
||||
{t('admin.channels.enabledLabel')}
|
||||
</label>
|
||||
<div className="flex items-end justify-end sm:col-span-2">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ChannelDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { updateChannelSettings, updateChannelTime } from '../api'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
export function SettingsCard({
|
||||
channel,
|
||||
readyAssets,
|
||||
bare,
|
||||
onSaved,
|
||||
onError,
|
||||
}: Readonly<{
|
||||
channel: ChannelDto
|
||||
readyAssets: { id: string; originalFileName: string }[]
|
||||
bare?: boolean
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState(channel.name)
|
||||
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
|
||||
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
|
||||
const [number, setNumber] = useState(channel.number?.toString() ?? '')
|
||||
const [offsetHours, setOffsetHours] = useState(channel.utcOffsetMinutes / 60)
|
||||
// Начало вещательных суток приходит как «06:00:00» — в поле нужен формат «06:00».
|
||||
const [dayStart, setDayStart] = useState(channel.dayStartTime.slice(0, 5))
|
||||
|
||||
useEffect(() => {
|
||||
setName(channel.name)
|
||||
setIsEnabled(channel.isEnabled)
|
||||
setFillerAssetId(channel.fillerAssetId ?? '')
|
||||
setNumber(channel.number?.toString() ?? '')
|
||||
setOffsetHours(channel.utcOffsetMinutes / 60)
|
||||
setDayStart(channel.dayStartTime.slice(0, 5))
|
||||
}, [channel])
|
||||
|
||||
const save = useMutation({
|
||||
// Время канала живёт отдельной командой — сохраняем обе за одно нажатие.
|
||||
mutationFn: async () => {
|
||||
await updateChannelSettings(channel.id, {
|
||||
name: name.trim(),
|
||||
isEnabled,
|
||||
// Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений.
|
||||
bumpersEnabled: channel.bumpersEnabled,
|
||||
bumper: channel.bumper,
|
||||
fillerAssetId: fillerAssetId || null,
|
||||
})
|
||||
await updateChannelTime(channel.id, {
|
||||
number: number.trim() === '' ? null : Number(number),
|
||||
utcOffsetMinutes: Math.round(offsetHours * 60),
|
||||
dayStartTime: `${dayStart}:00`,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<CollapsibleCard
|
||||
title={t('admin.channels.settings')}
|
||||
defaultOpen
|
||||
bare={bare}
|
||||
contentClassName="grid gap-4 sm:grid-cols-2"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.name')}</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.number')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={number}
|
||||
placeholder={t('admin.channels.numberPlaceholder')}
|
||||
onChange={(e) => setNumber(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.utcOffset')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={-12}
|
||||
max={14}
|
||||
step={1}
|
||||
value={offsetHours}
|
||||
onChange={(e) => setOffsetHours(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.utcOffsetHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.dayStart')}</Label>
|
||||
<Input type="time" value={dayStart} onChange={(e) => setDayStart(e.target.value)} />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.dayStartHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.filler')}</Label>
|
||||
<Select
|
||||
value={fillerAssetId || 'none'}
|
||||
onValueChange={(v) => setFillerAssetId(v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('admin.channels.noFiller')}</SelectItem>
|
||||
{readyAssets.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.originalFileName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isEnabled}
|
||||
onChange={(e) => setIsEnabled(e.target.checked)}
|
||||
/>
|
||||
{t('admin.channels.enabledLabel')}
|
||||
</label>
|
||||
<div className="flex items-end justify-end sm:col-span-2">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,334 +1,338 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Eye } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
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 = new Set<PlannedItemKind>(['Program', 'Fallback', 'SignOff'])
|
||||
|
||||
/**
|
||||
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
|
||||
* курсоров. Заставки приходят резервом известной длины — реальный рендер только при применении.
|
||||
*/
|
||||
export function TemplatePreview({ channelId }: Readonly<{ 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: qk.channels.preview(channelId, 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 }: Readonly<{ preview: SchedulePreviewDto }>) {
|
||||
const { t } = useTranslation()
|
||||
const items = preview.items.filter((i) => PROGRAMME_KINDS.has(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 }: Readonly<{ 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,
|
||||
}: Readonly<{ 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 }: Readonly<{ preview: SchedulePreviewDto }>) {
|
||||
const { t } = useTranslation()
|
||||
// Ключ строки — «вид + порядковый номер внутри вида»: у предупреждения нет своего id, а текст
|
||||
// повторяется (одна и та же причина на разных слотах), и позиция здесь — единственное, что его
|
||||
// отличает. Считаем ключ при группировке, чтобы список не пересобирался на каждый рендер.
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, { key: string; text: string }[]>()
|
||||
for (const warning of preview.warnings) {
|
||||
const list = map.get(warning.kind) ?? []
|
||||
list.push({ key: `${warning.kind}#${list.length}`, text: 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) => (
|
||||
<span key={detail.key} className="text-muted-foreground">
|
||||
{detail.text}
|
||||
</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 }: Readonly<{ 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((a, b) => a.localeCompare(b))
|
||||
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>
|
||||
)
|
||||
}
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Eye } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
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 = new Set<PlannedItemKind>(['Program', 'Fallback', 'SignOff'])
|
||||
|
||||
/**
|
||||
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
|
||||
* курсоров. Заставки приходят резервом известной длины — реальный рендер только при применении.
|
||||
*/
|
||||
export function TemplatePreview({ channelId }: Readonly<{ 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: qk.channels.preview(channelId, 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 }: Readonly<{ preview: SchedulePreviewDto }>) {
|
||||
const { t } = useTranslation()
|
||||
const items = preview.items.filter((i) => PROGRAMME_KINDS.has(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 }: Readonly<{ 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,
|
||||
}: Readonly<{ 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 }: Readonly<{ preview: SchedulePreviewDto }>) {
|
||||
const { t } = useTranslation()
|
||||
// Ключ строки — «вид + порядковый номер внутри вида»: у предупреждения нет своего id, а текст
|
||||
// повторяется (одна и та же причина на разных слотах), и позиция здесь — единственное, что его
|
||||
// отличает. Считаем ключ при группировке, чтобы список не пересобирался на каждый рендер.
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, { key: string; text: string }[]>()
|
||||
for (const warning of preview.warnings) {
|
||||
const list = map.get(warning.kind) ?? []
|
||||
list.push({ key: `${warning.kind}#${list.length}`, text: 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) => (
|
||||
<span key={detail.key} className="text-muted-foreground">
|
||||
{detail.text}
|
||||
</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 }: Readonly<{ 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((a, b) => a.localeCompare(b))
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -149,7 +149,11 @@ export function CollectionDetail({ collectionId }: Readonly<{ collectionId: stri
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button size="sm" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -10,13 +10,7 @@ import type { GenreDto } from '@/shared/api/types'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { sortRows, useTableSort } from '@/shared/lib/table-sort'
|
||||
|
||||
@@ -170,7 +170,12 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
|
||||
<GroupFilterPanel filter={filter ?? EMPTY_FILTER} onChange={setFilter} />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="outline" disabled={findMutation.isPending} onClick={() => findMutation.mutate()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={findMutation.isPending}
|
||||
onClick={() => findMutation.mutate()}
|
||||
>
|
||||
<Search className="h-4 w-4" /> {t('admin.groups.find')}
|
||||
</Button>
|
||||
{candidates !== null && (
|
||||
|
||||
@@ -109,11 +109,7 @@ export function BlockBuilder({
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatClock(item.seconds)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeAt(index)}
|
||||
>
|
||||
<Button size="sm" variant="ghost" onClick={() => removeAt(index)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
|
||||
@@ -75,7 +75,9 @@ export function MaintenancePanel() {
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={clearMedia.isPending}
|
||||
onClick={() => confirmed(t('admin.maintenance.confirmClearMedia'), () => clearMedia.mutate())}
|
||||
onClick={() =>
|
||||
confirmed(t('admin.maintenance.confirmClearMedia'), () => clearMedia.mutate())
|
||||
}
|
||||
>
|
||||
{t('admin.maintenance.clearMedia')}
|
||||
</Button>
|
||||
@@ -121,7 +123,9 @@ export function MaintenancePanel() {
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={clearShows.isPending}
|
||||
onClick={() => confirmed(t('admin.maintenance.confirmDeleteShows'), () => clearShows.mutate())}
|
||||
onClick={() =>
|
||||
confirmed(t('admin.maintenance.confirmDeleteShows'), () => clearShows.mutate())
|
||||
}
|
||||
>
|
||||
{t('admin.maintenance.deleteShows')}
|
||||
</Button>
|
||||
|
||||
@@ -1,455 +1,461 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ChevronDown, ChevronRight, Folder } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listShows } from '@/features/admin/shows/api'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import type { ManualInboxFileDto } from '@/shared/api/types'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { importManualInbox, listManualInbox } from './api'
|
||||
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
|
||||
import { buildEpisodeRegex, findNumbers, REGEX_PRESETS } from './episode-regex'
|
||||
import { matchShowByName } from './match-show'
|
||||
|
||||
/** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */
|
||||
function formatSize(bytes: number): string {
|
||||
const units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ']
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024
|
||||
unit++
|
||||
}
|
||||
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Ручной разбор `manual/`: каталог не сканируется, файлы выбирает админ и сразу указывает шоу.
|
||||
* Импортированные файлы уходят из каталога — ровно как из обычного inbox, — а спутники (субтитры,
|
||||
* nfo) удаляются, чтобы не оставалось мусора.
|
||||
*
|
||||
* Номера сезона и серии распознаются здесь же и в таком виде и уходят на сервер: что показано,
|
||||
* то и сохранится.
|
||||
*/
|
||||
export function ManualInboxDialog({ onClose }: Readonly<{ onClose: () => void }>) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [showId, setShowId] = useState('')
|
||||
// Ручной выбор шоу отключает автоопределение: перебивать решение человека нельзя.
|
||||
const [showPicked, setShowPicked] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [seasonStr, setSeasonStr] = useState('')
|
||||
const [regexStr, setRegexStr] = useState('')
|
||||
const [collapsed, setCollapsed] = useState<string[]>([])
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: qk.media.manual,
|
||||
queryFn: listManualInbox,
|
||||
})
|
||||
const { data: shows } = useQuery({ queryKey: qk.shows.all, queryFn: () => listShows() })
|
||||
|
||||
const regexOk = isValidRegex(regexStr)
|
||||
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
|
||||
|
||||
// Распознанные номера считаются один раз на всё: их показывает список и их же уходит на сервер.
|
||||
const parsedByPath = useMemo(() => {
|
||||
const options = {
|
||||
seasonOverride:
|
||||
seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null,
|
||||
episodeRegex: regexOk ? regexStr : null,
|
||||
}
|
||||
const map = new Map<string, ReturnType<typeof parseEpisodeName>>()
|
||||
for (const file of data?.files ?? []) map.set(file.relativePath, parseEpisodeName(file.name, options))
|
||||
return map
|
||||
}, [data, seasonOverride, regexStr, regexOk])
|
||||
|
||||
const folders = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const matched = (data?.files ?? []).filter((file) =>
|
||||
q ? file.relativePath.toLowerCase().includes(q) : true,
|
||||
)
|
||||
|
||||
const grouped = new Map<string, ManualInboxFileDto[]>()
|
||||
for (const file of matched) {
|
||||
const list = grouped.get(file.folder) ?? []
|
||||
list.push(file)
|
||||
grouped.set(file.folder, list)
|
||||
}
|
||||
|
||||
// Внутри папки — в порядке серий: так сразу видно пропуски и что регулярка сработала.
|
||||
return [...grouped.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([folder, files]) => ({
|
||||
folder,
|
||||
files: [...files].sort((a, b) =>
|
||||
compareParsed(
|
||||
{ name: a.name, parsed: parsedByPath.get(a.relativePath) ?? { season: null, episode: null } },
|
||||
{ name: b.name, parsed: parsedByPath.get(b.relativePath) ?? { season: null, episode: null } },
|
||||
),
|
||||
),
|
||||
}))
|
||||
}, [data, query, parsedByPath])
|
||||
|
||||
const selectable = folders.flatMap((g) => g.files.filter((f) => !f.alreadyImported))
|
||||
|
||||
// Образец для конструктора — первый файл списка: по нему и указывают, где номер серии.
|
||||
const sample = selectable[0] ?? folders[0]?.files[0]
|
||||
const sampleParts = useMemo(() => {
|
||||
if (!sample) return []
|
||||
const numbers = findNumbers(sample.name)
|
||||
// start — позиция куска в имени файла: она уникальна в пределах образца и годится как key,
|
||||
// в отличие от индекса (куски одинакового текста встречаются в имени по нескольку раз).
|
||||
const parts: { start: number; text: string; number: number | null }[] = []
|
||||
let cursor = 0
|
||||
for (const number of numbers) {
|
||||
if (number.start > cursor)
|
||||
parts.push({
|
||||
start: cursor,
|
||||
text: sample.name.slice(cursor, number.start),
|
||||
number: null,
|
||||
})
|
||||
parts.push({ start: number.start, text: number.text, number: number.index })
|
||||
cursor = number.start + number.text.length
|
||||
}
|
||||
if (cursor < sample.name.length)
|
||||
parts.push({ start: cursor, text: sample.name.slice(cursor), number: null })
|
||||
return parts
|
||||
}, [sample])
|
||||
/**
|
||||
* Автоопределение шоу по имени релиза — то же, что в загрузке в шоу. Сначала пробуем имя файла,
|
||||
* затем имя папки: в раздачах название сериала обычно есть и там, и там («Mr.Pickles.S01.1080p»).
|
||||
*/
|
||||
const detectedShowId = useMemo(
|
||||
() =>
|
||||
shows && sample
|
||||
? (matchShowByName(sample.name, shows) ?? matchShowByName(sample.folder, shows))
|
||||
: undefined,
|
||||
[shows, sample],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (showPicked || showId || !detectedShowId) return
|
||||
setShowId(detectedShowId)
|
||||
}, [detectedShowId, showPicked, showId])
|
||||
|
||||
const autoDetected = !showPicked && !!detectedShowId && showId === detectedShowId
|
||||
|
||||
const recognized = selectable.filter(
|
||||
(f) => parsedByPath.get(f.relativePath)?.episode != null,
|
||||
).length
|
||||
|
||||
const onError = useApiError()
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
importManualInbox(
|
||||
selected.map((relativePath) => {
|
||||
const parsed = parsedByPath.get(relativePath)
|
||||
return {
|
||||
relativePath,
|
||||
season: parsed?.episode != null ? (parsed.season ?? 1) : null,
|
||||
episode: parsed?.episode ?? null,
|
||||
}
|
||||
}),
|
||||
showId,
|
||||
),
|
||||
onSuccess: (result) => {
|
||||
if (result.imported > 0)
|
||||
toast.success(t('admin.media.manualImported', { count: result.imported }))
|
||||
// Отказы показываем по одному: у каждого своя причина, и файл остаётся в каталоге.
|
||||
for (const failure of result.failed)
|
||||
toast.error(`${failure.relativePath}: ${failure.reason}`)
|
||||
|
||||
setSelected([])
|
||||
void queryClient.invalidateQueries({ queryKey: qk.media.all })
|
||||
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
|
||||
if (result.failed.length === 0) onClose()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const toggle = (path: string) =>
|
||||
setSelected((current) =>
|
||||
current.includes(path) ? current.filter((p) => p !== path) : [...current, path],
|
||||
)
|
||||
|
||||
const toggleCollapsed = (folder: string) =>
|
||||
setCollapsed((current) =>
|
||||
current.includes(folder) ? current.filter((f) => f !== folder) : [...current, folder],
|
||||
)
|
||||
|
||||
const toggleFolder = (files: ManualInboxFileDto[]) => {
|
||||
const paths = files.filter((f) => !f.alreadyImported).map((f) => f.relativePath)
|
||||
const allSelected = paths.every((p) => selected.includes(p))
|
||||
setSelected((current) =>
|
||||
allSelected
|
||||
? current.filter((p) => !paths.includes(p))
|
||||
: [...new Set([...current, ...paths])],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.media.manualTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('admin.media.manualHint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.media.manualShow')}</Label>
|
||||
<Select
|
||||
value={showId}
|
||||
onValueChange={(value) => {
|
||||
setShowPicked(true)
|
||||
setShowId(value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('admin.media.manualPickShow')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(shows ?? []).map((show) => (
|
||||
<SelectItem key={show.id} value={show.id}>
|
||||
{show.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{autoDetected && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.media.manualDetected')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('common.search')}</Label>
|
||||
<Input value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.media.toShowSeason')}</Label>
|
||||
{/* Ноль — законный сезон: в нём живут спецвыпуски и пилоты. */}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t('admin.media.toShowAuto')}
|
||||
value={seasonStr}
|
||||
onChange={(e) => setSeasonStr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.media.toShowRegex')}</Label>
|
||||
<Input
|
||||
placeholder="^(\d+)"
|
||||
value={regexStr}
|
||||
onChange={(e) => setRegexStr(e.target.value)}
|
||||
className={!regexOk ? 'border-red-500' : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!regexOk && <p className="text-xs text-red-500">{t('admin.media.toShowRegexInvalid')}</p>}
|
||||
|
||||
{/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */}
|
||||
{sample && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.media.regexPickHint')}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-0.5 font-mono text-xs">
|
||||
{sampleParts.map((part) =>
|
||||
part.number === null ? (
|
||||
<span key={part.start} className="text-muted-foreground">
|
||||
{part.text}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={part.start}
|
||||
type="button"
|
||||
title={t('admin.media.regexPickTitle')}
|
||||
className="rounded border border-primary/60 bg-primary/10 px-1 text-primary hover:bg-primary/25"
|
||||
onClick={() => setRegexStr(buildEpisodeRegex(sample.name, part.number!))}
|
||||
>
|
||||
{part.text}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.media.regexPresets')}
|
||||
</span>
|
||||
{REGEX_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.key}
|
||||
type="button"
|
||||
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
||||
onClick={() => setRegexStr(preset.pattern)}
|
||||
>
|
||||
{t(`admin.media.regexPresetNames.${preset.key}`)}
|
||||
</button>
|
||||
))}
|
||||
{regexStr && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
||||
onClick={() => setRegexStr('')}
|
||||
>
|
||||
{t('admin.media.regexClear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={selectable.length === 0}
|
||||
onClick={() =>
|
||||
setSelected(
|
||||
selected.length === selectable.length
|
||||
? []
|
||||
: selectable.map((f) => f.relativePath),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('admin.media.manualSelectAll')}
|
||||
</Button>
|
||||
<span className="text-muted-foreground">
|
||||
{t('admin.media.manualSelected', { count: selected.length })}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t('admin.media.manualRecognized', {
|
||||
count: recognized,
|
||||
total: selectable.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="crt-panel max-h-80 overflow-y-auto rounded-md text-sm">
|
||||
{isLoading && <p className="px-3 py-2 text-muted-foreground">{t('common.loading')}</p>}
|
||||
{!isLoading && folders.length === 0 && (
|
||||
<p className="px-3 py-2 text-muted-foreground">{t('admin.media.manualEmpty')}</p>
|
||||
)}
|
||||
|
||||
{folders.map(({ folder, files }) => {
|
||||
const isCollapsed = collapsed.includes(folder)
|
||||
return (
|
||||
<div key={folder || '/'} className="border-b border-border last:border-0">
|
||||
<div className="flex items-center gap-2 bg-muted/30 px-3 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => toggleCollapsed(folder)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="shrink-0"
|
||||
checked={files
|
||||
.filter((f) => !f.alreadyImported)
|
||||
.every((f) => selected.includes(f.relativePath))}
|
||||
onChange={() => toggleFolder(files)}
|
||||
/>
|
||||
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium" title={folder}>
|
||||
{folder || t('admin.media.manualRoot')}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{files.length}</span>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<ul className="divide-y divide-border">
|
||||
{files.map((file) => {
|
||||
const label = formatSeasonEpisode(
|
||||
parsedByPath.get(file.relativePath) ?? { season: null, episode: null },
|
||||
)
|
||||
return (
|
||||
<li
|
||||
key={file.relativePath}
|
||||
className="flex items-center gap-2 px-3 py-1.5 pl-9"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="shrink-0"
|
||||
disabled={file.alreadyImported}
|
||||
checked={selected.includes(file.relativePath)}
|
||||
onChange={() => toggle(file.relativePath)}
|
||||
/>
|
||||
{label ? (
|
||||
<Badge className="shrink-0">{label}</Badge>
|
||||
) : (
|
||||
<Badge variant="muted" className="shrink-0">
|
||||
{t('admin.media.toShowUnknown')}
|
||||
</Badge>
|
||||
)}
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate ${file.alreadyImported ? 'text-muted-foreground' : ''}`}
|
||||
title={file.name}
|
||||
>
|
||||
{file.name}
|
||||
</span>
|
||||
{file.alreadyImported && (
|
||||
<Badge variant="muted">{t('admin.media.manualAlready')}</Badge>
|
||||
)}
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatSize(file.sizeBytes)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{data?.truncated && (
|
||||
<p className="text-xs text-amber-500">{t('admin.media.manualTruncated')}</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">{t('admin.media.manualCleanupHint')}</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="outline" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={selected.length === 0 || !showId || importMutation.isPending}
|
||||
onClick={() => importMutation.mutate()}
|
||||
>
|
||||
{t('admin.media.manualImport')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ChevronDown, ChevronRight, Folder } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listShows } from '@/features/admin/shows/api'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import type { ManualInboxFileDto } from '@/shared/api/types'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { importManualInbox, listManualInbox } from './api'
|
||||
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
|
||||
import { buildEpisodeRegex, findNumbers, REGEX_PRESETS } from './episode-regex'
|
||||
import { matchShowByName } from './match-show'
|
||||
|
||||
/** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */
|
||||
function formatSize(bytes: number): string {
|
||||
const units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ']
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024
|
||||
unit++
|
||||
}
|
||||
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Ручной разбор `manual/`: каталог не сканируется, файлы выбирает админ и сразу указывает шоу.
|
||||
* Импортированные файлы уходят из каталога — ровно как из обычного inbox, — а спутники (субтитры,
|
||||
* nfo) удаляются, чтобы не оставалось мусора.
|
||||
*
|
||||
* Номера сезона и серии распознаются здесь же и в таком виде и уходят на сервер: что показано,
|
||||
* то и сохранится.
|
||||
*/
|
||||
export function ManualInboxDialog({ onClose }: Readonly<{ onClose: () => void }>) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [showId, setShowId] = useState('')
|
||||
// Ручной выбор шоу отключает автоопределение: перебивать решение человека нельзя.
|
||||
const [showPicked, setShowPicked] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [seasonStr, setSeasonStr] = useState('')
|
||||
const [regexStr, setRegexStr] = useState('')
|
||||
const [collapsed, setCollapsed] = useState<string[]>([])
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: qk.media.manual,
|
||||
queryFn: listManualInbox,
|
||||
})
|
||||
const { data: shows } = useQuery({ queryKey: qk.shows.all, queryFn: () => listShows() })
|
||||
|
||||
const regexOk = isValidRegex(regexStr)
|
||||
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
|
||||
|
||||
// Распознанные номера считаются один раз на всё: их показывает список и их же уходит на сервер.
|
||||
const parsedByPath = useMemo(() => {
|
||||
const options = {
|
||||
seasonOverride:
|
||||
seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null,
|
||||
episodeRegex: regexOk ? regexStr : null,
|
||||
}
|
||||
const map = new Map<string, ReturnType<typeof parseEpisodeName>>()
|
||||
for (const file of data?.files ?? [])
|
||||
map.set(file.relativePath, parseEpisodeName(file.name, options))
|
||||
return map
|
||||
}, [data, seasonOverride, regexStr, regexOk])
|
||||
|
||||
const folders = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const matched = (data?.files ?? []).filter((file) =>
|
||||
q ? file.relativePath.toLowerCase().includes(q) : true,
|
||||
)
|
||||
|
||||
const grouped = new Map<string, ManualInboxFileDto[]>()
|
||||
for (const file of matched) {
|
||||
const list = grouped.get(file.folder) ?? []
|
||||
list.push(file)
|
||||
grouped.set(file.folder, list)
|
||||
}
|
||||
|
||||
// Внутри папки — в порядке серий: так сразу видно пропуски и что регулярка сработала.
|
||||
return [...grouped.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([folder, files]) => ({
|
||||
folder,
|
||||
files: [...files].sort((a, b) =>
|
||||
compareParsed(
|
||||
{
|
||||
name: a.name,
|
||||
parsed: parsedByPath.get(a.relativePath) ?? { season: null, episode: null },
|
||||
},
|
||||
{
|
||||
name: b.name,
|
||||
parsed: parsedByPath.get(b.relativePath) ?? { season: null, episode: null },
|
||||
},
|
||||
),
|
||||
),
|
||||
}))
|
||||
}, [data, query, parsedByPath])
|
||||
|
||||
const selectable = folders.flatMap((g) => g.files.filter((f) => !f.alreadyImported))
|
||||
|
||||
// Образец для конструктора — первый файл списка: по нему и указывают, где номер серии.
|
||||
const sample = selectable[0] ?? folders[0]?.files[0]
|
||||
const sampleParts = useMemo(() => {
|
||||
if (!sample) return []
|
||||
const numbers = findNumbers(sample.name)
|
||||
// start — позиция куска в имени файла: она уникальна в пределах образца и годится как key,
|
||||
// в отличие от индекса (куски одинакового текста встречаются в имени по нескольку раз).
|
||||
const parts: { start: number; text: string; number: number | null }[] = []
|
||||
let cursor = 0
|
||||
for (const number of numbers) {
|
||||
if (number.start > cursor)
|
||||
parts.push({
|
||||
start: cursor,
|
||||
text: sample.name.slice(cursor, number.start),
|
||||
number: null,
|
||||
})
|
||||
parts.push({ start: number.start, text: number.text, number: number.index })
|
||||
cursor = number.start + number.text.length
|
||||
}
|
||||
if (cursor < sample.name.length)
|
||||
parts.push({ start: cursor, text: sample.name.slice(cursor), number: null })
|
||||
return parts
|
||||
}, [sample])
|
||||
/**
|
||||
* Автоопределение шоу по имени релиза — то же, что в загрузке в шоу. Сначала пробуем имя файла,
|
||||
* затем имя папки: в раздачах название сериала обычно есть и там, и там («Mr.Pickles.S01.1080p»).
|
||||
*/
|
||||
const detectedShowId = useMemo(
|
||||
() =>
|
||||
shows && sample
|
||||
? (matchShowByName(sample.name, shows) ?? matchShowByName(sample.folder, shows))
|
||||
: undefined,
|
||||
[shows, sample],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (showPicked || showId || !detectedShowId) return
|
||||
setShowId(detectedShowId)
|
||||
}, [detectedShowId, showPicked, showId])
|
||||
|
||||
const autoDetected = !showPicked && !!detectedShowId && showId === detectedShowId
|
||||
|
||||
const recognized = selectable.filter(
|
||||
(f) => parsedByPath.get(f.relativePath)?.episode != null,
|
||||
).length
|
||||
|
||||
const onError = useApiError()
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
importManualInbox(
|
||||
selected.map((relativePath) => {
|
||||
const parsed = parsedByPath.get(relativePath)
|
||||
return {
|
||||
relativePath,
|
||||
season: parsed?.episode != null ? (parsed.season ?? 1) : null,
|
||||
episode: parsed?.episode ?? null,
|
||||
}
|
||||
}),
|
||||
showId,
|
||||
),
|
||||
onSuccess: (result) => {
|
||||
if (result.imported > 0)
|
||||
toast.success(t('admin.media.manualImported', { count: result.imported }))
|
||||
// Отказы показываем по одному: у каждого своя причина, и файл остаётся в каталоге.
|
||||
for (const failure of result.failed) toast.error(`${failure.relativePath}: ${failure.reason}`)
|
||||
|
||||
setSelected([])
|
||||
void queryClient.invalidateQueries({ queryKey: qk.media.all })
|
||||
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
|
||||
if (result.failed.length === 0) onClose()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const toggle = (path: string) =>
|
||||
setSelected((current) =>
|
||||
current.includes(path) ? current.filter((p) => p !== path) : [...current, path],
|
||||
)
|
||||
|
||||
const toggleCollapsed = (folder: string) =>
|
||||
setCollapsed((current) =>
|
||||
current.includes(folder) ? current.filter((f) => f !== folder) : [...current, folder],
|
||||
)
|
||||
|
||||
const toggleFolder = (files: ManualInboxFileDto[]) => {
|
||||
const paths = files.filter((f) => !f.alreadyImported).map((f) => f.relativePath)
|
||||
const allSelected = paths.every((p) => selected.includes(p))
|
||||
setSelected((current) =>
|
||||
allSelected
|
||||
? current.filter((p) => !paths.includes(p))
|
||||
: [...new Set([...current, ...paths])],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.media.manualTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('admin.media.manualHint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.media.manualShow')}</Label>
|
||||
<Select
|
||||
value={showId}
|
||||
onValueChange={(value) => {
|
||||
setShowPicked(true)
|
||||
setShowId(value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('admin.media.manualPickShow')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(shows ?? []).map((show) => (
|
||||
<SelectItem key={show.id} value={show.id}>
|
||||
{show.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{autoDetected && (
|
||||
<p className="text-xs text-muted-foreground">{t('admin.media.manualDetected')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('common.search')}</Label>
|
||||
<Input value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.media.toShowSeason')}</Label>
|
||||
{/* Ноль — законный сезон: в нём живут спецвыпуски и пилоты. */}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t('admin.media.toShowAuto')}
|
||||
value={seasonStr}
|
||||
onChange={(e) => setSeasonStr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.media.toShowRegex')}</Label>
|
||||
<Input
|
||||
placeholder="^(\d+)"
|
||||
value={regexStr}
|
||||
onChange={(e) => setRegexStr(e.target.value)}
|
||||
className={!regexOk ? 'border-red-500' : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!regexOk && (
|
||||
<p className="text-xs text-red-500">{t('admin.media.toShowRegexInvalid')}</p>
|
||||
)}
|
||||
|
||||
{/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */}
|
||||
{sample && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.media.regexPickHint')}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-0.5 font-mono text-xs">
|
||||
{sampleParts.map((part) =>
|
||||
part.number === null ? (
|
||||
<span key={part.start} className="text-muted-foreground">
|
||||
{part.text}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={part.start}
|
||||
type="button"
|
||||
title={t('admin.media.regexPickTitle')}
|
||||
className="rounded border border-primary/60 bg-primary/10 px-1 text-primary hover:bg-primary/25"
|
||||
onClick={() => setRegexStr(buildEpisodeRegex(sample.name, part.number!))}
|
||||
>
|
||||
{part.text}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.media.regexPresets')}
|
||||
</span>
|
||||
{REGEX_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.key}
|
||||
type="button"
|
||||
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
||||
onClick={() => setRegexStr(preset.pattern)}
|
||||
>
|
||||
{t(`admin.media.regexPresetNames.${preset.key}`)}
|
||||
</button>
|
||||
))}
|
||||
{regexStr && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
||||
onClick={() => setRegexStr('')}
|
||||
>
|
||||
{t('admin.media.regexClear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={selectable.length === 0}
|
||||
onClick={() =>
|
||||
setSelected(
|
||||
selected.length === selectable.length
|
||||
? []
|
||||
: selectable.map((f) => f.relativePath),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('admin.media.manualSelectAll')}
|
||||
</Button>
|
||||
<span className="text-muted-foreground">
|
||||
{t('admin.media.manualSelected', { count: selected.length })}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t('admin.media.manualRecognized', {
|
||||
count: recognized,
|
||||
total: selectable.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="crt-panel max-h-80 overflow-y-auto rounded-md text-sm">
|
||||
{isLoading && <p className="px-3 py-2 text-muted-foreground">{t('common.loading')}</p>}
|
||||
{!isLoading && folders.length === 0 && (
|
||||
<p className="px-3 py-2 text-muted-foreground">{t('admin.media.manualEmpty')}</p>
|
||||
)}
|
||||
|
||||
{folders.map(({ folder, files }) => {
|
||||
const isCollapsed = collapsed.includes(folder)
|
||||
return (
|
||||
<div key={folder || '/'} className="border-b border-border last:border-0">
|
||||
<div className="flex items-center gap-2 bg-muted/30 px-3 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => toggleCollapsed(folder)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="shrink-0"
|
||||
checked={files
|
||||
.filter((f) => !f.alreadyImported)
|
||||
.every((f) => selected.includes(f.relativePath))}
|
||||
onChange={() => toggleFolder(files)}
|
||||
/>
|
||||
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium" title={folder}>
|
||||
{folder || t('admin.media.manualRoot')}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{files.length}</span>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<ul className="divide-y divide-border">
|
||||
{files.map((file) => {
|
||||
const label = formatSeasonEpisode(
|
||||
parsedByPath.get(file.relativePath) ?? { season: null, episode: null },
|
||||
)
|
||||
return (
|
||||
<li
|
||||
key={file.relativePath}
|
||||
className="flex items-center gap-2 px-3 py-1.5 pl-9"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="shrink-0"
|
||||
disabled={file.alreadyImported}
|
||||
checked={selected.includes(file.relativePath)}
|
||||
onChange={() => toggle(file.relativePath)}
|
||||
/>
|
||||
{label ? (
|
||||
<Badge className="shrink-0">{label}</Badge>
|
||||
) : (
|
||||
<Badge variant="muted" className="shrink-0">
|
||||
{t('admin.media.toShowUnknown')}
|
||||
</Badge>
|
||||
)}
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate ${file.alreadyImported ? 'text-muted-foreground' : ''}`}
|
||||
title={file.name}
|
||||
>
|
||||
{file.name}
|
||||
</span>
|
||||
{file.alreadyImported && (
|
||||
<Badge variant="muted">{t('admin.media.manualAlready')}</Badge>
|
||||
)}
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatSize(file.sizeBytes)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{data?.truncated && (
|
||||
<p className="text-xs text-amber-500">{t('admin.media.manualTruncated')}</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">{t('admin.media.manualCleanupHint')}</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="outline" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={selected.length === 0 || !showId || importMutation.isPending}
|
||||
onClick={() => importMutation.mutate()}
|
||||
>
|
||||
{t('admin.media.manualImport')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertCircle, Check, ChevronDown, ChevronUp, Clock, Loader2, RotateCw, X } from 'lucide-react'
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Loader2,
|
||||
RotateCw,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { type UploadItem, useUploadStore } from './upload-store'
|
||||
|
||||
|
||||
@@ -57,7 +57,8 @@ export function UploadToShowDialog({
|
||||
// Предпросмотр: что распарсим для каждого файла при текущих настройках, в порядке добавления.
|
||||
const previews = useMemo(() => {
|
||||
const opts = {
|
||||
seasonOverride: seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null,
|
||||
seasonOverride:
|
||||
seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null,
|
||||
episodeRegex: regexOk ? regexStr : null,
|
||||
}
|
||||
return files
|
||||
@@ -157,7 +158,9 @@ export function UploadToShowDialog({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={LIBRARY_VALUE}>{t('admin.media.toShowLibrary')}</SelectItem>
|
||||
<SelectItem value={LIBRARY_VALUE}>
|
||||
{t('admin.media.toShowLibrary')}
|
||||
</SelectItem>
|
||||
{shows?.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
|
||||
@@ -23,7 +23,10 @@ const MIN_CANDIDATE_LENGTH = 2
|
||||
* Возвращает id наиболее подходящего шоу для имени файла или undefined, если совпадений нет.
|
||||
* Совпадением считается вхождение названия шоу как цельной последовательности слов в имя файла.
|
||||
*/
|
||||
export function matchShowByName(fileName: string, shows: readonly ShowNameRef[]): string | undefined {
|
||||
export function matchShowByName(
|
||||
fileName: string,
|
||||
shows: readonly ShowNameRef[],
|
||||
): string | undefined {
|
||||
const haystack = ` ${normalize(fileName)} `
|
||||
let best: { id: string; length: number } | undefined
|
||||
|
||||
|
||||
@@ -56,7 +56,9 @@ export function RolesPanel() {
|
||||
})
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const { register, handleSubmit, reset } = useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema) })
|
||||
const { register, handleSubmit, reset } = useForm<z.infer<typeof schema>>({
|
||||
resolver: zodResolver(schema),
|
||||
})
|
||||
|
||||
const onCreate = async (values: z.infer<typeof schema>) => {
|
||||
try {
|
||||
@@ -128,7 +130,11 @@ export function RolesPanel() {
|
||||
<tr key={role.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-2">{role.name}</td>
|
||||
<td className="px-4 py-2">
|
||||
{role.isSystem ? <Badge variant="muted">{t('common.yes')}</Badge> : t('common.no')}
|
||||
{role.isSystem ? (
|
||||
<Badge variant="muted">{t('common.yes')}</Badge>
|
||||
) : (
|
||||
t('common.no')
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -240,30 +240,36 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
|
||||
>
|
||||
{t('admin.shows.deselectAll')}
|
||||
</Button>
|
||||
<Button size="sm" disabled={selected.length === 0 || adding != null} onClick={() => void bulkAdd()}>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={selected.length === 0 || adding != null}
|
||||
onClick={() => void bulkAdd()}
|
||||
>
|
||||
{addButtonLabel(adding, isSingle ? Math.min(1, selected.length) : selected.length, t)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="crt-panel max-h-72 overflow-y-auto rounded-md">
|
||||
{candidates.length === 0 ? (
|
||||
<p className="px-4 py-3 text-sm text-muted-foreground">{t('admin.shows.noMatches')}</p>
|
||||
<p className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{t('admin.shows.noMatches')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border text-sm">
|
||||
{candItems.map(({ asset, parsed }) => {
|
||||
const label = formatSeasonEpisode(parsed)
|
||||
return (
|
||||
<li key={asset.id}>
|
||||
<label className="flex cursor-pointer items-center gap-3 px-4 py-2 hover:bg-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!deselected.has(asset.id)}
|
||||
onChange={() => toggle(asset.id)}
|
||||
/>
|
||||
{label ? <Badge>{label}</Badge> : <Badge variant="muted">—</Badge>}
|
||||
<span className="truncate">{asset.originalFileName}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li key={asset.id}>
|
||||
<label className="flex cursor-pointer items-center gap-3 px-4 py-2 hover:bg-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!deselected.has(asset.id)}
|
||||
onChange={() => toggle(asset.id)}
|
||||
/>
|
||||
{label ? <Badge>{label}</Badge> : <Badge variant="muted">—</Badge>}
|
||||
<span className="truncate">{asset.originalFileName}</span>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
@@ -295,48 +301,48 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
|
||||
: parseEpisodeName(episode.assetName ?? '')
|
||||
const label = formatSeasonEpisode(parsed)
|
||||
return (
|
||||
<tr key={episode.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-2 text-muted-foreground">{epOffset + index + 1}</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{episode.stillImageId && (
|
||||
<img
|
||||
src={imageUrl(episode.stillImageId)}
|
||||
alt=""
|
||||
className="h-9 w-16 shrink-0 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
{label && <Badge>{label}</Badge>}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate">{episode.title ?? episode.assetName ?? '—'}</div>
|
||||
{episode.title && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{episode.assetName}
|
||||
</div>
|
||||
<tr key={episode.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-2 text-muted-foreground">{epOffset + index + 1}</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{episode.stillImageId && (
|
||||
<img
|
||||
src={imageUrl(episode.stillImageId)}
|
||||
alt=""
|
||||
className="h-9 w-16 shrink-0 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
{label && <Badge>{label}</Badge>}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate">{episode.title ?? episode.assetName ?? '—'}</div>
|
||||
{episode.title && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{episode.assetName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">
|
||||
{formatDuration(episode.durationSeconds)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{episode.assetStatus && (
|
||||
<Badge variant={episode.assetStatus === 'Ready' ? 'default' : 'muted'}>
|
||||
{t(`admin.media.statuses.${episode.assetStatus}`)}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => removeMutation.mutate(episode.id)}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">
|
||||
{formatDuration(episode.durationSeconds)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{episode.assetStatus && (
|
||||
<Badge variant={episode.assetStatus === 'Ready' ? 'default' : 'muted'}>
|
||||
{t(`admin.media.statuses.${episode.assetStatus}`)}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => removeMutation.mutate(episode.id)}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{show.episodes.length === 0 && (
|
||||
|
||||
@@ -95,7 +95,8 @@ export function ShowMetadataCard({
|
||||
mutationFn: async () => {
|
||||
const yearNum = year.trim() ? Number(year) : null
|
||||
const infoChanged =
|
||||
(description.trim() || null) !== (show.description ?? null) || yearNum !== (show.year ?? null)
|
||||
(description.trim() || null) !== (show.description ?? null) ||
|
||||
yearNum !== (show.year ?? null)
|
||||
if (name.trim() && name.trim() !== show.name) await renameShow(show.id, name.trim())
|
||||
if (originalName.trim() !== (show.originalName ?? ''))
|
||||
await setShowOriginalName(show.id, originalName.trim() || null)
|
||||
@@ -137,226 +138,238 @@ export function ShowMetadataCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.metadata.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 sm:flex-row">
|
||||
{/* Постер */}
|
||||
<div className="flex w-40 shrink-0 flex-col gap-2">
|
||||
<div className="flex aspect-[2/3] items-center justify-center overflow-hidden rounded-md border border-border bg-muted/30">
|
||||
{show.posterImageId ? (
|
||||
<img
|
||||
src={imageUrl(show.posterImageId)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{t('admin.metadata.noPoster')}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
|
||||
{t('admin.metadata.pickPoster')}
|
||||
</Button>
|
||||
<ImageGallery
|
||||
open={galleryOpen}
|
||||
onOpenChange={setGalleryOpen}
|
||||
category="ShowPoster"
|
||||
onSelect={(img) => setPoster.mutate(img.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Название + поиск + ручная правка */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.metadata.name')}</Label>
|
||||
<Input value={name} maxLength={256} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.metadata.originalName')}</Label>
|
||||
<Input
|
||||
value={originalName}
|
||||
maxLength={256}
|
||||
placeholder={t('admin.metadata.originalNamePlaceholder')}
|
||||
onChange={(e) => {
|
||||
setOriginalName(e.target.value)
|
||||
setSearched(false)
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.metadata.originalNameHint')}</p>
|
||||
</div>
|
||||
|
||||
{providers && providers.length > 0 && (searched || results.length > 0) && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{searched && results.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">{t('admin.metadata.nothingFound')}</p>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md">
|
||||
{results.map((r) => (
|
||||
<li key={r.externalId} className="flex items-start gap-3 p-2">
|
||||
{r.posterUrl ? (
|
||||
<img src={r.posterUrl} alt="" className="h-16 w-11 shrink-0 rounded object-cover" />
|
||||
) : (
|
||||
<div className="h-16 w-11 shrink-0 rounded bg-muted/40" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-medium">
|
||||
<span>
|
||||
{r.title}
|
||||
{r.year != null && (
|
||||
<span className="text-muted-foreground"> ({r.year})</span>
|
||||
)}
|
||||
</span>
|
||||
{/* В выдаче OMDb сериалы и полнометражки идут вперемешку — без метки
|
||||
одноимённые фильм и сериал не различить. */}
|
||||
{r.kind && (
|
||||
<Badge variant="muted">{t(`admin.shows.kinds.${r.kind}`)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{r.overview && (
|
||||
<p className="line-clamp-2 text-xs text-muted-foreground">{r.overview}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={apply.isPending}
|
||||
onClick={() => apply.mutate(r.externalId)}
|
||||
>
|
||||
{t('admin.metadata.apply')}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<Label>{t('admin.metadata.overview')}</Label>
|
||||
<textarea
|
||||
className="min-h-20 w-full rounded-sm border border-border bg-transparent px-3 py-2 text-sm"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.metadata.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 sm:flex-row">
|
||||
{/* Постер */}
|
||||
<div className="flex w-40 shrink-0 flex-col gap-2">
|
||||
<div className="flex aspect-[2/3] items-center justify-center overflow-hidden rounded-md border border-border bg-muted/30">
|
||||
{show.posterImageId ? (
|
||||
<img
|
||||
src={imageUrl(show.posterImageId)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-24 flex-col gap-1.5">
|
||||
<Label>{t('admin.metadata.year')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{providers && providers.length > 0 && (
|
||||
<>
|
||||
<Select value={effectiveProvider} onValueChange={setProvider}>
|
||||
<SelectTrigger className="h-9 w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{p.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={search.isPending || !searchTerm}
|
||||
onClick={() => search.mutate()}
|
||||
>
|
||||
{t('admin.metadata.searchBtn')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="sm" disabled={save.isPending || !name.trim()} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{linked && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={refreshEpisodes.isPending}
|
||||
onClick={() => refreshEpisodes.mutate()}
|
||||
>
|
||||
{refreshEpisodes.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{refreshEpisodes.isPending
|
||||
? t('admin.metadata.refreshing')
|
||||
: t('admin.metadata.refreshEpisodes')}
|
||||
</Button>
|
||||
)}
|
||||
{linked && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={findMissing.isPending}
|
||||
onClick={() => findMissing.mutate()}
|
||||
>
|
||||
{findMissing.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t('admin.metadata.findMissing')}
|
||||
</Button>
|
||||
)}
|
||||
{(show.metadataProvider || show.posterImageId) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={clear.isPending}
|
||||
onClick={() => clear.mutate()}
|
||||
>
|
||||
{t('admin.metadata.clear')}
|
||||
</Button>
|
||||
)}
|
||||
{show.metadataProvider && (
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.metadata.sourceLabel')}: {show.metadataProvider}
|
||||
{t('admin.metadata.noPoster')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
|
||||
{t('admin.metadata.pickPoster')}
|
||||
</Button>
|
||||
<ImageGallery
|
||||
open={galleryOpen}
|
||||
onOpenChange={setGalleryOpen}
|
||||
category="ShowPoster"
|
||||
onSelect={(img) => setPoster.mutate(img.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={missing != null} onOpenChange={(open) => !open && setMissing(null)}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.metadata.missingTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{missing && missing.seasons.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('admin.metadata.missingNoSeasons')}</p>
|
||||
)}
|
||||
{missing && missing.seasons.length > 0 && (
|
||||
<div className="flex max-h-[60vh] flex-col gap-3 overflow-y-auto">
|
||||
{missing.seasons.map((s) => (
|
||||
<div key={s.season} className="rounded-md border border-border p-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{t('admin.metadata.seasonN', { n: s.season })}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.metadata.loadedOf', {
|
||||
loaded: s.loaded,
|
||||
total: s.expected ?? '?',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<SeasonGapNote gap={s} />
|
||||
{/* Название + поиск + ручная правка */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.metadata.name')}</Label>
|
||||
<Input value={name} maxLength={256} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.metadata.originalName')}</Label>
|
||||
<Input
|
||||
value={originalName}
|
||||
maxLength={256}
|
||||
placeholder={t('admin.metadata.originalNamePlaceholder')}
|
||||
onChange={(e) => {
|
||||
setOriginalName(e.target.value)
|
||||
setSearched(false)
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.metadata.originalNameHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{providers && providers.length > 0 && (searched || results.length > 0) && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{searched && results.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.metadata.nothingFound')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md">
|
||||
{results.map((r) => (
|
||||
<li key={r.externalId} className="flex items-start gap-3 p-2">
|
||||
{r.posterUrl ? (
|
||||
<img
|
||||
src={r.posterUrl}
|
||||
alt=""
|
||||
className="h-16 w-11 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-16 w-11 shrink-0 rounded bg-muted/40" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-medium">
|
||||
<span>
|
||||
{r.title}
|
||||
{r.year != null && (
|
||||
<span className="text-muted-foreground"> ({r.year})</span>
|
||||
)}
|
||||
</span>
|
||||
{/* В выдаче OMDb сериалы и полнометражки идут вперемешку — без метки
|
||||
одноимённые фильм и сериал не различить. */}
|
||||
{r.kind && (
|
||||
<Badge variant="muted">{t(`admin.shows.kinds.${r.kind}`)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{r.overview && (
|
||||
<p className="line-clamp-2 text-xs text-muted-foreground">
|
||||
{r.overview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={apply.isPending}
|
||||
onClick={() => apply.mutate(r.externalId)}
|
||||
>
|
||||
{t('admin.metadata.apply')}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<Label>{t('admin.metadata.overview')}</Label>
|
||||
<textarea
|
||||
className="min-h-20 w-full rounded-sm border border-border bg-transparent px-3 py-2 text-sm"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-24 flex-col gap-1.5">
|
||||
<Label>{t('admin.metadata.year')}</Label>
|
||||
<Input type="number" value={year} onChange={(e) => setYear(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{providers && providers.length > 0 && (
|
||||
<>
|
||||
<Select value={effectiveProvider} onValueChange={setProvider}>
|
||||
<SelectTrigger className="h-9 w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{p.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={search.isPending || !searchTerm}
|
||||
onClick={() => search.mutate()}
|
||||
>
|
||||
{t('admin.metadata.searchBtn')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={save.isPending || !name.trim()}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{linked && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={refreshEpisodes.isPending}
|
||||
onClick={() => refreshEpisodes.mutate()}
|
||||
>
|
||||
{refreshEpisodes.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{refreshEpisodes.isPending
|
||||
? t('admin.metadata.refreshing')
|
||||
: t('admin.metadata.refreshEpisodes')}
|
||||
</Button>
|
||||
)}
|
||||
{linked && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={findMissing.isPending}
|
||||
onClick={() => findMissing.mutate()}
|
||||
>
|
||||
{findMissing.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t('admin.metadata.findMissing')}
|
||||
</Button>
|
||||
)}
|
||||
{(show.metadataProvider || show.posterImageId) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={clear.isPending}
|
||||
onClick={() => clear.mutate()}
|
||||
>
|
||||
{t('admin.metadata.clear')}
|
||||
</Button>
|
||||
)}
|
||||
{show.metadataProvider && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.metadata.sourceLabel')}: {show.metadataProvider}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={missing != null} onOpenChange={(open) => !open && setMissing(null)}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.metadata.missingTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{missing && missing.seasons.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('admin.metadata.missingNoSeasons')}</p>
|
||||
)}
|
||||
{missing && missing.seasons.length > 0 && (
|
||||
<div className="flex max-h-[60vh] flex-col gap-3 overflow-y-auto">
|
||||
{missing.seasons.map((s) => (
|
||||
<div key={s.season} className="rounded-md border border-border p-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{t('admin.metadata.seasonN', { n: s.season })}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.metadata.loadedOf', {
|
||||
loaded: s.loaded,
|
||||
total: s.expected ?? '?',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<SeasonGapNote gap={s} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,7 +86,10 @@ export function applyMetadata(showId: string, provider: string, externalId: stri
|
||||
})
|
||||
}
|
||||
|
||||
export function updateMetadata(showId: string, body: { description: string | null; year: number | null }) {
|
||||
export function updateMetadata(
|
||||
showId: string,
|
||||
body: { description: string | null; year: number | null },
|
||||
) {
|
||||
return apiRequest<void>(`/admin/metadata/shows/${showId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,7 @@ import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
@@ -224,7 +218,9 @@ export function UsersPanel() {
|
||||
<td className="px-4 py-2">
|
||||
<Select
|
||||
value={roles?.find((r) => r.name === user.role)?.id}
|
||||
onValueChange={(newRoleId) => changeRoleMutation.mutate({ userId: user.id, roleId: newRoleId })}
|
||||
onValueChange={(newRoleId) =>
|
||||
changeRoleMutation.mutate({ userId: user.id, roleId: newRoleId })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-32">
|
||||
<SelectValue>{user.role}</SelectValue>
|
||||
@@ -251,18 +247,30 @@ export function UsersPanel() {
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex gap-2">
|
||||
{user.isBlocked ? (
|
||||
<Button size="sm" variant="outline" onClick={() => unblockMutation.mutate(user.id)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => unblockMutation.mutate(user.id)}
|
||||
>
|
||||
{t('admin.users.unblock')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={() => blockMutation.mutate(user.id)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => blockMutation.mutate(user.id)}
|
||||
>
|
||||
{t('admin.users.block')}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => setResetTarget(user)}>
|
||||
{t('admin.users.resetPassword')}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => deleteMutation.mutate(user.id)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => deleteMutation.mutate(user.id)}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -275,13 +283,23 @@ export function UsersPanel() {
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
‹
|
||||
</Button>
|
||||
<span>
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
›
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,12 @@ export function LoginForm({ onSuccess }: Readonly<{ onSuccess: () => void }>) {
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="password">{t('auth.password')}</Label>
|
||||
<Input id="password" type="password" autoComplete="current-password" {...registerField('password')} />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
{...registerField('password')}
|
||||
/>
|
||||
{errors.password && <p className="text-xs text-red-500">{errors.password.message}</p>}
|
||||
</div>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
|
||||
@@ -47,7 +47,12 @@ export function RegisterForm({ onSuccess }: Readonly<{ onSuccess: () => void }>)
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="password">{t('auth.password')}</Label>
|
||||
<Input id="password" type="password" autoComplete="new-password" {...registerField('password')} />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
{...registerField('password')}
|
||||
/>
|
||||
{errors.password && <p className="text-xs text-red-500">{errors.password.message}</p>}
|
||||
</div>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
|
||||
@@ -12,7 +12,10 @@ export function login(userName: string, password: string) {
|
||||
}
|
||||
|
||||
export function register(userName: string, password: string) {
|
||||
return apiRequest<AuthResponse>('/auth/register', { method: 'POST', body: { userName, password } })
|
||||
return apiRequest<AuthResponse>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: { userName, password },
|
||||
})
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
@@ -20,7 +23,10 @@ export function logout() {
|
||||
}
|
||||
|
||||
export function changePassword(currentPassword: string, newPassword: string) {
|
||||
return apiRequest<void>('/auth/change-password', { method: 'POST', body: { currentPassword, newPassword } })
|
||||
return apiRequest<void>('/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: { currentPassword, newPassword },
|
||||
})
|
||||
}
|
||||
|
||||
export function changeUserName(newUserName: string) {
|
||||
@@ -39,7 +45,10 @@ export function applyAuthResponse(auth: AuthResponse) {
|
||||
/** Тихая попытка восстановить сессию по refresh-cookie при загрузке приложения. */
|
||||
export async function bootstrapSession() {
|
||||
try {
|
||||
const auth = await apiRequest<AuthResponse>('/auth/refresh', { method: 'POST', skipRefresh: true })
|
||||
const auth = await apiRequest<AuthResponse>('/auth/refresh', {
|
||||
method: 'POST',
|
||||
skipRefresh: true,
|
||||
})
|
||||
applyAuthResponse(auth)
|
||||
} catch {
|
||||
setAccessToken(null)
|
||||
|
||||
@@ -1,339 +1,332 @@
|
||||
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 { qk } from '@/shared/api/query-keys'
|
||||
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' })
|
||||
}
|
||||
|
||||
/** Кадр серии, если он есть; иначе постер шоу (он вертикальный, отсюда другая ширина). */
|
||||
function EntryThumb({ entry }: Readonly<{ entry: PublicEpgEntryDto | undefined }>) {
|
||||
if (entry?.episodeStillImageId)
|
||||
return (
|
||||
<img
|
||||
src={imageUrl(entry.episodeStillImageId)}
|
||||
alt=""
|
||||
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
|
||||
/>
|
||||
)
|
||||
|
||||
if (entry?.showPosterImageId)
|
||||
return (
|
||||
<img
|
||||
src={imageUrl(entry.showPosterImageId)}
|
||||
alt=""
|
||||
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
|
||||
/>
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
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: qk.air.channels,
|
||||
queryFn: listChannels,
|
||||
})
|
||||
const { data: features } = useQuery({ queryKey: qk.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: qk.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}
|
||||
type="button"
|
||||
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">
|
||||
{/* До выдачи cookie tw_stream плеер грузить нечем — держим место заглушкой. */}
|
||||
{(!selected || !watchReady) && (
|
||||
<div className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{selected && watchReady && !playerError && (
|
||||
<ChannelPlayer
|
||||
key={`${selected}-${attempt}`}
|
||||
slug={selected}
|
||||
channel={currentChannel}
|
||||
nextUp={nextUp}
|
||||
flash={flash}
|
||||
onUnavailable={handleUnavailable}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{current && (
|
||||
<div className="crt-panel flex gap-3 rounded-md p-3">
|
||||
<EntryThumb entry={currentEntry} />
|
||||
<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 { qk } from '@/shared/api/query-keys'
|
||||
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' })
|
||||
}
|
||||
|
||||
/** Кадр серии, если он есть; иначе постер шоу (он вертикальный, отсюда другая ширина). */
|
||||
function EntryThumb({ entry }: Readonly<{ entry: PublicEpgEntryDto | undefined }>) {
|
||||
if (entry?.episodeStillImageId)
|
||||
return (
|
||||
<img
|
||||
src={imageUrl(entry.episodeStillImageId)}
|
||||
alt=""
|
||||
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
|
||||
/>
|
||||
)
|
||||
|
||||
if (entry?.showPosterImageId)
|
||||
return (
|
||||
<img
|
||||
src={imageUrl(entry.showPosterImageId)}
|
||||
alt=""
|
||||
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
|
||||
/>
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
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: qk.air.channels,
|
||||
queryFn: listChannels,
|
||||
})
|
||||
const { data: features } = useQuery({ queryKey: qk.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: qk.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}
|
||||
type="button"
|
||||
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">
|
||||
{/* До выдачи cookie tw_stream плеер грузить нечем — держим место заглушкой. */}
|
||||
{(!selected || !watchReady) && (
|
||||
<div className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{selected && watchReady && !playerError && (
|
||||
<ChannelPlayer
|
||||
key={`${selected}-${attempt}`}
|
||||
slug={selected}
|
||||
channel={currentChannel}
|
||||
nextUp={nextUp}
|
||||
flash={flash}
|
||||
onUnavailable={handleUnavailable}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{current && (
|
||||
<div className="crt-panel flex gap-3 rounded-md p-3">
|
||||
<EntryThumb entry={currentEntry} />
|
||||
<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,286 +1,285 @@
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстанавливает сохранённую громкость/mute и запускает воспроизведение; если браузер блокирует
|
||||
* автоплей со звуком — откатывается на воспроизведение без звука (о чём сообщает <c>onMuted</c>).
|
||||
*/
|
||||
function startPlaybackWithAudio(
|
||||
video: HTMLVideoElement,
|
||||
audio: { volume: number; muted: boolean },
|
||||
onMuted: (muted: boolean) => void,
|
||||
) {
|
||||
video.volume = audio.volume
|
||||
video.muted = audio.muted
|
||||
video.play().catch(() => {
|
||||
video.muted = true
|
||||
onMuted(true)
|
||||
void video.play().catch(() => undefined)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* HLS-плеер линейного канала. Это живой эфир: ни перемотки, ни паузы — только звук, громкость и
|
||||
* полноэкранный режим. Cookie tw_stream уже выдана к монтированию.
|
||||
*/
|
||||
export function ChannelPlayer({
|
||||
slug,
|
||||
channel,
|
||||
nextUp,
|
||||
flash,
|
||||
onUnavailable,
|
||||
}: Readonly<{
|
||||
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
|
||||
|
||||
const startPlayback = () => startPlaybackWithAudio(video, audioRef.current, setMuted)
|
||||
|
||||
// Слушатели нативной ветки — держим ссылки, чтобы снять их в 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>
|
||||
)
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстанавливает сохранённую громкость/mute и запускает воспроизведение; если браузер блокирует
|
||||
* автоплей со звуком — откатывается на воспроизведение без звука (о чём сообщает <c>onMuted</c>).
|
||||
*/
|
||||
function startPlaybackWithAudio(
|
||||
video: HTMLVideoElement,
|
||||
audio: { volume: number; muted: boolean },
|
||||
onMuted: (muted: boolean) => void,
|
||||
) {
|
||||
video.volume = audio.volume
|
||||
video.muted = audio.muted
|
||||
video.play().catch(() => {
|
||||
video.muted = true
|
||||
onMuted(true)
|
||||
void video.play().catch(() => undefined)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* HLS-плеер линейного канала. Это живой эфир: ни перемотки, ни паузы — только звук, громкость и
|
||||
* полноэкранный режим. Cookie tw_stream уже выдана к монтированию.
|
||||
*/
|
||||
export function ChannelPlayer({
|
||||
slug,
|
||||
channel,
|
||||
nextUp,
|
||||
flash,
|
||||
onUnavailable,
|
||||
}: Readonly<{
|
||||
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
|
||||
|
||||
const startPlayback = () => startPlaybackWithAudio(video, audioRef.current, setMuted)
|
||||
|
||||
// Слушатели нативной ветки — держим ссылки, чтобы снять их в 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,10 @@ export function ChannelLogo({
|
||||
src={imageUrl(imageId)}
|
||||
alt=""
|
||||
style={{ opacity }}
|
||||
className={cn('pointer-events-none absolute h-10 w-auto max-w-24 object-contain', CORNER_CLASS[corner])}
|
||||
className={cn(
|
||||
'pointer-events-none absolute h-10 w-auto max-w-24 object-contain',
|
||||
CORNER_CLASS[corner],
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,8 +36,13 @@ function RootLayout() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="border-b border-border">
|
||||
<div className={cn('mx-auto flex items-center justify-between gap-4 px-4 py-3', containerMax)}>
|
||||
<Link to="/" className="crt-glow flex items-center gap-2 text-lg font-bold tracking-widest">
|
||||
<div
|
||||
className={cn('mx-auto flex items-center justify-between gap-4 px-4 py-3', containerMax)}
|
||||
>
|
||||
<Link
|
||||
to="/"
|
||||
className="crt-glow flex items-center gap-2 text-lg font-bold tracking-widest"
|
||||
>
|
||||
<Radio className="h-5 w-5" />
|
||||
{t('appName')}
|
||||
</Link>
|
||||
@@ -45,11 +50,19 @@ function RootLayout() {
|
||||
<nav className="hidden items-center gap-4 text-sm md:flex">
|
||||
{user && (
|
||||
<>
|
||||
<Link to="/dashboard" className="hover:text-primary" activeProps={{ className: 'text-primary' }}>
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="hover:text-primary"
|
||||
activeProps={{ className: 'text-primary' }}
|
||||
>
|
||||
{t('nav.dashboard')}
|
||||
</Link>
|
||||
{user.role === 'admin' && (
|
||||
<Link to="/admin" className="hover:text-primary" activeProps={{ className: 'text-primary' }}>
|
||||
<Link
|
||||
to="/admin"
|
||||
className="hover:text-primary"
|
||||
activeProps={{ className: 'text-primary' }}
|
||||
>
|
||||
{t('nav.admin')}
|
||||
</Link>
|
||||
)}
|
||||
@@ -135,7 +148,11 @@ function RootLayout() {
|
||||
>
|
||||
{user.userName}
|
||||
</Link>
|
||||
<button type="button" className="py-1 text-left" onClick={() => void handleLogout()}>
|
||||
<button
|
||||
type="button"
|
||||
className="py-1 text-left"
|
||||
onClick={() => void handleLogout()}
|
||||
>
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { ChannelDetail } from '@/features/admin/channels/ChannelDetail'
|
||||
|
||||
export const Route = createFileRoute('/admin/channels/$channelId')({ component: ChannelDetailRoute })
|
||||
export const Route = createFileRoute('/admin/channels/$channelId')({
|
||||
component: ChannelDetailRoute,
|
||||
})
|
||||
|
||||
function ChannelDetailRoute() {
|
||||
const { channelId } = Route.useParams()
|
||||
|
||||
@@ -18,7 +18,9 @@ function HomePage() {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="crt-glow text-4xl font-bold tracking-[0.2em]">{t('home.title')}</h1>
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-muted-foreground">{t('home.subtitle')}</p>
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-muted-foreground">
|
||||
{t('home.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="max-w-md text-muted-foreground">{t('home.tagline')}</p>
|
||||
|
||||
@@ -16,26 +16,39 @@ import { toast } from '@/shared/ui/toast-store'
|
||||
export const Route = createFileRoute('/settings')({ component: SettingsPage })
|
||||
|
||||
const userNameSchema = z.object({ newUserName: z.string().min(3).max(64) })
|
||||
const passwordSchema = z.object({ currentPassword: z.string().min(1), newPassword: z.string().min(8) })
|
||||
const passwordSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8),
|
||||
})
|
||||
|
||||
function SettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const { isReady } = useRequireAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const userNameForm = useForm<z.infer<typeof userNameSchema>>({ resolver: zodResolver(userNameSchema) })
|
||||
const passwordForm = useForm<z.infer<typeof passwordSchema>>({ resolver: zodResolver(passwordSchema) })
|
||||
const userNameForm = useForm<z.infer<typeof userNameSchema>>({
|
||||
resolver: zodResolver(userNameSchema),
|
||||
})
|
||||
const passwordForm = useForm<z.infer<typeof passwordSchema>>({
|
||||
resolver: zodResolver(passwordSchema),
|
||||
})
|
||||
|
||||
if (!isReady) return null
|
||||
|
||||
const onSaveUserName = async (values: z.infer<typeof userNameSchema>) => {
|
||||
try {
|
||||
await changeUserName(values.newUserName)
|
||||
useAuthStore.getState().setUser({ ...useAuthStore.getState().user!, userName: values.newUserName })
|
||||
useAuthStore
|
||||
.getState()
|
||||
.setUser({ ...useAuthStore.getState().user!, userName: values.newUserName })
|
||||
toast.success(t('settings.saved'))
|
||||
userNameForm.reset()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof HttpError && error.status === 409 ? t('auth.userNameTaken') : t('common.error'))
|
||||
toast.error(
|
||||
error instanceof HttpError && error.status === 409
|
||||
? t('auth.userNameTaken')
|
||||
: t('common.error'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,12 +82,19 @@ function SettingsPage() {
|
||||
<CardTitle>{t('settings.changeUserName')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={userNameForm.handleSubmit(onSaveUserName)}>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={userNameForm.handleSubmit(onSaveUserName)}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="newUserName">{t('settings.newUserName')}</Label>
|
||||
<Input id="newUserName" {...userNameForm.register('newUserName')} />
|
||||
</div>
|
||||
<Button type="submit" className="self-start" disabled={userNameForm.formState.isSubmitting}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="self-start"
|
||||
disabled={userNameForm.formState.isSubmitting}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -86,16 +106,27 @@ function SettingsPage() {
|
||||
<CardTitle>{t('settings.changePassword')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={passwordForm.handleSubmit(onSavePassword)}>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={passwordForm.handleSubmit(onSavePassword)}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="currentPassword">{t('settings.currentPassword')}</Label>
|
||||
<Input id="currentPassword" type="password" {...passwordForm.register('currentPassword')} />
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
{...passwordForm.register('currentPassword')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="newPassword">{t('settings.newPassword')}</Label>
|
||||
<Input id="newPassword" type="password" {...passwordForm.register('newPassword')} />
|
||||
</div>
|
||||
<Button type="submit" className="self-start" disabled={passwordForm.formState.isSubmitting}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="self-start"
|
||||
disabled={passwordForm.formState.isSubmitting}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -28,7 +28,10 @@ export async function refreshAccessToken(): Promise<boolean> {
|
||||
if (!refreshInFlight) {
|
||||
refreshInFlight = (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!response.ok) return false
|
||||
const data = (await response.json()) as { accessToken?: unknown }
|
||||
if (typeof data?.accessToken !== 'string') return false
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react'
|
||||
|
||||
export type KeyedRow<T> = { key: string; value: T }
|
||||
|
||||
const toRows = <T,>(values: readonly T[]): KeyedRow<T>[] =>
|
||||
const toRows = <T>(values: readonly T[]): KeyedRow<T>[] =>
|
||||
values.map((value) => ({ key: crypto.randomUUID(), value }))
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,7 @@ import { toast } from '@/shared/ui/toast-store'
|
||||
export function useApiError() {
|
||||
const { t } = useTranslation()
|
||||
return useCallback(
|
||||
(error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
|
||||
(error: unknown) => toast.error(error instanceof HttpError ? error.detail : t('common.error')),
|
||||
[t],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
ToastContext,
|
||||
registerToastPush,
|
||||
type ToastItem,
|
||||
type ToastVariant,
|
||||
} from './toast-store'
|
||||
import { ToastContext, registerToastPush, type ToastItem, type ToastVariant } from './toast-store'
|
||||
|
||||
let nextId = 1
|
||||
|
||||
|
||||
@@ -31,7 +31,9 @@ type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return <Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />
|
||||
)
|
||||
},
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
@@ -1,33 +1,46 @@
|
||||
import { type HTMLAttributes, forwardRef } from 'react'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('crt-panel rounded-md', className)} {...props} />
|
||||
))
|
||||
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('crt-panel rounded-md', className)} {...props} />
|
||||
),
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col gap-1.5 p-6', className)} {...props} />
|
||||
))
|
||||
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col gap-1.5 p-6', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
export const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingElement>>(
|
||||
// children разворачиваем явно: заголовок без видимого содержимого — это дыра для скринридера,
|
||||
// и статический анализ такое ловит только тогда, когда содержимое видно в разметке.
|
||||
({ className, children, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn('crt-glow text-xl font-semibold tracking-tight', className)} {...props}>
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('crt-glow text-xl font-semibold tracking-tight', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
export const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />,
|
||||
)
|
||||
export const CardDescription = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
))
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
))
|
||||
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
@@ -50,7 +50,11 @@ export const DialogTitle = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Title>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title ref={ref} className={cn('crt-glow text-lg font-semibold', className)} {...props} />
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('crt-glow text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
@@ -58,7 +62,11 @@ export const DialogDescription = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Description>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
|
||||
@@ -23,10 +23,7 @@ export function SortHeader({
|
||||
return (
|
||||
// aria-sort — атрибут заголовка столбца, а не кнопки внутри него: у роли button его нет,
|
||||
// и скринридер там его просто не прочтёт.
|
||||
<th
|
||||
className={cn('px-4 py-2 font-medium', className)}
|
||||
aria-sort={active ? direction : 'none'}
|
||||
>
|
||||
<th className={cn('px-4 py-2 font-medium', className)} aria-sort={active ? direction : 'none'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(sortKey)}
|
||||
|
||||
@@ -10,7 +10,13 @@ export function Toaster() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[10000] flex flex-col gap-2">
|
||||
{toasts.map((t) => (
|
||||
<ToastItem key={t.id} id={t.id} message={t.message} variant={t.variant} onDismiss={dismiss} />
|
||||
<ToastItem
|
||||
key={t.id}
|
||||
id={t.id}
|
||||
message={t.message}
|
||||
variant={t.variant}
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user