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:
Leonid Pershin
2026-07-27 01:38:12 +03:00
co-authored by Claude Opus 5
parent 0442056367
commit 0606ea3e6e
58 changed files with 5158 additions and 4903 deletions
@@ -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'
+375 -371
View File
@@ -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>
</>
)
}
+4 -1
View File
@@ -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>
+6 -1
View File
@@ -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}>
+6 -1
View File
@@ -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 -3
View File
@@ -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)
+332 -339
View File
@@ -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 }
}
+285 -286
View File
@@ -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],
)}
/>
)
}
+22 -5
View File
@@ -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()
+3 -1
View File
@@ -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>
+41 -10
View File
@@ -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>
+4 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -2
View File
@@ -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 -6
View File
@@ -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
+3 -1
View File
@@ -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'
+26 -13
View File
@@ -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'
+10 -2
View File
@@ -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
+1 -4
View File
@@ -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)}
+7 -1
View File
@@ -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>
)