diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a8b30b9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +# Базовые правила для редакторов — чтобы IDE не ставила свои отступы и концы строк до того, как +# файл дойдёт до форматтера. Итоговый стиль всё равно задают csharpier (.config/dotnet-tools.json) +# и Prettier (frontend/.prettierrc.json); значения здесь совпадают с их настройками намеренно — +# оба читают .editorconfig, и расхождение развернуло бы форматирование в другую сторону. + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 +max_line_length = 100 + +[*.cs] +indent_size = 4 + +[*.md] +# В markdown два пробела в конце строки — это перенос, обрезать их нельзя. +trim_trailing_whitespace = false diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..2d9f755 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,12 @@ +# Коммиты, которые правили только оформление: перевод концов строк в LF и первые прогоны +# csharpier/Prettier. Без этого списка `git blame` на половине файлов показывал бы их вместо +# автора реальной правки. +# +# Включить локально (в .git/config, а не в репозитории — потому команду надо выполнить у себя): +# git config blame.ignoreRevsFile .git-blame-ignore-revs +# +# Добавлять сюда только чисто механические коммиты, полным SHA, по одному на строку: +# git rev-parse HEAD >> .git-blame-ignore-revs + +# Перевод дерева в LF. +0442056367cb6cd3b38964dacf75f44f123e11ac diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 0a00b90..802dae6 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -21,6 +21,14 @@ jobs: - name: Restore working-directory: backend run: dotnet restore TeleWave.slnx + # Форматирование проверяем до сборки: падает за секунду и сразу показывает, что чинить. + # Версия csharpier прибита в .config/dotnet-tools.json — глобальная копия разработчика на CI + # не влияет, иначе её минорка переформатировала бы репозиторий в свой стиль. + - name: Format check + working-directory: backend + run: | + dotnet tool restore + dotnet csharpier check . # Строгая сборка: TreatWarningsAsErrors=true из Directory.Build.props не отключаем. - name: Build (Release) working-directory: backend @@ -38,6 +46,9 @@ jobs: - name: Install working-directory: frontend run: pnpm install --frozen-lockfile + - name: Format check + working-directory: frontend + run: pnpm format:check - name: Lint working-directory: frontend run: pnpm lint diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..146e30d --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,5 @@ +dist +pnpm-lock.yaml + +# Генерируется плагином TanStack Router на dev/build — форматировать бесполезно, перезапишется. +src/routeTree.gen.ts diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 0000000..75a894a --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "semi": false, + "singleQuote": true, + "printWidth": 100 +} diff --git a/frontend/package.json b/frontend/package.json index 3189c70..a7befbd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "oxlint", + "format": "prettier --write .", + "format:check": "prettier --check .", "typecheck": "tsc -b", "preview": "vite preview" }, @@ -39,7 +41,8 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", - "oxlint": "^1.71.0", + "oxlint": "1.75.0", + "prettier": "3.9.6", "tailwindcss": "^4.3.2", "typescript": "~6.0.2", "vite": "^8.1.1" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 81f6d10..868550e 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -85,8 +85,11 @@ importers: specifier: ^6.0.3 version: 6.0.4(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) oxlint: - specifier: ^1.71.0 + specifier: 1.75.0 version: 1.75.0 + prettier: + specifier: 3.9.6 + version: 3.9.6 tailwindcss: specifier: ^4.3.2 version: 4.3.3 diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index 39789d9..a6c8116 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -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' diff --git a/frontend/src/features/admin/channels/api.ts b/frontend/src/features/admin/channels/api.ts index dec17fe..80e27a5 100644 --- a/frontend/src/features/admin/channels/api.ts +++ b/frontend/src/features/admin/channels/api.ts @@ -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('/admin/channels') -} - -export function getChannel(id: string) { - return apiRequest(`/admin/channels/${id}`) -} - -export function createChannel(body: { name: string; slug: string }) { - return apiRequest('/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(`/admin/channels/${id}/settings`, { method: 'PUT', body }) -} - -/** Оверлеи и аналоговый фильтр канала — как он выглядит у зрителя. */ -export function updateViewerSettings(id: string, body: ViewerSettings) { - return apiRequest(`/admin/channels/${id}/viewer`, { method: 'PUT', body }) -} - -/** Номер канала и его время: смещение от UTC и начало вещательных суток. */ -export function updateChannelTime( - id: string, - body: { number: number | null; utcOffsetMinutes: number; dayStartTime: string }, -) { - return apiRequest(`/admin/channels/${id}/time`, { method: 'PUT', body }) -} - -// ── Сетка канала ────────────────────────────────────────────────────────── - -export function getChannelTemplate(channelId: string) { - return apiRequest(`/admin/channels/${channelId}/template`) -} - -/** Заводит каналу сетку, если её нет: у каналов из старой ротации шаблона может не быть. */ -export function createChannelTemplate(channelId: string) { - return apiRequest(`/admin/channels/${channelId}/template`, { method: 'POST' }) -} - -/** Применяет правила к эфиру: пересобирает будущий хвост. Правка слотов эфир не двигает. */ -export function applyChannelTemplate(channelId: string) { - return apiRequest(`/admin/channels/${channelId}/template/apply`, { - method: 'POST', - }) -} - -/** Проверки сетки по правилам — считаются по шаблону, без прогона генератора. */ -export function getTemplateIssues(channelId: string) { - return apiRequest(`/admin/channels/${channelId}/template/issues`) -} - -/** Что изменится в эфире, если применить сейчас. Прогон сухой — лента не трогается. */ -export function getApplyDiff(channelId: string) { - return apiRequest(`/admin/channels/${channelId}/template/diff`) -} - -/** Копия сетки на другой канал: слои, слоты, стыки и правила. Группы общие и не копируются. */ -export function copyTemplateTo(channelId: string, targetChannelId: string) { - return apiRequest( - `/admin/channels/${channelId}/template/copy-to/${targetChannelId}`, - { method: 'POST' }, - ) -} - -/** Цепочка происхождения записи, записанная в момент генерации. */ -export function getEntryTrace(entryId: string) { - return apiRequest(`/admin/channels/entries/${entryId}/trace`) -} - -/** Сухой прогон по текущим правилам: ничего не пишет и не двигает курсоры слотов. */ -export function previewTemplate(channelId: string, days: number) { - const query = new URLSearchParams({ days: String(days) }) - return apiRequest( - `/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(`/admin/templates/${templateId}`, { method: 'PUT', body }) -} - -export function createLayer(templateId: string, body: { name: string; priority: number }) { - return apiRequest(`/admin/templates/${templateId}/layers`, { - method: 'POST', - body, - }) -} - -export function updateLayer( - layerId: string, - body: { - name: string - priority: number - applicability: LayerApplicability | null - isEnabled: boolean - }, -) { - return apiRequest(`/admin/layers/${layerId}`, { method: 'PUT', body }) -} - -export function deleteLayer(layerId: string) { - return apiRequest(`/admin/layers/${layerId}`, { method: 'DELETE' }) -} - -/** Тело слота: то же для создания и правки (см. SlotInput на сервере). */ -export type SlotBody = Omit - -/** Слот из ответа сервера → тело запроса: отбрасываем то, что сервер проставляет сам. */ -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(`/admin/layers/${layerId}/slots`, { method: 'POST', body }) -} - -export function updateSlot(slotId: string, body: SlotBody) { - return apiRequest(`/admin/slots/${slotId}`, { method: 'PUT', body }) -} - -export function deleteSlot(slotId: string) { - return apiRequest(`/admin/slots/${slotId}`, { method: 'DELETE' }) -} - -// ── Стыки канала ────────────────────────────────────────────────────────── - -export function listJunctions(channelId: string) { - return apiRequest(`/admin/channels/${channelId}/junctions`) -} - -export function createJunction(channelId: string, name: string) { - return apiRequest(`/admin/channels/${channelId}/junctions`, { - method: 'POST', - body: { name }, - }) -} - -export function renameJunction(junctionId: string, name: string) { - return apiRequest(`/admin/junctions/${junctionId}`, { method: 'PUT', body: { name } }) -} - -export function deleteJunction(junctionId: string) { - return apiRequest(`/admin/junctions/${junctionId}`, { method: 'DELETE' }) -} - -export function addJunctionElement(junctionId: string, kind: JunctionElementKind) { - return apiRequest(`/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(`/admin/junctions/${junctionId}/elements/${elementId}`, { - method: 'PUT', - body, - }) -} - -export function removeJunctionElement(junctionId: string, elementId: string) { - return apiRequest(`/admin/junctions/${junctionId}/elements/${elementId}`, { - method: 'DELETE', - }) -} - -/** Порядок врезок: не упомянутые остаются после перечисленных. */ -export function reorderJunction(junctionId: string, elementIdsInOrder: string[]) { - return apiRequest(`/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(`/admin/channels/${id}/bumper/templates`, { - method: 'POST', - body: { name }, - }) -} - -export function updateBumperTemplate(id: string, templateId: string, body: BumperTemplateStyleBody) { - return apiRequest(`/admin/channels/${id}/bumper/templates/${templateId}`, { - method: 'PUT', - body, - }) -} - -export function removeBumperTemplate(id: string, templateId: string) { - return apiRequest(`/admin/channels/${id}/bumper/templates/${templateId}`, { - method: 'DELETE', - }) -} - -/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */ -function uploadBumperTemplateFile( - id: string, - templateId: string, - kind: 'audio' | 'background', - file: File, -): Promise { - 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( - `/admin/channels/${id}/bumper/templates/${templateId}/variants`, - { method: 'POST', body: { name } }, - ) -} - -export function updateBumperVariant( - id: string, - templateId: string, - variantId: string, - body: BumperVariantBody, -) { - return apiRequest( - `/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`, - { method: 'PUT', body }, - ) -} - -export function removeBumperVariant(id: string, templateId: string, variantId: string) { - return apiRequest( - `/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`, - { method: 'DELETE' }, - ) -} - -/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */ -export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) { - return apiRequest(`/admin/channels/${id}/bumper/templates/${templateId}/background`, { - method: 'PUT', - body: { imageId }, - }) -} - -export function clearBumperTemplateAudio(id: string, templateId: string) { - return apiRequest(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, { - method: 'DELETE', - }) -} - -export function clearBumperTemplateBackground(id: string, templateId: string) { - return apiRequest(`/admin/channels/${id}/bumper/templates/${templateId}/background`, { - method: 'DELETE', - }) -} - -/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */ -export function renderBumperPreviews(id: string, templateId: string) { - return apiRequest(`/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(`/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('/admin/channels') +} + +export function getChannel(id: string) { + return apiRequest(`/admin/channels/${id}`) +} + +export function createChannel(body: { name: string; slug: string }) { + return apiRequest('/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(`/admin/channels/${id}/settings`, { method: 'PUT', body }) +} + +/** Оверлеи и аналоговый фильтр канала — как он выглядит у зрителя. */ +export function updateViewerSettings(id: string, body: ViewerSettings) { + return apiRequest(`/admin/channels/${id}/viewer`, { method: 'PUT', body }) +} + +/** Номер канала и его время: смещение от UTC и начало вещательных суток. */ +export function updateChannelTime( + id: string, + body: { number: number | null; utcOffsetMinutes: number; dayStartTime: string }, +) { + return apiRequest(`/admin/channels/${id}/time`, { method: 'PUT', body }) +} + +// ── Сетка канала ────────────────────────────────────────────────────────── + +export function getChannelTemplate(channelId: string) { + return apiRequest(`/admin/channels/${channelId}/template`) +} + +/** Заводит каналу сетку, если её нет: у каналов из старой ротации шаблона может не быть. */ +export function createChannelTemplate(channelId: string) { + return apiRequest(`/admin/channels/${channelId}/template`, { method: 'POST' }) +} + +/** Применяет правила к эфиру: пересобирает будущий хвост. Правка слотов эфир не двигает. */ +export function applyChannelTemplate(channelId: string) { + return apiRequest(`/admin/channels/${channelId}/template/apply`, { + method: 'POST', + }) +} + +/** Проверки сетки по правилам — считаются по шаблону, без прогона генератора. */ +export function getTemplateIssues(channelId: string) { + return apiRequest(`/admin/channels/${channelId}/template/issues`) +} + +/** Что изменится в эфире, если применить сейчас. Прогон сухой — лента не трогается. */ +export function getApplyDiff(channelId: string) { + return apiRequest(`/admin/channels/${channelId}/template/diff`) +} + +/** Копия сетки на другой канал: слои, слоты, стыки и правила. Группы общие и не копируются. */ +export function copyTemplateTo(channelId: string, targetChannelId: string) { + return apiRequest( + `/admin/channels/${channelId}/template/copy-to/${targetChannelId}`, + { method: 'POST' }, + ) +} + +/** Цепочка происхождения записи, записанная в момент генерации. */ +export function getEntryTrace(entryId: string) { + return apiRequest(`/admin/channels/entries/${entryId}/trace`) +} + +/** Сухой прогон по текущим правилам: ничего не пишет и не двигает курсоры слотов. */ +export function previewTemplate(channelId: string, days: number) { + const query = new URLSearchParams({ days: String(days) }) + return apiRequest( + `/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(`/admin/templates/${templateId}`, { method: 'PUT', body }) +} + +export function createLayer(templateId: string, body: { name: string; priority: number }) { + return apiRequest(`/admin/templates/${templateId}/layers`, { + method: 'POST', + body, + }) +} + +export function updateLayer( + layerId: string, + body: { + name: string + priority: number + applicability: LayerApplicability | null + isEnabled: boolean + }, +) { + return apiRequest(`/admin/layers/${layerId}`, { method: 'PUT', body }) +} + +export function deleteLayer(layerId: string) { + return apiRequest(`/admin/layers/${layerId}`, { method: 'DELETE' }) +} + +/** Тело слота: то же для создания и правки (см. SlotInput на сервере). */ +export type SlotBody = Omit + +/** Слот из ответа сервера → тело запроса: отбрасываем то, что сервер проставляет сам. */ +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(`/admin/layers/${layerId}/slots`, { method: 'POST', body }) +} + +export function updateSlot(slotId: string, body: SlotBody) { + return apiRequest(`/admin/slots/${slotId}`, { method: 'PUT', body }) +} + +export function deleteSlot(slotId: string) { + return apiRequest(`/admin/slots/${slotId}`, { method: 'DELETE' }) +} + +// ── Стыки канала ────────────────────────────────────────────────────────── + +export function listJunctions(channelId: string) { + return apiRequest(`/admin/channels/${channelId}/junctions`) +} + +export function createJunction(channelId: string, name: string) { + return apiRequest(`/admin/channels/${channelId}/junctions`, { + method: 'POST', + body: { name }, + }) +} + +export function renameJunction(junctionId: string, name: string) { + return apiRequest(`/admin/junctions/${junctionId}`, { method: 'PUT', body: { name } }) +} + +export function deleteJunction(junctionId: string) { + return apiRequest(`/admin/junctions/${junctionId}`, { method: 'DELETE' }) +} + +export function addJunctionElement(junctionId: string, kind: JunctionElementKind) { + return apiRequest(`/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(`/admin/junctions/${junctionId}/elements/${elementId}`, { + method: 'PUT', + body, + }) +} + +export function removeJunctionElement(junctionId: string, elementId: string) { + return apiRequest(`/admin/junctions/${junctionId}/elements/${elementId}`, { + method: 'DELETE', + }) +} + +/** Порядок врезок: не упомянутые остаются после перечисленных. */ +export function reorderJunction(junctionId: string, elementIdsInOrder: string[]) { + return apiRequest(`/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(`/admin/channels/${id}/bumper/templates`, { + method: 'POST', + body: { name }, + }) +} + +export function updateBumperTemplate( + id: string, + templateId: string, + body: BumperTemplateStyleBody, +) { + return apiRequest(`/admin/channels/${id}/bumper/templates/${templateId}`, { + method: 'PUT', + body, + }) +} + +export function removeBumperTemplate(id: string, templateId: string) { + return apiRequest(`/admin/channels/${id}/bumper/templates/${templateId}`, { + method: 'DELETE', + }) +} + +/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */ +function uploadBumperTemplateFile( + id: string, + templateId: string, + kind: 'audio' | 'background', + file: File, +): Promise { + 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( + `/admin/channels/${id}/bumper/templates/${templateId}/variants`, + { method: 'POST', body: { name } }, + ) +} + +export function updateBumperVariant( + id: string, + templateId: string, + variantId: string, + body: BumperVariantBody, +) { + return apiRequest( + `/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`, + { method: 'PUT', body }, + ) +} + +export function removeBumperVariant(id: string, templateId: string, variantId: string) { + return apiRequest( + `/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`, + { method: 'DELETE' }, + ) +} + +/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */ +export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) { + return apiRequest(`/admin/channels/${id}/bumper/templates/${templateId}/background`, { + method: 'PUT', + body: { imageId }, + }) +} + +export function clearBumperTemplateAudio(id: string, templateId: string) { + return apiRequest(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, { + method: 'DELETE', + }) +} + +export function clearBumperTemplateBackground(id: string, templateId: string) { + return apiRequest(`/admin/channels/${id}/bumper/templates/${templateId}/background`, { + method: 'DELETE', + }) +} + +/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */ +export function renderBumperPreviews(id: string, templateId: string) { + return apiRequest(`/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(`/admin/channels/${id}/schedule?${query.toString()}`) +} diff --git a/frontend/src/features/admin/channels/components/BumperBackgroundField.tsx b/frontend/src/features/admin/channels/components/BumperBackgroundField.tsx index a343d77..6ea8317 100644 --- a/frontend/src/features/admin/channels/components/BumperBackgroundField.tsx +++ b/frontend/src/features/admin/channels/components/BumperBackgroundField.tsx @@ -44,7 +44,9 @@ export function BumperBackgroundField({ : `· ${t('admin.channels.bumperFileDefault')}`} - {t('admin.channels.bumperBackgroundHint')} + + {t('admin.channels.bumperBackgroundHint')} +
{backgroundImageId && ( {backgroundImageId && ( - )} diff --git a/frontend/src/features/admin/channels/components/BumperCard.tsx b/frontend/src/features/admin/channels/components/BumperCard.tsx index 4b1066a..b5d1b70 100644 --- a/frontend/src/features/admin/channels/components/BumperCard.tsx +++ b/frontend/src/features/admin/channels/components/BumperCard.tsx @@ -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(channel.bumper) - - const setField = (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 ( - - - - {/* Общие настройки. Как часто ставить заставку — не здесь: это условие элемента стыка. */} -
-
- - -
-
- - -
-
-

{t('admin.channels.bumperConditionsHint')}

-
- -
- - {/* Блоки заставок */} -
-

{t('admin.channels.bumperTemplates')}

-

{t('admin.channels.bumperTemplatesHint')}

-
-
- {templates.map((template) => ( - - ))} -
-
- -
-
- ) -} +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(channel.bumper) + + const setField = (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 ( + + + + {/* Общие настройки. Как часто ставить заставку — не здесь: это условие элемента стыка. */} +
+
+ + +
+
+ + +
+
+

{t('admin.channels.bumperConditionsHint')}

+
+ +
+ + {/* Блоки заставок */} +
+

{t('admin.channels.bumperTemplates')}

+

{t('admin.channels.bumperTemplatesHint')}

+
+
+ {templates.map((template) => ( + + ))} +
+
+ +
+
+ ) +} diff --git a/frontend/src/features/admin/channels/components/BumperPreviewPlayer.tsx b/frontend/src/features/admin/channels/components/BumperPreviewPlayer.tsx index a78e72a..6bee567 100644 --- a/frontend/src/features/admin/channels/components/BumperPreviewPlayer.tsx +++ b/frontend/src/features/admin/channels/components/BumperPreviewPlayer.tsx @@ -33,12 +33,19 @@ export function BumperPreviewPlayer({ return ( <>
- - {t('admin.channels.bumperPreviewHint')} + + {t('admin.channels.bumperPreviewHint')} +
{ready && (
@@ -47,7 +54,9 @@ export function BumperPreviewPlayer({ .map((v) => (
{v.name} - +
))}
diff --git a/frontend/src/features/admin/channels/components/BumperTemplateEditor.tsx b/frontend/src/features/admin/channels/components/BumperTemplateEditor.tsx index ab08b52..761d7ac 100644 --- a/frontend/src/features/admin/channels/components/BumperTemplateEditor.tsx +++ b/frontend/src/features/admin/channels/components/BumperTemplateEditor.tsx @@ -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({ {/* Подблоки (текст-варианты) */}

{t('admin.channels.bumperVariants')}

-

{t('admin.channels.bumperVariantsHint')}

+

+ {t('admin.channels.bumperVariantsHint')} +

{[...template.variants] .sort((a, b) => a.position - b.position) .map((variant) => ( diff --git a/frontend/src/features/admin/channels/components/BumperVariantEditor.tsx b/frontend/src/features/admin/channels/components/BumperVariantEditor.tsx index 322da6d..9e9dbb9 100644 --- a/frontend/src/features/admin/channels/components/BumperVariantEditor.tsx +++ b/frontend/src/features/admin/channels/components/BumperVariantEditor.tsx @@ -142,11 +142,19 @@ export function BumperVariantEditor({ <>
- set('line1', e.target.value)} /> + set('line1', e.target.value)} + />
- set('line2', e.target.value)} /> + set('line2', e.target.value)} + />
)} @@ -154,7 +162,12 @@ export function BumperVariantEditor({
{canRemove && ( - )} diff --git a/frontend/src/features/admin/channels/components/EntryTraceDialog.tsx b/frontend/src/features/admin/channels/components/EntryTraceDialog.tsx index 831e415..48f0dd4 100644 --- a/frontend/src/features/admin/channels/components/EntryTraceDialog.tsx +++ b/frontend/src/features/admin/channels/components/EntryTraceDialog.tsx @@ -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 ( - !open && onClose()}> - - - - {data - ? `${data.showName ?? '—'} · ${formatChannelTime(data.startsAtUtc, utcOffsetMinutes)}` - : t('common.loading')} - - - - {data && ( -
- {layerSummary(data, t)} - {slotSummary(data, t)} - {groupSummary(data)} - {data.collectionName} - {strategySummary(data, t)} - {data.junctionName} -
- )} -
-
- ) -} - -type Translate = ReturnType['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 ( - <> -
{label}
-
{children || '—'}
- - ) -} +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 ( + !open && onClose()}> + + + + {data + ? `${data.showName ?? '—'} · ${formatChannelTime(data.startsAtUtc, utcOffsetMinutes)}` + : t('common.loading')} + + + + {data && ( +
+ {layerSummary(data, t)} + {slotSummary(data, t)} + {groupSummary(data)} + {data.collectionName} + {strategySummary(data, t)} + {data.junctionName} +
+ )} +
+
+ ) +} + +type Translate = ReturnType['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 ( + <> +
{label}
+
{children || '—'}
+ + ) +} diff --git a/frontend/src/features/admin/channels/components/JunctionElementDialog.tsx b/frontend/src/features/admin/channels/components/JunctionElementDialog.tsx index 816de1e..9a943c0 100644 --- a/frontend/src/features/admin/channels/components/JunctionElementDialog.tsx +++ b/frontend/src/features/admin/channels/components/JunctionElementDialog.tsx @@ -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' diff --git a/frontend/src/features/admin/channels/components/JunctionsCard.tsx b/frontend/src/features/admin/channels/components/JunctionsCard.tsx index 9765832..67ecda3 100644 --- a/frontend/src/features/admin/channels/components/JunctionsCard.tsx +++ b/frontend/src/features/admin/channels/components/JunctionsCard.tsx @@ -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 = { - Ad: 'bg-amber-500/70', - Promo: 'bg-sky-500/70', - Bumper: 'bg-violet-500/70', - Filler: 'bg-muted-foreground/40', -} - -type Translate = ReturnType['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 ( - -
-

{t('admin.channels.junctionsHint')}

- - {template && ( -
- - -
- )} - - {(junctions ?? []).map((junction) => ( - - ))} - -
- setNewName(e.target.value)} - /> - -
-
-
- ) -} - -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(null) - const [dragged, setDragged] = useState(null) - const [editing, setEditing] = useState(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 ( -
-
- setName(e.target.value)} - onBlur={() => - name !== null && name.trim() && name !== junction.name - ? renameMutation.mutate(name.trim()) - : setName(null) - } - /> - - - {exact ? '' : '≈ '} - {formatClock(total)} - - -
- - {/* Цепочка: что играет между концом одной программы и началом следующей. */} -
- - {t('admin.channels.junctionFrom')} - - {elements.length === 0 && ( - <> - - {t('admin.channels.junctionEmpty')} - - )} - {elements.map((element) => ( - - - - - ))} - - - {t('admin.channels.junctionTo')} - -
- - {/* Линейка: доля каждой врезки в стыке. Пустые (без группы) в неё не попадают. */} - {total > 0 && ( -
- {elements.map((element, index) => ( -
- ))} -
- )} - - {editing && ( - setEditing(null)} - onChanged={onChanged} - onError={onError} - /> - )} -
- ) -} +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 = { + Ad: 'bg-amber-500/70', + Promo: 'bg-sky-500/70', + Bumper: 'bg-violet-500/70', + Filler: 'bg-muted-foreground/40', +} + +type Translate = ReturnType['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 ( + +
+

{t('admin.channels.junctionsHint')}

+ + {template && ( +
+ + +
+ )} + + {(junctions ?? []).map((junction) => ( + + ))} + +
+ setNewName(e.target.value)} + /> + +
+
+
+ ) +} + +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(null) + const [dragged, setDragged] = useState(null) + const [editing, setEditing] = useState(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 ( +
+
+ setName(e.target.value)} + onBlur={() => + name !== null && name.trim() && name !== junction.name + ? renameMutation.mutate(name.trim()) + : setName(null) + } + /> + + + {exact ? '' : '≈ '} + {formatClock(total)} + + +
+ + {/* Цепочка: что играет между концом одной программы и началом следующей. */} +
+ + {t('admin.channels.junctionFrom')} + + {elements.length === 0 && ( + <> + + {t('admin.channels.junctionEmpty')} + + )} + {elements.map((element) => ( + + + + + ))} + + + {t('admin.channels.junctionTo')} + +
+ + {/* Линейка: доля каждой врезки в стыке. Пустые (без группы) в неё не попадают. */} + {total > 0 && ( +
+ {elements.map((element, index) => ( +
+ ))} +
+ )} + + {editing && ( + setEditing(null)} + onChanged={onChanged} + onError={onError} + /> + )} +
+ ) +} diff --git a/frontend/src/features/admin/channels/components/LayerApplicabilityDialog.tsx b/frontend/src/features/admin/channels/components/LayerApplicabilityDialog.tsx index e2a73b2..25a916c 100644 --- a/frontend/src/features/admin/channels/components/LayerApplicabilityDialog.tsx +++ b/frontend/src/features/admin/channels/components/LayerApplicabilityDialog.tsx @@ -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 }))} /> - 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(() => - 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) => - setWindows((current) => - current.map((row) => - row.key === key ? { ...row, window: { ...row.window, ...part } } : row, - ), - ) - - return ( - -
-

{t('admin.channels.rulesHint')}

- -
-
-

- {t('admin.channels.audienceWindows')} -

- -
- - {windows.length === 0 ? ( -

{t('admin.channels.noAudienceWindows')}

- ) : ( -
    - {windows.map(({ key, window }) => ( -
  • -
    - - patchWindow(key, { from: `${e.target.value}:00` })} - /> -
    -
    - - patchWindow(key, { to: `${e.target.value}:00` })} - /> -
    -
    - - -
    - -
  • - ))} -
- )} -

{t('admin.channels.audienceWindowsHint')}

-
- -
- - {limitOn && ( -
-
- - setWindowDays(Number(e.target.value))} - /> -
-
- - setMax(Number(e.target.value))} - /> -
-
- )} -

{t('admin.channels.repeatLimitHint')}

-
- -
-

- {t('admin.channels.postChecks')} -

-
-
- - setBreakCap(Number(e.target.value))} - /> -
-
- - setGenreCap(Number(e.target.value))} - /> -
-
- - setFallbackCap(Number(e.target.value))} - /> -
-
-

{t('admin.channels.postChecksHint')}

-
- -
- -
-
-
- ) -} +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(() => + 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) => + setWindows((current) => + current.map((row) => + row.key === key ? { ...row, window: { ...row.window, ...part } } : row, + ), + ) + + return ( + +
+

{t('admin.channels.rulesHint')}

+ +
+
+

+ {t('admin.channels.audienceWindows')} +

+ +
+ + {windows.length === 0 ? ( +

{t('admin.channels.noAudienceWindows')}

+ ) : ( +
    + {windows.map(({ key, window }) => ( +
  • +
    + + patchWindow(key, { from: `${e.target.value}:00` })} + /> +
    +
    + + patchWindow(key, { to: `${e.target.value}:00` })} + /> +
    +
    + + +
    + +
  • + ))} +
+ )} +

{t('admin.channels.audienceWindowsHint')}

+
+ +
+ + {limitOn && ( +
+
+ + setWindowDays(Number(e.target.value))} + /> +
+
+ + setMax(Number(e.target.value))} + /> +
+
+ )} +

{t('admin.channels.repeatLimitHint')}

+
+ +
+

+ {t('admin.channels.postChecks')} +

+
+
+ + setBreakCap(Number(e.target.value))} + /> +
+
+ + setGenreCap(Number(e.target.value))} + /> +
+
+ + setFallbackCap(Number(e.target.value))} + /> +
+
+

{t('admin.channels.postChecksHint')}

+
+ +
+ +
+
+
+ ) +} diff --git a/frontend/src/features/admin/channels/components/ScheduleGrid.tsx b/frontend/src/features/admin/channels/components/ScheduleGrid.tsx index c0e9288..df6b021 100644 --- a/frontend/src/features/admin/channels/components/ScheduleGrid.tsx +++ b/frontend/src/features/admin/channels/components/ScheduleGrid.tsx @@ -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 = { - 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(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, 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 ( -
-
-
-
{dayStart}
- {WEEKDAYS.map((weekday) => ( -
- {t(`admin.channels.weekdays.${weekday}`)} - -
- ))} -
- -
-
- {hours.map((hour) => ( -
- {hour.toString().padStart(2, '0')}:00 -
- ))} -
- - {WEEKDAYS.map((weekday) => ( -
dragged && e.preventDefault()} - onDrop={(e) => drop(e, weekday)} - > - {hours.map((hour, index) => ( - - ))} - - {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 к роли не привязан. - - ) - })} -
- ))} -
-
-
- ) -} - -/** - * Панель слоёв: видимость, приоритет (перетаскиванием), применимость и выбор редактируемого. - * Выше в списке — приоритетнее; фоновый слой всегда внизу и не двигается. - */ -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(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 ( -
    - {ordered.map((layer) => { - const inactiveToday = day !== null && !coversDate(layer.applicability, day) - return ( -
  • 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 ? ( - - ) : ( - - )} - onToggle(layer)} - /> - - - {layer.isBackground ? t('admin.channels.background') : layer.slots.length} - - {!layer.isBackground && ( - <> - - - - )} -
  • - ) - })} -
- ) -} - -/** «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 = { + 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(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, 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 ( +
+
+
+
{dayStart}
+ {WEEKDAYS.map((weekday) => ( +
+ {t(`admin.channels.weekdays.${weekday}`)} + +
+ ))} +
+ +
+
+ {hours.map((hour) => ( +
+ {hour.toString().padStart(2, '0')}:00 +
+ ))} +
+ + {WEEKDAYS.map((weekday) => ( +
dragged && e.preventDefault()} + onDrop={(e) => drop(e, weekday)} + > + {hours.map((hour, index) => ( + + ))} + + {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 к роли не привязан. + + ) + })} +
+ ))} +
+
+
+ ) +} + +/** + * Панель слоёв: видимость, приоритет (перетаскиванием), применимость и выбор редактируемого. + * Выше в списке — приоритетнее; фоновый слой всегда внизу и не двигается. + */ +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(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 ( +
    + {ordered.map((layer) => { + const inactiveToday = day !== null && !coversDate(layer.applicability, day) + return ( +
  • 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 ? ( + + ) : ( + + )} + onToggle(layer)} + /> + + + {layer.isBackground ? t('admin.channels.background') : layer.slots.length} + + {!layer.isBackground && ( + <> + + + + )} +
  • + ) + })} +
+ ) +} + +/** «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) +} diff --git a/frontend/src/features/admin/channels/components/SettingsCard.tsx b/frontend/src/features/admin/channels/components/SettingsCard.tsx index 417b9e5..fa886de 100644 --- a/frontend/src/features/admin/channels/components/SettingsCard.tsx +++ b/frontend/src/features/admin/channels/components/SettingsCard.tsx @@ -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 ( - -
- - setName(e.target.value)} /> -
-
- - setNumber(e.target.value)} - /> -
-
- - setOffsetHours(Number(e.target.value))} - /> -

{t('admin.channels.utcOffsetHint')}

-
-
- - setDayStart(e.target.value)} /> -

{t('admin.channels.dayStartHint')}

-
-
- - -
- -
- -
-
- ) -} +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 ( + +
+ + setName(e.target.value)} /> +
+
+ + setNumber(e.target.value)} + /> +
+
+ + setOffsetHours(Number(e.target.value))} + /> +

{t('admin.channels.utcOffsetHint')}

+
+
+ + setDayStart(e.target.value)} /> +

{t('admin.channels.dayStartHint')}

+
+
+ + +
+ +
+ +
+
+ ) +} diff --git a/frontend/src/features/admin/channels/components/TemplatePreview.tsx b/frontend/src/features/admin/channels/components/TemplatePreview.tsx index e854142..8686cc6 100644 --- a/frontend/src/features/admin/channels/components/TemplatePreview.tsx +++ b/frontend/src/features/admin/channels/components/TemplatePreview.tsx @@ -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 = { - 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(['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 ( -
-
- - {open && ( - <> - - - {isFetching ? t('common.loading') : t('admin.channels.previewHint')} - - - )} -
- - {open && data && ( -
-
- {(['programme', 'tape', 'problems'] as const).map((value) => ( - - ))} -
- - {tab === 'programme' && } - {tab === 'tape' && } - {tab === 'problems' && } -
- )} -
- ) -} - -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

{t('admin.channels.noSchedule')}

- - return ( -
    - {items.map((item, index) => ( -
  • - - {formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)} - - - {item.title ?? t(`admin.channels.previewKinds.${item.kind}`)} - - {item.slotTitle && ( - {item.slotTitle} - )} -
  • - ))} -
- ) -} - -/** Час вещания → сколько в нём минут врезок. По ним же строится гистограмма нагрузки. */ -function loadByHour(preview: SchedulePreviewDto): { hour: Date; minutes: number }[] { - const buckets = new Map() - 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

{t('admin.channels.noSchedule')}

- - return ( -
- {load.length > 0 && ( -
- - {t('admin.channels.previewLoad', { peak: Math.round(peak) })} - -
- {load.map((bucket) => ( -
- ))} -
-
- )} - -
    - {preview.items.map((item, index) => ( - - ))} -
-
- ) -} - -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 ( -
  • - - {formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)} - - - - {t(`admin.channels.previewKinds.${item.kind}`)} - - {item.title ?? ''} -
  • - ) -} - -/** Предупреждения, сгруппированные по виду: десять однотипных строк читаются как одна проблема. */ -function Problems({ preview }: Readonly<{ preview: SchedulePreviewDto }>) { - const { t } = useTranslation() - // Ключ строки — «вид + порядковый номер внутри вида»: у предупреждения нет своего id, а текст - // повторяется (одна и та же причина на разных слотах), и позиция здесь — единственное, что его - // отличает. Считаем ключ при группировке, чтобы список не пересобирался на каждый рендер. - const grouped = useMemo(() => { - const map = new Map() - 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 ( -
    - {grouped.length === 0 ? ( -

    {t('admin.channels.noProblems')}

    - ) : ( -
      - {grouped.map(([kind, details]) => ( -
    • - - {t(`admin.channels.warnings.${kind}`)} · {details.length} - - {details.slice(0, 20).map((detail) => ( - - {detail.text} - - ))} - {details.length > 20 && ( - - {t('admin.channels.andMore', { count: details.length - 20 })} - - )} -
    • - ))} -
    - )} - - -
    - ) -} - -/** - * Тепловая карта повторов: матрица «шоу × вещательные сутки», яркость — число показов. Сразу видно, - * что один фильм крутится четыре раза за неделю. - */ -function RepeatHeatmap({ preview }: Readonly<{ preview: SchedulePreviewDto }>) { - const { t } = useTranslation() - - const { days, rows } = useMemo(() => { - const counts = new Map>() - const dayKeys = new Set() - - 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() - 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 ( -
    - - {t('admin.channels.heatmap')} - -
    - - - - - ))} - - - - - {rows.map((row) => ( - - - {days.map((day) => { - const count = row.byDay.get(day) ?? 0 - return ( - - ) - })} - - - ))} - -
    - {days.map((day) => ( - - {day.slice(8)}.{day.slice(5, 7)} - {t('admin.channels.heatmapTotal')}
    - {row.title} - - - {count > 0 ? count : ''} - - {row.total}
    -
    -
    - ) -} +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 = { + 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(['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 ( +
    +
    + + {open && ( + <> + + + {isFetching ? t('common.loading') : t('admin.channels.previewHint')} + + + )} +
    + + {open && data && ( +
    +
    + {(['programme', 'tape', 'problems'] as const).map((value) => ( + + ))} +
    + + {tab === 'programme' && } + {tab === 'tape' && } + {tab === 'problems' && } +
    + )} +
    + ) +} + +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

    {t('admin.channels.noSchedule')}

    + + return ( +
      + {items.map((item, index) => ( +
    • + + {formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)} + + + {item.title ?? t(`admin.channels.previewKinds.${item.kind}`)} + + {item.slotTitle && ( + {item.slotTitle} + )} +
    • + ))} +
    + ) +} + +/** Час вещания → сколько в нём минут врезок. По ним же строится гистограмма нагрузки. */ +function loadByHour(preview: SchedulePreviewDto): { hour: Date; minutes: number }[] { + const buckets = new Map() + 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

    {t('admin.channels.noSchedule')}

    + + return ( +
    + {load.length > 0 && ( +
    + + {t('admin.channels.previewLoad', { peak: Math.round(peak) })} + +
    + {load.map((bucket) => ( +
    + ))} +
    +
    + )} + +
      + {preview.items.map((item, index) => ( + + ))} +
    +
    + ) +} + +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 ( +
  • + + {formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)} + + + + {t(`admin.channels.previewKinds.${item.kind}`)} + + {item.title ?? ''} +
  • + ) +} + +/** Предупреждения, сгруппированные по виду: десять однотипных строк читаются как одна проблема. */ +function Problems({ preview }: Readonly<{ preview: SchedulePreviewDto }>) { + const { t } = useTranslation() + // Ключ строки — «вид + порядковый номер внутри вида»: у предупреждения нет своего id, а текст + // повторяется (одна и та же причина на разных слотах), и позиция здесь — единственное, что его + // отличает. Считаем ключ при группировке, чтобы список не пересобирался на каждый рендер. + const grouped = useMemo(() => { + const map = new Map() + 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 ( +
    + {grouped.length === 0 ? ( +

    {t('admin.channels.noProblems')}

    + ) : ( +
      + {grouped.map(([kind, details]) => ( +
    • + + {t(`admin.channels.warnings.${kind}`)} · {details.length} + + {details.slice(0, 20).map((detail) => ( + + {detail.text} + + ))} + {details.length > 20 && ( + + {t('admin.channels.andMore', { count: details.length - 20 })} + + )} +
    • + ))} +
    + )} + + +
    + ) +} + +/** + * Тепловая карта повторов: матрица «шоу × вещательные сутки», яркость — число показов. Сразу видно, + * что один фильм крутится четыре раза за неделю. + */ +function RepeatHeatmap({ preview }: Readonly<{ preview: SchedulePreviewDto }>) { + const { t } = useTranslation() + + const { days, rows } = useMemo(() => { + const counts = new Map>() + const dayKeys = new Set() + + 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() + 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 ( +
    + + {t('admin.channels.heatmap')} + +
    + + + + + ))} + + + + + {rows.map((row) => ( + + + {days.map((day) => { + const count = row.byDay.get(day) ?? 0 + return ( + + ) + })} + + + ))} + +
    + {days.map((day) => ( + + {day.slice(8)}.{day.slice(5, 7)} + {t('admin.channels.heatmapTotal')}
    + {row.title} + + + {count > 0 ? count : ''} + + {row.total}
    +
    +
    + ) +} diff --git a/frontend/src/features/admin/collections/CollectionDetail.tsx b/frontend/src/features/admin/collections/CollectionDetail.tsx index db41172..dd65d5d 100644 --- a/frontend/src/features/admin/collections/CollectionDetail.tsx +++ b/frontend/src/features/admin/collections/CollectionDetail.tsx @@ -149,7 +149,11 @@ export function CollectionDetail({ collectionId }: Readonly<{ collectionId: stri />
    -
    diff --git a/frontend/src/features/admin/genres/GenresPanel.tsx b/frontend/src/features/admin/genres/GenresPanel.tsx index 34e04f1..d107ed7 100644 --- a/frontend/src/features/admin/genres/GenresPanel.tsx +++ b/frontend/src/features/admin/genres/GenresPanel.tsx @@ -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' diff --git a/frontend/src/features/admin/groups/GroupDetail.tsx b/frontend/src/features/admin/groups/GroupDetail.tsx index 67abef4..6095ea3 100644 --- a/frontend/src/features/admin/groups/GroupDetail.tsx +++ b/frontend/src/features/admin/groups/GroupDetail.tsx @@ -170,7 +170,12 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
    - {candidates !== null && ( diff --git a/frontend/src/features/admin/interstitials/BlockBuilder.tsx b/frontend/src/features/admin/interstitials/BlockBuilder.tsx index 1f71413..7724de0 100644 --- a/frontend/src/features/admin/interstitials/BlockBuilder.tsx +++ b/frontend/src/features/admin/interstitials/BlockBuilder.tsx @@ -109,11 +109,7 @@ export function BlockBuilder({ {formatClock(item.seconds)} - diff --git a/frontend/src/features/admin/maintenance/MaintenancePanel.tsx b/frontend/src/features/admin/maintenance/MaintenancePanel.tsx index 66cca33..31a337f 100644 --- a/frontend/src/features/admin/maintenance/MaintenancePanel.tsx +++ b/frontend/src/features/admin/maintenance/MaintenancePanel.tsx @@ -75,7 +75,9 @@ export function MaintenancePanel() { @@ -121,7 +123,9 @@ export function MaintenancePanel() { diff --git a/frontend/src/features/admin/media/ManualInboxDialog.tsx b/frontend/src/features/admin/media/ManualInboxDialog.tsx index e52a853..0f39dba 100644 --- a/frontend/src/features/admin/media/ManualInboxDialog.tsx +++ b/frontend/src/features/admin/media/ManualInboxDialog.tsx @@ -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([]) - 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([]) - - 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>() - 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() - 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 ( - !open && onClose()}> - - - {t('admin.media.manualTitle')} - {t('admin.media.manualHint')} - - -
    - {/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */} -
    - - - {autoDetected && ( -

    - {t('admin.media.manualDetected')} -

    - )} -
    - -
    -
    - - setQuery(e.target.value)} /> -
    -
    - - {/* Ноль — законный сезон: в нём живут спецвыпуски и пилоты. */} - setSeasonStr(e.target.value)} - /> -
    -
    - - setRegexStr(e.target.value)} - className={!regexOk ? 'border-red-500' : undefined} - /> -
    -
    - {!regexOk &&

    {t('admin.media.toShowRegexInvalid')}

    } - - {/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */} - {sample && ( -
    - - {t('admin.media.regexPickHint')} - -
    - {sampleParts.map((part) => - part.number === null ? ( - - {part.text} - - ) : ( - - ), - )} -
    -
    - - {t('admin.media.regexPresets')} - - {REGEX_PRESETS.map((preset) => ( - - ))} - {regexStr && ( - - )} -
    -
    - )} - -
    - - - {t('admin.media.manualSelected', { count: selected.length })} - - - {t('admin.media.manualRecognized', { - count: recognized, - total: selectable.length, - })} - -
    - -
    - {isLoading &&

    {t('common.loading')}

    } - {!isLoading && folders.length === 0 && ( -

    {t('admin.media.manualEmpty')}

    - )} - - {folders.map(({ folder, files }) => { - const isCollapsed = collapsed.includes(folder) - return ( -
    -
    - - !f.alreadyImported) - .every((f) => selected.includes(f.relativePath))} - onChange={() => toggleFolder(files)} - /> - - - {folder || t('admin.media.manualRoot')} - - {files.length} -
    - - {!isCollapsed && ( -
      - {files.map((file) => { - const label = formatSeasonEpisode( - parsedByPath.get(file.relativePath) ?? { season: null, episode: null }, - ) - return ( -
    • - toggle(file.relativePath)} - /> - {label ? ( - {label} - ) : ( - - {t('admin.media.toShowUnknown')} - - )} - - {file.name} - - {file.alreadyImported && ( - {t('admin.media.manualAlready')} - )} - - {formatSize(file.sizeBytes)} - -
    • - ) - })} -
    - )} -
    - ) - })} -
    - - {data?.truncated && ( -

    {t('admin.media.manualTruncated')}

    - )} - -

    {t('admin.media.manualCleanupHint')}

    -
    - - - - - -
    -
    - ) -} +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([]) + 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([]) + + 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>() + 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() + 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 ( + !open && onClose()}> + + + {t('admin.media.manualTitle')} + {t('admin.media.manualHint')} + + +
    + {/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */} +
    + + + {autoDetected && ( +

    {t('admin.media.manualDetected')}

    + )} +
    + +
    +
    + + setQuery(e.target.value)} /> +
    +
    + + {/* Ноль — законный сезон: в нём живут спецвыпуски и пилоты. */} + setSeasonStr(e.target.value)} + /> +
    +
    + + setRegexStr(e.target.value)} + className={!regexOk ? 'border-red-500' : undefined} + /> +
    +
    + {!regexOk && ( +

    {t('admin.media.toShowRegexInvalid')}

    + )} + + {/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */} + {sample && ( +
    + + {t('admin.media.regexPickHint')} + +
    + {sampleParts.map((part) => + part.number === null ? ( + + {part.text} + + ) : ( + + ), + )} +
    +
    + + {t('admin.media.regexPresets')} + + {REGEX_PRESETS.map((preset) => ( + + ))} + {regexStr && ( + + )} +
    +
    + )} + +
    + + + {t('admin.media.manualSelected', { count: selected.length })} + + + {t('admin.media.manualRecognized', { + count: recognized, + total: selectable.length, + })} + +
    + +
    + {isLoading &&

    {t('common.loading')}

    } + {!isLoading && folders.length === 0 && ( +

    {t('admin.media.manualEmpty')}

    + )} + + {folders.map(({ folder, files }) => { + const isCollapsed = collapsed.includes(folder) + return ( +
    +
    + + !f.alreadyImported) + .every((f) => selected.includes(f.relativePath))} + onChange={() => toggleFolder(files)} + /> + + + {folder || t('admin.media.manualRoot')} + + {files.length} +
    + + {!isCollapsed && ( +
      + {files.map((file) => { + const label = formatSeasonEpisode( + parsedByPath.get(file.relativePath) ?? { season: null, episode: null }, + ) + return ( +
    • + toggle(file.relativePath)} + /> + {label ? ( + {label} + ) : ( + + {t('admin.media.toShowUnknown')} + + )} + + {file.name} + + {file.alreadyImported && ( + {t('admin.media.manualAlready')} + )} + + {formatSize(file.sizeBytes)} + +
    • + ) + })} +
    + )} +
    + ) + })} +
    + + {data?.truncated && ( +

    {t('admin.media.manualTruncated')}

    + )} + +

    {t('admin.media.manualCleanupHint')}

    +
    + + + + + +
    +
    + ) +} diff --git a/frontend/src/features/admin/media/UploadSnackbar.tsx b/frontend/src/features/admin/media/UploadSnackbar.tsx index 915fd49..a386ed8 100644 --- a/frontend/src/features/admin/media/UploadSnackbar.tsx +++ b/frontend/src/features/admin/media/UploadSnackbar.tsx @@ -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' diff --git a/frontend/src/features/admin/media/UploadToShowDialog.tsx b/frontend/src/features/admin/media/UploadToShowDialog.tsx index 980e052..ca3778e 100644 --- a/frontend/src/features/admin/media/UploadToShowDialog.tsx +++ b/frontend/src/features/admin/media/UploadToShowDialog.tsx @@ -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({ - {t('admin.media.toShowLibrary')} + + {t('admin.media.toShowLibrary')} + {shows?.map((s) => ( {s.name} diff --git a/frontend/src/features/admin/media/match-show.ts b/frontend/src/features/admin/media/match-show.ts index 1011065..dbd526a 100644 --- a/frontend/src/features/admin/media/match-show.ts +++ b/frontend/src/features/admin/media/match-show.ts @@ -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 diff --git a/frontend/src/features/admin/roles/RolesPanel.tsx b/frontend/src/features/admin/roles/RolesPanel.tsx index c80e1bb..5f31079 100644 --- a/frontend/src/features/admin/roles/RolesPanel.tsx +++ b/frontend/src/features/admin/roles/RolesPanel.tsx @@ -56,7 +56,9 @@ export function RolesPanel() { }) const [open, setOpen] = useState(false) - const { register, handleSubmit, reset } = useForm>({ resolver: zodResolver(schema) }) + const { register, handleSubmit, reset } = useForm>({ + resolver: zodResolver(schema), + }) const onCreate = async (values: z.infer) => { try { @@ -128,7 +130,11 @@ export function RolesPanel() { {role.name} - {role.isSystem ? {t('common.yes')} : t('common.no')} + {role.isSystem ? ( + {t('common.yes')} + ) : ( + t('common.no') + )}
    diff --git a/frontend/src/features/admin/shows/ShowDetail.tsx b/frontend/src/features/admin/shows/ShowDetail.tsx index 4fe5815..1c3a286 100644 --- a/frontend/src/features/admin/shows/ShowDetail.tsx +++ b/frontend/src/features/admin/shows/ShowDetail.tsx @@ -240,30 +240,36 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) { > {t('admin.shows.deselectAll')} -
    {candidates.length === 0 ? ( -

    {t('admin.shows.noMatches')}

    +

    + {t('admin.shows.noMatches')} +

    ) : (
      {candItems.map(({ asset, parsed }) => { const label = formatSeasonEpisode(parsed) return ( -
    • - -
    • +
    • + +
    • ) })}
    @@ -295,48 +301,48 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) { : parseEpisodeName(episode.assetName ?? '') const label = formatSeasonEpisode(parsed) return ( - - {epOffset + index + 1} - -
    - {episode.stillImageId && ( - - )} - {label && {label}} -
    -
    {episode.title ?? episode.assetName ?? '—'}
    - {episode.title && ( -
    - {episode.assetName} -
    + + {epOffset + index + 1} + +
    + {episode.stillImageId && ( + )} + {label && {label}} +
    +
    {episode.title ?? episode.assetName ?? '—'}
    + {episode.title && ( +
    + {episode.assetName} +
    + )} +
    -
    - - - {formatDuration(episode.durationSeconds)} - - - {episode.assetStatus && ( - - {t(`admin.media.statuses.${episode.assetStatus}`)} - - )} - - - - - + + + {formatDuration(episode.durationSeconds)} + + + {episode.assetStatus && ( + + {t(`admin.media.statuses.${episode.assetStatus}`)} + + )} + + + + + ) })} {show.episodes.length === 0 && ( diff --git a/frontend/src/features/admin/shows/ShowMetadataCard.tsx b/frontend/src/features/admin/shows/ShowMetadataCard.tsx index afb0e3b..7648993 100644 --- a/frontend/src/features/admin/shows/ShowMetadataCard.tsx +++ b/frontend/src/features/admin/shows/ShowMetadataCard.tsx @@ -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 ( <> - - - {t('admin.metadata.title')} - - - {/* Постер */} -
    -
    - {show.posterImageId ? ( - - ) : ( - {t('admin.metadata.noPoster')} - )} -
    - - setPoster.mutate(img.id)} - /> -
    - - {/* Название + поиск + ручная правка */} -
    -
    - - setName(e.target.value)} /> -
    - -
    - - { - setOriginalName(e.target.value) - setSearched(false) - }} - /> -

    {t('admin.metadata.originalNameHint')}

    -
    - - {providers && providers.length > 0 && (searched || results.length > 0) && ( -
    - {searched && results.length === 0 && ( -

    {t('admin.metadata.nothingFound')}

    - )} - - {results.length > 0 && ( -
      - {results.map((r) => ( -
    • - {r.posterUrl ? ( - - ) : ( -
      - )} -
      -
      - - {r.title} - {r.year != null && ( - ({r.year}) - )} - - {/* В выдаче OMDb сериалы и полнометражки идут вперемешку — без метки - одноимённые фильм и сериал не различить. */} - {r.kind && ( - {t(`admin.shows.kinds.${r.kind}`)} - )} -
      - {r.overview && ( -

      {r.overview}

      - )} -
      - -
    • - ))} -
    - )} -
    - )} - -
    -
    -
    - -