Add Prettier to the frontend and gate formatting in CI
Форматтера у фронтенда не было: стиль держался вручную и успел разъехаться в 50 файлах. Ставим Prettier с настройками под уже сложившийся стиль (без точек с запятой, одинарные кавычки, ширина 100 — подобрана замером: при 100 расходится меньше файлов, чем при 96 или 110) и прогоняем его по коду. `src/routeTree.gen.ts` исключён — его переписывает плагин роутера. Чтобы форматирование больше не расходилось незаметно, добавлены проверки в CI: `csharpier check` для бэкенда (его отсутствие и позволило накопиться 79 неотформатированным файлам) и `prettier --check` для фронтенда. Версии форматтеров прибиты точно, без кареток: минорка меняет вывод и красит CI на файлах, которых никто не трогал. `.editorconfig` задаёт редакторам те же отступы и LF ещё до форматтера; значения совпадают с настройками csharpier и Prettier намеренно — оба его читают. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0442056367
commit
0606ea3e6e
@@ -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
|
||||||
@@ -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
|
||||||
@@ -21,6 +21,14 @@ jobs:
|
|||||||
- name: Restore
|
- name: Restore
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
run: dotnet restore TeleWave.slnx
|
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 не отключаем.
|
# Строгая сборка: TreatWarningsAsErrors=true из Directory.Build.props не отключаем.
|
||||||
- name: Build (Release)
|
- name: Build (Release)
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
@@ -38,6 +46,9 @@ jobs:
|
|||||||
- name: Install
|
- name: Install
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Format check
|
||||||
|
working-directory: frontend
|
||||||
|
run: pnpm format:check
|
||||||
- name: Lint
|
- name: Lint
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
dist
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
# Генерируется плагином TanStack Router на dev/build — форматировать бесполезно, перезапишется.
|
||||||
|
src/routeTree.gen.ts
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
"typecheck": "tsc -b",
|
"typecheck": "tsc -b",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
@@ -39,7 +41,8 @@
|
|||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
"oxlint": "^1.71.0",
|
"oxlint": "1.75.0",
|
||||||
|
"prettier": "3.9.6",
|
||||||
"tailwindcss": "^4.3.2",
|
"tailwindcss": "^4.3.2",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.1.1"
|
"vite": "^8.1.1"
|
||||||
|
|||||||
Generated
+4
-1
@@ -85,8 +85,11 @@ importers:
|
|||||||
specifier: ^6.0.3
|
specifier: ^6.0.3
|
||||||
version: 6.0.4(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))
|
version: 6.0.4(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))
|
||||||
oxlint:
|
oxlint:
|
||||||
specifier: ^1.71.0
|
specifier: 1.75.0
|
||||||
version: 1.75.0
|
version: 1.75.0
|
||||||
|
prettier:
|
||||||
|
specifier: 3.9.6
|
||||||
|
version: 3.9.6
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.3.2
|
specifier: ^4.3.2
|
||||||
version: 4.3.3
|
version: 4.3.3
|
||||||
|
|||||||
@@ -11,12 +11,7 @@ import { Badge } from '@/shared/ui/badge'
|
|||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Card, CardContent } from '@/shared/ui/card'
|
import { Card, CardContent } from '@/shared/ui/card'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import {
|
import { applyChannelTemplate, getChannel, getChannelTemplate, getSchedule } from './api'
|
||||||
applyChannelTemplate,
|
|
||||||
getChannel,
|
|
||||||
getChannelTemplate,
|
|
||||||
getSchedule,
|
|
||||||
} from './api'
|
|
||||||
import { ApplyDialog } from './components/ApplyDialog'
|
import { ApplyDialog } from './components/ApplyDialog'
|
||||||
import { BumperCard } from './components/BumperCard'
|
import { BumperCard } from './components/BumperCard'
|
||||||
import { EntryTraceDialog } from './components/EntryTraceDialog'
|
import { EntryTraceDialog } from './components/EntryTraceDialog'
|
||||||
|
|||||||
@@ -1,371 +1,375 @@
|
|||||||
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||||
import type {
|
import type {
|
||||||
ApplyResultDto,
|
ApplyResultDto,
|
||||||
BumperSettings,
|
BumperSettings,
|
||||||
BumperTextKind,
|
BumperTextKind,
|
||||||
BumperTrigger,
|
BumperTrigger,
|
||||||
ChannelDto,
|
ChannelDto,
|
||||||
ChannelSummaryDto,
|
ChannelSummaryDto,
|
||||||
CopyTemplateResultDto,
|
CopyTemplateResultDto,
|
||||||
CreatedIdResponse,
|
CreatedIdResponse,
|
||||||
EntryTraceDto,
|
EntryTraceDto,
|
||||||
JunctionAmountMode,
|
JunctionAmountMode,
|
||||||
JunctionConditions,
|
JunctionConditions,
|
||||||
JunctionElementKind,
|
JunctionElementKind,
|
||||||
JunctionTemplateDto,
|
JunctionTemplateDto,
|
||||||
LayerApplicability,
|
LayerApplicability,
|
||||||
PlanningRules,
|
PlanningRules,
|
||||||
ScheduleDiffDto,
|
ScheduleDiffDto,
|
||||||
ScheduleEntryDto,
|
ScheduleEntryDto,
|
||||||
SchedulePreviewDto,
|
SchedulePreviewDto,
|
||||||
ScheduleTemplateDto,
|
ScheduleTemplateDto,
|
||||||
SlotDto,
|
SlotDto,
|
||||||
TemplateIssueDto,
|
TemplateIssueDto,
|
||||||
ViewerSettings,
|
ViewerSettings,
|
||||||
} from '@/shared/api/types'
|
} from '@/shared/api/types'
|
||||||
|
|
||||||
export function listChannels() {
|
export function listChannels() {
|
||||||
return apiRequest<ChannelSummaryDto[]>('/admin/channels')
|
return apiRequest<ChannelSummaryDto[]>('/admin/channels')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChannel(id: string) {
|
export function getChannel(id: string) {
|
||||||
return apiRequest<ChannelDto>(`/admin/channels/${id}`)
|
return apiRequest<ChannelDto>(`/admin/channels/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createChannel(body: { name: string; slug: string }) {
|
export function createChannel(body: { name: string; slug: string }) {
|
||||||
return apiRequest<CreatedIdResponse>('/admin/channels', { method: 'POST', body })
|
return apiRequest<CreatedIdResponse>('/admin/channels', { method: 'POST', body })
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelSettingsBody = {
|
type ChannelSettingsBody = {
|
||||||
name: string
|
name: string
|
||||||
isEnabled: boolean
|
isEnabled: boolean
|
||||||
bumpersEnabled: boolean
|
bumpersEnabled: boolean
|
||||||
bumper: BumperSettings
|
bumper: BumperSettings
|
||||||
fillerAssetId: string | null
|
fillerAssetId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
|
export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
|
||||||
return apiRequest<void>(`/admin/channels/${id}/settings`, { method: 'PUT', body })
|
return apiRequest<void>(`/admin/channels/${id}/settings`, { method: 'PUT', body })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Оверлеи и аналоговый фильтр канала — как он выглядит у зрителя. */
|
/** Оверлеи и аналоговый фильтр канала — как он выглядит у зрителя. */
|
||||||
export function updateViewerSettings(id: string, body: ViewerSettings) {
|
export function updateViewerSettings(id: string, body: ViewerSettings) {
|
||||||
return apiRequest<void>(`/admin/channels/${id}/viewer`, { method: 'PUT', body })
|
return apiRequest<void>(`/admin/channels/${id}/viewer`, { method: 'PUT', body })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Номер канала и его время: смещение от UTC и начало вещательных суток. */
|
/** Номер канала и его время: смещение от UTC и начало вещательных суток. */
|
||||||
export function updateChannelTime(
|
export function updateChannelTime(
|
||||||
id: string,
|
id: string,
|
||||||
body: { number: number | null; utcOffsetMinutes: number; dayStartTime: string },
|
body: { number: number | null; utcOffsetMinutes: number; dayStartTime: string },
|
||||||
) {
|
) {
|
||||||
return apiRequest<void>(`/admin/channels/${id}/time`, { method: 'PUT', body })
|
return apiRequest<void>(`/admin/channels/${id}/time`, { method: 'PUT', body })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Сетка канала ──────────────────────────────────────────────────────────
|
// ── Сетка канала ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getChannelTemplate(channelId: string) {
|
export function getChannelTemplate(channelId: string) {
|
||||||
return apiRequest<ScheduleTemplateDto>(`/admin/channels/${channelId}/template`)
|
return apiRequest<ScheduleTemplateDto>(`/admin/channels/${channelId}/template`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Заводит каналу сетку, если её нет: у каналов из старой ротации шаблона может не быть. */
|
/** Заводит каналу сетку, если её нет: у каналов из старой ротации шаблона может не быть. */
|
||||||
export function createChannelTemplate(channelId: string) {
|
export function createChannelTemplate(channelId: string) {
|
||||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${channelId}/template`, { method: 'POST' })
|
return apiRequest<CreatedIdResponse>(`/admin/channels/${channelId}/template`, { method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Применяет правила к эфиру: пересобирает будущий хвост. Правка слотов эфир не двигает. */
|
/** Применяет правила к эфиру: пересобирает будущий хвост. Правка слотов эфир не двигает. */
|
||||||
export function applyChannelTemplate(channelId: string) {
|
export function applyChannelTemplate(channelId: string) {
|
||||||
return apiRequest<ApplyResultDto>(`/admin/channels/${channelId}/template/apply`, {
|
return apiRequest<ApplyResultDto>(`/admin/channels/${channelId}/template/apply`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Проверки сетки по правилам — считаются по шаблону, без прогона генератора. */
|
/** Проверки сетки по правилам — считаются по шаблону, без прогона генератора. */
|
||||||
export function getTemplateIssues(channelId: string) {
|
export function getTemplateIssues(channelId: string) {
|
||||||
return apiRequest<TemplateIssueDto[]>(`/admin/channels/${channelId}/template/issues`)
|
return apiRequest<TemplateIssueDto[]>(`/admin/channels/${channelId}/template/issues`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Что изменится в эфире, если применить сейчас. Прогон сухой — лента не трогается. */
|
/** Что изменится в эфире, если применить сейчас. Прогон сухой — лента не трогается. */
|
||||||
export function getApplyDiff(channelId: string) {
|
export function getApplyDiff(channelId: string) {
|
||||||
return apiRequest<ScheduleDiffDto>(`/admin/channels/${channelId}/template/diff`)
|
return apiRequest<ScheduleDiffDto>(`/admin/channels/${channelId}/template/diff`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Копия сетки на другой канал: слои, слоты, стыки и правила. Группы общие и не копируются. */
|
/** Копия сетки на другой канал: слои, слоты, стыки и правила. Группы общие и не копируются. */
|
||||||
export function copyTemplateTo(channelId: string, targetChannelId: string) {
|
export function copyTemplateTo(channelId: string, targetChannelId: string) {
|
||||||
return apiRequest<CopyTemplateResultDto>(
|
return apiRequest<CopyTemplateResultDto>(
|
||||||
`/admin/channels/${channelId}/template/copy-to/${targetChannelId}`,
|
`/admin/channels/${channelId}/template/copy-to/${targetChannelId}`,
|
||||||
{ method: 'POST' },
|
{ method: 'POST' },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Цепочка происхождения записи, записанная в момент генерации. */
|
/** Цепочка происхождения записи, записанная в момент генерации. */
|
||||||
export function getEntryTrace(entryId: string) {
|
export function getEntryTrace(entryId: string) {
|
||||||
return apiRequest<EntryTraceDto>(`/admin/channels/entries/${entryId}/trace`)
|
return apiRequest<EntryTraceDto>(`/admin/channels/entries/${entryId}/trace`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Сухой прогон по текущим правилам: ничего не пишет и не двигает курсоры слотов. */
|
/** Сухой прогон по текущим правилам: ничего не пишет и не двигает курсоры слотов. */
|
||||||
export function previewTemplate(channelId: string, days: number) {
|
export function previewTemplate(channelId: string, days: number) {
|
||||||
const query = new URLSearchParams({ days: String(days) })
|
const query = new URLSearchParams({ days: String(days) })
|
||||||
return apiRequest<SchedulePreviewDto>(
|
return apiRequest<SchedulePreviewDto>(
|
||||||
`/admin/channels/${channelId}/template/preview?${query.toString()}`,
|
`/admin/channels/${channelId}/template/preview?${query.toString()}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateTemplate(
|
export function updateTemplate(
|
||||||
templateId: string,
|
templateId: string,
|
||||||
body: {
|
body: {
|
||||||
name: string
|
name: string
|
||||||
fallbackGroupId: string | null
|
fallbackGroupId: string | null
|
||||||
defaultJunctionId: string | null
|
defaultJunctionId: string | null
|
||||||
rules: PlanningRules | null
|
rules: PlanningRules | null
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
return apiRequest<void>(`/admin/templates/${templateId}`, { method: 'PUT', body })
|
return apiRequest<void>(`/admin/templates/${templateId}`, { method: 'PUT', body })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createLayer(templateId: string, body: { name: string; priority: number }) {
|
export function createLayer(templateId: string, body: { name: string; priority: number }) {
|
||||||
return apiRequest<CreatedIdResponse>(`/admin/templates/${templateId}/layers`, {
|
return apiRequest<CreatedIdResponse>(`/admin/templates/${templateId}/layers`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateLayer(
|
export function updateLayer(
|
||||||
layerId: string,
|
layerId: string,
|
||||||
body: {
|
body: {
|
||||||
name: string
|
name: string
|
||||||
priority: number
|
priority: number
|
||||||
applicability: LayerApplicability | null
|
applicability: LayerApplicability | null
|
||||||
isEnabled: boolean
|
isEnabled: boolean
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'PUT', body })
|
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'PUT', body })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteLayer(layerId: string) {
|
export function deleteLayer(layerId: string) {
|
||||||
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'DELETE' })
|
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Тело слота: то же для создания и правки (см. SlotInput на сервере). */
|
/** Тело слота: то же для создания и правки (см. SlotInput на сервере). */
|
||||||
export type SlotBody = Omit<SlotDto, 'id' | 'layerId' | 'groupName'>
|
export type SlotBody = Omit<SlotDto, 'id' | 'layerId' | 'groupName'>
|
||||||
|
|
||||||
/** Слот из ответа сервера → тело запроса: отбрасываем то, что сервер проставляет сам. */
|
/** Слот из ответа сервера → тело запроса: отбрасываем то, что сервер проставляет сам. */
|
||||||
export function toSlotBody(slot: SlotDto): SlotBody {
|
export function toSlotBody(slot: SlotDto): SlotBody {
|
||||||
const { id: _id, layerId: _layerId, groupName: _groupName, ...body } = slot
|
const { id: _id, layerId: _layerId, groupName: _groupName, ...body } = slot
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSlot(layerId: string, body: SlotBody) {
|
export function createSlot(layerId: string, body: SlotBody) {
|
||||||
return apiRequest<CreatedIdResponse>(`/admin/layers/${layerId}/slots`, { method: 'POST', body })
|
return apiRequest<CreatedIdResponse>(`/admin/layers/${layerId}/slots`, { method: 'POST', body })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateSlot(slotId: string, body: SlotBody) {
|
export function updateSlot(slotId: string, body: SlotBody) {
|
||||||
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'PUT', body })
|
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'PUT', body })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteSlot(slotId: string) {
|
export function deleteSlot(slotId: string) {
|
||||||
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'DELETE' })
|
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Стыки канала ──────────────────────────────────────────────────────────
|
// ── Стыки канала ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function listJunctions(channelId: string) {
|
export function listJunctions(channelId: string) {
|
||||||
return apiRequest<JunctionTemplateDto[]>(`/admin/channels/${channelId}/junctions`)
|
return apiRequest<JunctionTemplateDto[]>(`/admin/channels/${channelId}/junctions`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createJunction(channelId: string, name: string) {
|
export function createJunction(channelId: string, name: string) {
|
||||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${channelId}/junctions`, {
|
return apiRequest<CreatedIdResponse>(`/admin/channels/${channelId}/junctions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { name },
|
body: { name },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renameJunction(junctionId: string, name: string) {
|
export function renameJunction(junctionId: string, name: string) {
|
||||||
return apiRequest<void>(`/admin/junctions/${junctionId}`, { method: 'PUT', body: { name } })
|
return apiRequest<void>(`/admin/junctions/${junctionId}`, { method: 'PUT', body: { name } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteJunction(junctionId: string) {
|
export function deleteJunction(junctionId: string) {
|
||||||
return apiRequest<void>(`/admin/junctions/${junctionId}`, { method: 'DELETE' })
|
return apiRequest<void>(`/admin/junctions/${junctionId}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addJunctionElement(junctionId: string, kind: JunctionElementKind) {
|
export function addJunctionElement(junctionId: string, kind: JunctionElementKind) {
|
||||||
return apiRequest<CreatedIdResponse>(`/admin/junctions/${junctionId}/elements`, {
|
return apiRequest<CreatedIdResponse>(`/admin/junctions/${junctionId}/elements`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { kind },
|
body: { kind },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Тело врезки: то же для любого типа — лишние поля сервер обнуляет сам (см. JunctionElement.Update). */
|
/** Тело врезки: то же для любого типа — лишние поля сервер обнуляет сам (см. JunctionElement.Update). */
|
||||||
export type JunctionElementBody = {
|
export type JunctionElementBody = {
|
||||||
kind: JunctionElementKind
|
kind: JunctionElementKind
|
||||||
groupId: string | null
|
groupId: string | null
|
||||||
bumperTemplateId: string | null
|
bumperTemplateId: string | null
|
||||||
amountMode: JunctionAmountMode
|
amountMode: JunctionAmountMode
|
||||||
amountValue: number
|
amountValue: number
|
||||||
isRequired: boolean
|
isRequired: boolean
|
||||||
conditions: JunctionConditions | null
|
conditions: JunctionConditions | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateJunctionElement(
|
export function updateJunctionElement(
|
||||||
junctionId: string,
|
junctionId: string,
|
||||||
elementId: string,
|
elementId: string,
|
||||||
body: JunctionElementBody,
|
body: JunctionElementBody,
|
||||||
) {
|
) {
|
||||||
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
|
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body,
|
body,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeJunctionElement(junctionId: string, elementId: string) {
|
export function removeJunctionElement(junctionId: string, elementId: string) {
|
||||||
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
|
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Порядок врезок: не упомянутые остаются после перечисленных. */
|
/** Порядок врезок: не упомянутые остаются после перечисленных. */
|
||||||
export function reorderJunction(junctionId: string, elementIdsInOrder: string[]) {
|
export function reorderJunction(junctionId: string, elementIdsInOrder: string[]) {
|
||||||
return apiRequest<void>(`/admin/junctions/${junctionId}/order`, {
|
return apiRequest<void>(`/admin/junctions/${junctionId}/order`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: { elementIdsInOrder },
|
body: { elementIdsInOrder },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type BumperTemplateStyleBody = {
|
type BumperTemplateStyleBody = {
|
||||||
name: string
|
name: string
|
||||||
backgroundColor: string
|
backgroundColor: string
|
||||||
backgroundColor2: string
|
backgroundColor2: string
|
||||||
accentColor: string
|
accentColor: string
|
||||||
textColor: string
|
textColor: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addBumperTemplate(id: string, name: string) {
|
export function addBumperTemplate(id: string, name: string) {
|
||||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/bumper/templates`, {
|
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/bumper/templates`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { name },
|
body: { name },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateBumperTemplate(id: string, templateId: string, body: BumperTemplateStyleBody) {
|
export function updateBumperTemplate(
|
||||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
id: string,
|
||||||
method: 'PUT',
|
templateId: string,
|
||||||
body,
|
body: BumperTemplateStyleBody,
|
||||||
})
|
) {
|
||||||
}
|
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||||
|
method: 'PUT',
|
||||||
export function removeBumperTemplate(id: string, templateId: string) {
|
body,
|
||||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
})
|
||||||
method: 'DELETE',
|
}
|
||||||
})
|
|
||||||
}
|
export function removeBumperTemplate(id: string, templateId: string) {
|
||||||
|
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||||
/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */
|
method: 'DELETE',
|
||||||
function uploadBumperTemplateFile(
|
})
|
||||||
id: string,
|
}
|
||||||
templateId: string,
|
|
||||||
kind: 'audio' | 'background',
|
/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */
|
||||||
file: File,
|
function uploadBumperTemplateFile(
|
||||||
): Promise<void> {
|
id: string,
|
||||||
return new Promise((resolve, reject) => {
|
templateId: string,
|
||||||
const xhr = new XMLHttpRequest()
|
kind: 'audio' | 'background',
|
||||||
const query = new URLSearchParams({ fileName: file.name })
|
file: File,
|
||||||
xhr.open(
|
): Promise<void> {
|
||||||
'PUT',
|
return new Promise((resolve, reject) => {
|
||||||
`/api/admin/channels/${id}/bumper/templates/${templateId}/${kind}?${query.toString()}`,
|
const xhr = new XMLHttpRequest()
|
||||||
)
|
const query = new URLSearchParams({ fileName: file.name })
|
||||||
const token = getAccessToken()
|
xhr.open(
|
||||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
'PUT',
|
||||||
xhr.onload = () => {
|
`/api/admin/channels/${id}/bumper/templates/${templateId}/${kind}?${query.toString()}`,
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
)
|
||||||
resolve()
|
const token = getAccessToken()
|
||||||
} else {
|
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||||
let detail = `HTTP ${xhr.status}`
|
xhr.onload = () => {
|
||||||
try {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
|
resolve()
|
||||||
detail = problem.detail ?? problem.title ?? detail
|
} else {
|
||||||
} catch {
|
let detail = `HTTP ${xhr.status}`
|
||||||
/* пусто */
|
try {
|
||||||
}
|
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
|
||||||
reject(new HttpError({ detail }, xhr.status))
|
detail = problem.detail ?? problem.title ?? detail
|
||||||
}
|
} catch {
|
||||||
}
|
/* пусто */
|
||||||
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
|
}
|
||||||
xhr.send(file)
|
reject(new HttpError({ detail }, xhr.status))
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
|
||||||
export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) {
|
xhr.send(file)
|
||||||
return uploadBumperTemplateFile(id, templateId, 'audio', file)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type BumperVariantBody = {
|
export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) {
|
||||||
name: string
|
return uploadBumperTemplateFile(id, templateId, 'audio', file)
|
||||||
kind: BumperTextKind
|
}
|
||||||
nowLabel: string
|
|
||||||
nextLabel: string
|
type BumperVariantBody = {
|
||||||
line1: string
|
name: string
|
||||||
line2: string
|
kind: BumperTextKind
|
||||||
trigger: BumperTrigger
|
nowLabel: string
|
||||||
weight: number
|
nextLabel: string
|
||||||
}
|
line1: string
|
||||||
|
line2: string
|
||||||
export function addBumperVariant(id: string, templateId: string, name: string) {
|
trigger: BumperTrigger
|
||||||
return apiRequest<CreatedIdResponse>(
|
weight: number
|
||||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants`,
|
}
|
||||||
{ method: 'POST', body: { name } },
|
|
||||||
)
|
export function addBumperVariant(id: string, templateId: string, name: string) {
|
||||||
}
|
return apiRequest<CreatedIdResponse>(
|
||||||
|
`/admin/channels/${id}/bumper/templates/${templateId}/variants`,
|
||||||
export function updateBumperVariant(
|
{ method: 'POST', body: { name } },
|
||||||
id: string,
|
)
|
||||||
templateId: string,
|
}
|
||||||
variantId: string,
|
|
||||||
body: BumperVariantBody,
|
export function updateBumperVariant(
|
||||||
) {
|
id: string,
|
||||||
return apiRequest<void>(
|
templateId: string,
|
||||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
variantId: string,
|
||||||
{ method: 'PUT', body },
|
body: BumperVariantBody,
|
||||||
)
|
) {
|
||||||
}
|
return apiRequest<void>(
|
||||||
|
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||||
export function removeBumperVariant(id: string, templateId: string, variantId: string) {
|
{ method: 'PUT', body },
|
||||||
return apiRequest<void>(
|
)
|
||||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
}
|
||||||
{ method: 'DELETE' },
|
|
||||||
)
|
export function removeBumperVariant(id: string, templateId: string, variantId: string) {
|
||||||
}
|
return apiRequest<void>(
|
||||||
|
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||||
/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */
|
{ method: 'DELETE' },
|
||||||
export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) {
|
)
|
||||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
}
|
||||||
method: 'PUT',
|
|
||||||
body: { imageId },
|
/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */
|
||||||
})
|
export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) {
|
||||||
}
|
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||||
|
method: 'PUT',
|
||||||
export function clearBumperTemplateAudio(id: string, templateId: string) {
|
body: { imageId },
|
||||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, {
|
})
|
||||||
method: 'DELETE',
|
}
|
||||||
})
|
|
||||||
}
|
export function clearBumperTemplateAudio(id: string, templateId: string) {
|
||||||
|
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, {
|
||||||
export function clearBumperTemplateBackground(id: string, templateId: string) {
|
method: 'DELETE',
|
||||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
})
|
||||||
method: 'DELETE',
|
}
|
||||||
})
|
|
||||||
}
|
export function clearBumperTemplateBackground(id: string, templateId: string) {
|
||||||
|
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||||
/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */
|
method: 'DELETE',
|
||||||
export function renderBumperPreviews(id: string, templateId: string) {
|
})
|
||||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
|
}
|
||||||
method: 'POST',
|
|
||||||
})
|
/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */
|
||||||
}
|
export function renderBumperPreviews(id: string, templateId: string) {
|
||||||
|
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
|
||||||
export function bumperPreviewPlaylistUrl(id: string, templateId: string, variantId: string) {
|
method: 'POST',
|
||||||
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/${variantId}/index.m3u8`
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSchedule(id: string, from: Date, to: Date) {
|
export function bumperPreviewPlaylistUrl(id: string, templateId: string, variantId: string) {
|
||||||
const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() })
|
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/${variantId}/index.m3u8`
|
||||||
return apiRequest<ScheduleEntryDto[]>(`/admin/channels/${id}/schedule?${query.toString()}`)
|
}
|
||||||
}
|
|
||||||
|
export function getSchedule(id: string, from: Date, to: Date) {
|
||||||
|
const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() })
|
||||||
|
return apiRequest<ScheduleEntryDto[]>(`/admin/channels/${id}/schedule?${query.toString()}`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ export function BumperBackgroundField({
|
|||||||
: `· ${t('admin.channels.bumperFileDefault')}`}
|
: `· ${t('admin.channels.bumperFileDefault')}`}
|
||||||
</span>
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<span className="text-xs text-muted-foreground">{t('admin.channels.bumperBackgroundHint')}</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.channels.bumperBackgroundHint')}
|
||||||
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{backgroundImageId && (
|
{backgroundImageId && (
|
||||||
<img
|
<img
|
||||||
@@ -57,7 +59,12 @@ export function BumperBackgroundField({
|
|||||||
{t('admin.channels.bumperBackgroundPick')}
|
{t('admin.channels.bumperBackgroundPick')}
|
||||||
</Button>
|
</Button>
|
||||||
{backgroundImageId && (
|
{backgroundImageId && (
|
||||||
<Button size="sm" variant="ghost" disabled={clearBg.isPending} onClick={() => clearBg.mutate()}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={clearBg.isPending}
|
||||||
|
onClick={() => clearBg.mutate()}
|
||||||
|
>
|
||||||
{t('admin.channels.bumperReset')}
|
{t('admin.channels.bumperReset')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,145 +1,153 @@
|
|||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types'
|
import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { addBumperTemplate, updateChannelSettings } from '../api'
|
import { addBumperTemplate, updateChannelSettings } from '../api'
|
||||||
import { BumperTemplateEditor } from './BumperTemplateEditor'
|
import { BumperTemplateEditor } from './BumperTemplateEditor'
|
||||||
import { CollapsibleCard } from './CollapsibleCard'
|
import { CollapsibleCard } from './CollapsibleCard'
|
||||||
|
|
||||||
export function BumperCard({
|
export function BumperCard({
|
||||||
channel,
|
channel,
|
||||||
bare,
|
bare,
|
||||||
onSaved,
|
onSaved,
|
||||||
onError,
|
onError,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
channel: ChannelDto
|
channel: ChannelDto
|
||||||
bare?: boolean
|
bare?: boolean
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
onError: (e: unknown) => void
|
onError: (e: unknown) => void
|
||||||
}>) {
|
}>) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
|
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
|
||||||
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
|
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
|
||||||
|
|
||||||
const setField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
|
const setField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
|
||||||
setBumper((prev) => ({ ...prev, [key]: value }))
|
setBumper((prev) => ({ ...prev, [key]: value }))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBumpersEnabled(channel.bumpersEnabled)
|
setBumpersEnabled(channel.bumpersEnabled)
|
||||||
setBumper(channel.bumper)
|
setBumper(channel.bumper)
|
||||||
}, [channel])
|
}, [channel])
|
||||||
|
|
||||||
// Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные
|
// Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные
|
||||||
// поля берём из канала без изменений (они правятся в своей карточке).
|
// поля берём из канала без изменений (они правятся в своей карточке).
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
updateChannelSettings(channel.id, {
|
updateChannelSettings(channel.id, {
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
isEnabled: channel.isEnabled,
|
isEnabled: channel.isEnabled,
|
||||||
bumpersEnabled,
|
bumpersEnabled,
|
||||||
bumper,
|
bumper,
|
||||||
fillerAssetId: channel.fillerAssetId,
|
fillerAssetId: channel.fillerAssetId,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(t('settings.saved'))
|
toast.success(t('settings.saved'))
|
||||||
onSaved()
|
onSaved()
|
||||||
},
|
},
|
||||||
onError,
|
onError,
|
||||||
})
|
})
|
||||||
|
|
||||||
const addTemplate = useMutation({
|
const addTemplate = useMutation({
|
||||||
mutationFn: () => addBumperTemplate(channel.id, ''),
|
mutationFn: () => addBumperTemplate(channel.id, ''),
|
||||||
onSuccess: onSaved,
|
onSuccess: onSaved,
|
||||||
onError,
|
onError,
|
||||||
})
|
})
|
||||||
|
|
||||||
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
|
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapsibleCard
|
<CollapsibleCard
|
||||||
title={t('admin.channels.bumpers')}
|
title={t('admin.channels.bumpers')}
|
||||||
bare={bare}
|
bare={bare}
|
||||||
contentClassName="flex flex-col gap-4"
|
contentClassName="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<label className="flex items-start gap-2 text-sm">
|
<label className="flex items-start gap-2 text-sm">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
checked={bumpersEnabled}
|
checked={bumpersEnabled}
|
||||||
onChange={(e) => setBumpersEnabled(e.target.checked)}
|
onChange={(e) => setBumpersEnabled(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
{t('admin.channels.bumpersLabel')}
|
{t('admin.channels.bumpersLabel')}
|
||||||
<span className="block text-xs text-muted-foreground">
|
<span className="block text-xs text-muted-foreground">
|
||||||
{t('admin.channels.bumpersHint')}
|
{t('admin.channels.bumpersHint')}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Общие настройки. Как часто ставить заставку — не здесь: это условие элемента стыка. */}
|
{/* Общие настройки. Как часто ставить заставку — не здесь: это условие элемента стыка. */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.channels.bumperSelection')}</Label>
|
<Label>{t('admin.channels.bumperSelection')}</Label>
|
||||||
<Select value={bumper.selection} onValueChange={(v) => setField('selection', v as BumperSelection)}>
|
<Select
|
||||||
<SelectTrigger>
|
value={bumper.selection}
|
||||||
<SelectValue />
|
onValueChange={(v) => setField('selection', v as BumperSelection)}
|
||||||
</SelectTrigger>
|
>
|
||||||
<SelectContent>
|
<SelectTrigger>
|
||||||
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
|
<SelectValue />
|
||||||
<SelectItem value="WeightedRandom">
|
</SelectTrigger>
|
||||||
{t('admin.channels.bumperSelectionWeighted')}
|
<SelectContent>
|
||||||
</SelectItem>
|
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
|
||||||
<SelectItem value="AlwaysFirst">
|
<SelectItem value="WeightedRandom">
|
||||||
{t('admin.channels.bumperSelectionAlwaysFirst')}
|
{t('admin.channels.bumperSelectionWeighted')}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
<SelectItem value="AlwaysFirst">
|
||||||
</Select>
|
{t('admin.channels.bumperSelectionAlwaysFirst')}
|
||||||
</div>
|
</SelectItem>
|
||||||
<div className="flex flex-col gap-1.5">
|
</SelectContent>
|
||||||
<Label>{t('admin.channels.bumperFont')}</Label>
|
</Select>
|
||||||
<Select value={bumper.font} onValueChange={(v) => setField('font', v as BumperFont)}>
|
</div>
|
||||||
<SelectTrigger>
|
<div className="flex flex-col gap-1.5">
|
||||||
<SelectValue />
|
<Label>{t('admin.channels.bumperFont')}</Label>
|
||||||
</SelectTrigger>
|
<Select value={bumper.font} onValueChange={(v) => setField('font', v as BumperFont)}>
|
||||||
<SelectContent>
|
<SelectTrigger>
|
||||||
<SelectItem value="Sans">{t('admin.channels.bumperFontSans')}</SelectItem>
|
<SelectValue />
|
||||||
<SelectItem value="Serif">{t('admin.channels.bumperFontSerif')}</SelectItem>
|
</SelectTrigger>
|
||||||
</SelectContent>
|
<SelectContent>
|
||||||
</Select>
|
<SelectItem value="Sans">{t('admin.channels.bumperFontSans')}</SelectItem>
|
||||||
</div>
|
<SelectItem value="Serif">{t('admin.channels.bumperFontSerif')}</SelectItem>
|
||||||
</div>
|
</SelectContent>
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperConditionsHint')}</p>
|
</Select>
|
||||||
<div className="flex justify-end">
|
</div>
|
||||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
</div>
|
||||||
{t('common.save')}
|
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperConditionsHint')}</p>
|
||||||
</Button>
|
<div className="flex justify-end">
|
||||||
</div>
|
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||||
|
{t('common.save')}
|
||||||
{/* Блоки заставок */}
|
</Button>
|
||||||
<div className="border-t border-border pt-4">
|
</div>
|
||||||
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
|
{/* Блоки заставок */}
|
||||||
</div>
|
<div className="border-t border-border pt-4">
|
||||||
<div className="flex flex-col gap-3">
|
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
|
||||||
{templates.map((template) => (
|
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
|
||||||
<BumperTemplateEditor
|
</div>
|
||||||
key={template.id}
|
<div className="flex flex-col gap-3">
|
||||||
channelId={channel.id}
|
{templates.map((template) => (
|
||||||
template={template}
|
<BumperTemplateEditor
|
||||||
onChanged={onSaved}
|
key={template.id}
|
||||||
onError={onError}
|
channelId={channel.id}
|
||||||
/>
|
template={template}
|
||||||
))}
|
onChanged={onSaved}
|
||||||
</div>
|
onError={onError}
|
||||||
<div className="flex justify-center">
|
/>
|
||||||
<Button size="sm" variant="outline" disabled={addTemplate.isPending} onClick={() => addTemplate.mutate()}>
|
))}
|
||||||
{t('admin.channels.bumperAddTemplate')}
|
</div>
|
||||||
</Button>
|
<div className="flex justify-center">
|
||||||
</div>
|
<Button
|
||||||
</CollapsibleCard>
|
size="sm"
|
||||||
)
|
variant="outline"
|
||||||
}
|
disabled={addTemplate.isPending}
|
||||||
|
onClick={() => addTemplate.mutate()}
|
||||||
|
>
|
||||||
|
{t('admin.channels.bumperAddTemplate')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CollapsibleCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,12 +33,19 @@ export function BumperPreviewPlayer({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button size="sm" variant="outline" disabled={render.isPending} onClick={() => render.mutate()}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={render.isPending}
|
||||||
|
onClick={() => render.mutate()}
|
||||||
|
>
|
||||||
{render.isPending
|
{render.isPending
|
||||||
? t('admin.channels.bumperPreviewRendering')
|
? t('admin.channels.bumperPreviewRendering')
|
||||||
: t('admin.channels.bumperPreview')}
|
: t('admin.channels.bumperPreview')}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-xs text-muted-foreground">{t('admin.channels.bumperPreviewHint')}</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.channels.bumperPreviewHint')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{ready && (
|
{ready && (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
@@ -47,7 +54,9 @@ export function BumperPreviewPlayer({
|
|||||||
.map((v) => (
|
.map((v) => (
|
||||||
<div key={v.id} className="flex flex-col gap-1">
|
<div key={v.id} className="flex flex-col gap-1">
|
||||||
<span className="text-xs text-muted-foreground">{v.name}</span>
|
<span className="text-xs text-muted-foreground">{v.name}</span>
|
||||||
<HlsVideo src={`${bumperPreviewPlaylistUrl(channelId, templateId, v.id)}?t=${bust}`} />
|
<HlsVideo
|
||||||
|
src={`${bumperPreviewPlaylistUrl(channelId, templateId, v.id)}?t=${bust}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ export function BumperTemplateEditor({
|
|||||||
}, [template])
|
}, [template])
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: () => updateBumperTemplate(channelId, template.id, { name: name.trim(), ...colors }),
|
mutationFn: () =>
|
||||||
|
updateBumperTemplate(channelId, template.id, { name: name.trim(), ...colors }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(t('settings.saved'))
|
toast.success(t('settings.saved'))
|
||||||
onChanged()
|
onChanged()
|
||||||
@@ -163,7 +164,9 @@ export function BumperTemplateEditor({
|
|||||||
{/* Подблоки (текст-варианты) */}
|
{/* Подблоки (текст-варианты) */}
|
||||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||||
<p className="text-sm font-medium">{t('admin.channels.bumperVariants')}</p>
|
<p className="text-sm font-medium">{t('admin.channels.bumperVariants')}</p>
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperVariantsHint')}</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.channels.bumperVariantsHint')}
|
||||||
|
</p>
|
||||||
{[...template.variants]
|
{[...template.variants]
|
||||||
.sort((a, b) => a.position - b.position)
|
.sort((a, b) => a.position - b.position)
|
||||||
.map((variant) => (
|
.map((variant) => (
|
||||||
|
|||||||
@@ -142,11 +142,19 @@ export function BumperVariantEditor({
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.channels.bumperLine1')}</Label>
|
<Label>{t('admin.channels.bumperLine1')}</Label>
|
||||||
<Input value={form.line1} maxLength={120} onChange={(e) => set('line1', e.target.value)} />
|
<Input
|
||||||
|
value={form.line1}
|
||||||
|
maxLength={120}
|
||||||
|
onChange={(e) => set('line1', e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.channels.bumperLine2')}</Label>
|
<Label>{t('admin.channels.bumperLine2')}</Label>
|
||||||
<Input value={form.line2} maxLength={120} onChange={(e) => set('line2', e.target.value)} />
|
<Input
|
||||||
|
value={form.line2}
|
||||||
|
maxLength={120}
|
||||||
|
onChange={(e) => set('line2', e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -154,7 +162,12 @@ export function BumperVariantEditor({
|
|||||||
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
{canRemove && (
|
{canRemove && (
|
||||||
<Button size="sm" variant="ghost" disabled={remove.isPending} onClick={() => remove.mutate()}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={remove.isPending}
|
||||||
|
onClick={() => remove.mutate()}
|
||||||
|
>
|
||||||
{t('common.delete')}
|
{t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,109 +1,105 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { qk } from '@/shared/api/query-keys'
|
import { qk } from '@/shared/api/query-keys'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { EntryTraceDto } from '@/shared/api/types'
|
import type { EntryTraceDto } from '@/shared/api/types'
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||||
Dialog,
|
import { getEntryTrace } from '../api'
|
||||||
DialogContent,
|
import { formatChannelTime } from '../lib/format'
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
/**
|
||||||
} from '@/shared/ui/dialog'
|
* «Почему это здесь» (см. 6.5): цепочка происхождения записи. Трейс пишется в момент генерации —
|
||||||
import { getEntryTrace } from '../api'
|
* восстановить его потом нельзя, поэтому у старых записей часть строк будет пустой.
|
||||||
import { formatChannelTime } from '../lib/format'
|
*/
|
||||||
|
export function EntryTraceDialog({
|
||||||
/**
|
entryId,
|
||||||
* «Почему это здесь» (см. 6.5): цепочка происхождения записи. Трейс пишется в момент генерации —
|
utcOffsetMinutes,
|
||||||
* восстановить его потом нельзя, поэтому у старых записей часть строк будет пустой.
|
onClose,
|
||||||
*/
|
}: Readonly<{
|
||||||
export function EntryTraceDialog({
|
entryId: string
|
||||||
entryId,
|
utcOffsetMinutes: number
|
||||||
utcOffsetMinutes,
|
onClose: () => void
|
||||||
onClose,
|
}>) {
|
||||||
}: Readonly<{
|
const { t } = useTranslation()
|
||||||
entryId: string
|
const { data } = useQuery({
|
||||||
utcOffsetMinutes: number
|
queryKey: qk.entries.trace(entryId),
|
||||||
onClose: () => void
|
queryFn: () => getEntryTrace(entryId),
|
||||||
}>) {
|
})
|
||||||
const { t } = useTranslation()
|
|
||||||
const { data } = useQuery({
|
return (
|
||||||
queryKey: qk.entries.trace(entryId),
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
queryFn: () => getEntryTrace(entryId),
|
<DialogContent>
|
||||||
})
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
return (
|
{data
|
||||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
? `${data.showName ?? '—'} · ${formatChannelTime(data.startsAtUtc, utcOffsetMinutes)}`
|
||||||
<DialogContent>
|
: t('common.loading')}
|
||||||
<DialogHeader>
|
</DialogTitle>
|
||||||
<DialogTitle>
|
</DialogHeader>
|
||||||
{data
|
|
||||||
? `${data.showName ?? '—'} · ${formatChannelTime(data.startsAtUtc, utcOffsetMinutes)}`
|
{data && (
|
||||||
: t('common.loading')}
|
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1.5 text-sm">
|
||||||
</DialogTitle>
|
<Row label={t('admin.channels.traceLayer')}>{layerSummary(data, t)}</Row>
|
||||||
</DialogHeader>
|
<Row label={t('admin.channels.traceSlot')}>{slotSummary(data, t)}</Row>
|
||||||
|
<Row label={t('admin.channels.traceGroup')}>{groupSummary(data)}</Row>
|
||||||
{data && (
|
<Row label={t('admin.channels.traceCollection')}>{data.collectionName}</Row>
|
||||||
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1.5 text-sm">
|
<Row label={t('admin.channels.traceStrategy')}>{strategySummary(data, t)}</Row>
|
||||||
<Row label={t('admin.channels.traceLayer')}>{layerSummary(data, t)}</Row>
|
<Row label={t('admin.channels.traceJunction')}>{data.junctionName}</Row>
|
||||||
<Row label={t('admin.channels.traceSlot')}>{slotSummary(data, t)}</Row>
|
</dl>
|
||||||
<Row label={t('admin.channels.traceGroup')}>{groupSummary(data)}</Row>
|
)}
|
||||||
<Row label={t('admin.channels.traceCollection')}>{data.collectionName}</Row>
|
</DialogContent>
|
||||||
<Row label={t('admin.channels.traceStrategy')}>{strategySummary(data, t)}</Row>
|
</Dialog>
|
||||||
<Row label={t('admin.channels.traceJunction')}>{data.junctionName}</Row>
|
)
|
||||||
</dl>
|
}
|
||||||
)}
|
|
||||||
</DialogContent>
|
type Translate = ReturnType<typeof useTranslation>['t']
|
||||||
</Dialog>
|
|
||||||
)
|
/** Склейка непустых частей строки трейса; пусто — значит строка не заполнена (покажем «—»). */
|
||||||
}
|
const joinParts = (parts: (string | null | undefined)[]) =>
|
||||||
|
parts.filter(Boolean).join(' · ') || null
|
||||||
type Translate = ReturnType<typeof useTranslation>['t']
|
|
||||||
|
function layerSummary(data: EntryTraceDto, t: Translate) {
|
||||||
/** Склейка непустых частей строки трейса; пусто — значит строка не заполнена (покажем «—»). */
|
if (!data.layerName) return null
|
||||||
const joinParts = (parts: (string | null | undefined)[]) => parts.filter(Boolean).join(' · ') || null
|
const priority =
|
||||||
|
data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''
|
||||||
function layerSummary(data: EntryTraceDto, t: Translate) {
|
return `${data.layerName}${priority}`
|
||||||
if (!data.layerName) return null
|
}
|
||||||
const priority =
|
|
||||||
data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''
|
function slotSummary(data: EntryTraceDto, t: Translate) {
|
||||||
return `${data.layerName}${priority}`
|
if (!data.slotTitle) return null
|
||||||
}
|
return joinParts([
|
||||||
|
data.slotTitle,
|
||||||
function slotSummary(data: EntryTraceDto, t: Translate) {
|
data.slotWeekday === null
|
||||||
if (!data.slotTitle) return null
|
? t('admin.channels.everyDay')
|
||||||
return joinParts([
|
: t(`admin.channels.weekdays.${data.slotWeekday}`),
|
||||||
data.slotTitle,
|
data.slotTargetStart?.slice(0, 5),
|
||||||
data.slotWeekday === null
|
data.slotDurationMinutes ? `${data.slotDurationMinutes} мин` : null,
|
||||||
? t('admin.channels.everyDay')
|
data.driftMinutes !== 0 ? t('admin.channels.traceDrift', { minutes: data.driftMinutes }) : null,
|
||||||
: t(`admin.channels.weekdays.${data.slotWeekday}`),
|
data.snapped ? t('admin.channels.traceSnapped') : null,
|
||||||
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 groupSummary(data: EntryTraceDto) {
|
}
|
||||||
if (!data.groupName) return null
|
|
||||||
const count = data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''
|
function strategySummary(data: EntryTraceDto, t: Translate) {
|
||||||
return `${data.groupName}${count}`
|
if (!data.strategy) return null
|
||||||
}
|
return joinParts([
|
||||||
|
t(`admin.channels.strategies.${data.strategy}`),
|
||||||
function strategySummary(data: EntryTraceDto, t: Translate) {
|
data.cooldownDays ? t('admin.channels.traceCooldown', { days: data.cooldownDays }) : null,
|
||||||
if (!data.strategy) return null
|
data.candidatesAfterCooldown !== null
|
||||||
return joinParts([
|
? t('admin.channels.traceCandidates', { count: data.candidatesAfterCooldown })
|
||||||
t(`admin.channels.strategies.${data.strategy}`),
|
: null,
|
||||||
data.cooldownDays ? t('admin.channels.traceCooldown', { days: data.cooldownDays }) : null,
|
])
|
||||||
data.candidatesAfterCooldown !== null
|
}
|
||||||
? t('admin.channels.traceCandidates', { count: data.candidatesAfterCooldown })
|
|
||||||
: null,
|
function Row({ label, children }: Readonly<{ label: string; children: React.ReactNode }>) {
|
||||||
])
|
return (
|
||||||
}
|
<>
|
||||||
|
<dt className="text-muted-foreground">{label}</dt>
|
||||||
function Row({ label, children }: Readonly<{ label: string; children: React.ReactNode }>) {
|
<dd>{children || '—'}</dd>
|
||||||
return (
|
</>
|
||||||
<>
|
)
|
||||||
<dt className="text-muted-foreground">{label}</dt>
|
}
|
||||||
<dd>{children || '—'}</dd>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,13 +10,7 @@ import type {
|
|||||||
} from '@/shared/api/types'
|
} from '@/shared/api/types'
|
||||||
import { qk } from '@/shared/api/query-keys'
|
import { qk } from '@/shared/api/query-keys'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import {
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/shared/ui/dialog'
|
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { removeJunctionElement, updateJunctionElement, type JunctionElementBody } from '../api'
|
import { removeJunctionElement, updateJunctionElement, type JunctionElementBody } from '../api'
|
||||||
|
|||||||
@@ -1,337 +1,339 @@
|
|||||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||||
import { ChevronRight, Plus, Trash2 } from 'lucide-react'
|
import { ChevronRight, Plus, Trash2 } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { listGroups } from '@/features/admin/groups/api'
|
import { listGroups } from '@/features/admin/groups/api'
|
||||||
import { formatClock } from '@/features/admin/interstitials/format'
|
import { formatClock } from '@/features/admin/interstitials/format'
|
||||||
import type {
|
import type {
|
||||||
ChannelDto,
|
ChannelDto,
|
||||||
GroupSummaryDto,
|
GroupSummaryDto,
|
||||||
JunctionElementDto,
|
JunctionElementDto,
|
||||||
JunctionElementKind,
|
JunctionElementKind,
|
||||||
JunctionTemplateDto,
|
JunctionTemplateDto,
|
||||||
ScheduleTemplateDto,
|
ScheduleTemplateDto,
|
||||||
} from '@/shared/api/types'
|
} from '@/shared/api/types'
|
||||||
import { qk } from '@/shared/api/query-keys'
|
import { qk } from '@/shared/api/query-keys'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { cn } from '@/shared/lib/cn'
|
import { cn } from '@/shared/lib/cn'
|
||||||
import {
|
import {
|
||||||
addJunctionElement,
|
addJunctionElement,
|
||||||
createJunction,
|
createJunction,
|
||||||
deleteJunction,
|
deleteJunction,
|
||||||
listJunctions,
|
listJunctions,
|
||||||
renameJunction,
|
renameJunction,
|
||||||
reorderJunction,
|
reorderJunction,
|
||||||
updateTemplate,
|
updateTemplate,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import { CollapsibleCard } from './CollapsibleCard'
|
import { CollapsibleCard } from './CollapsibleCard'
|
||||||
import { JunctionElementDialog } from './JunctionElementDialog'
|
import { JunctionElementDialog } from './JunctionElementDialog'
|
||||||
|
|
||||||
/** Заставка без загруженного звука синтезируется фиксированной длины — та же цифра, что на сервере. */
|
/** Заставка без загруженного звука синтезируется фиксированной длины — та же цифра, что на сервере. */
|
||||||
const DEFAULT_BUMPER_SECONDS = 8
|
const DEFAULT_BUMPER_SECONDS = 8
|
||||||
|
|
||||||
const KIND_COLORS: Record<JunctionElementKind, string> = {
|
const KIND_COLORS: Record<JunctionElementKind, string> = {
|
||||||
Ad: 'bg-amber-500/70',
|
Ad: 'bg-amber-500/70',
|
||||||
Promo: 'bg-sky-500/70',
|
Promo: 'bg-sky-500/70',
|
||||||
Bumper: 'bg-violet-500/70',
|
Bumper: 'bg-violet-500/70',
|
||||||
Filler: 'bg-muted-foreground/40',
|
Filler: 'bg-muted-foreground/40',
|
||||||
}
|
}
|
||||||
|
|
||||||
type Translate = ReturnType<typeof useTranslation>['t']
|
type Translate = ReturnType<typeof useTranslation>['t']
|
||||||
|
|
||||||
/** Что уточняет подпись врезки: имя блока заставки либо объём («×3» / «×5 мин»). */
|
/** Что уточняет подпись врезки: имя блока заставки либо объём («×3» / «×5 мин»). */
|
||||||
function elementSuffix(element: JunctionElementDto, t: Translate) {
|
function elementSuffix(element: JunctionElementDto, t: Translate) {
|
||||||
if (element.kind === 'Bumper')
|
if (element.kind === 'Bumper')
|
||||||
return element.bumperTemplateName ? ` · ${element.bumperTemplateName}` : ''
|
return element.bumperTemplateName ? ` · ${element.bumperTemplateName}` : ''
|
||||||
|
|
||||||
const units = element.amountMode === 'Duration' ? t('admin.channels.minutesShort') : ''
|
const units = element.amountMode === 'Duration' ? t('admin.channels.minutesShort') : ''
|
||||||
return ` ×${element.amountValue}${units}`
|
return ` ×${element.amountValue}${units}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Подсказка сегмента линейки: вид врезки и её оценочная длительность. */
|
/** Подсказка сегмента линейки: вид врезки и её оценочная длительность. */
|
||||||
function elementTitle(element: JunctionElementDto, seconds: number, t: Translate) {
|
function elementTitle(element: JunctionElementDto, seconds: number, t: Translate) {
|
||||||
const kind = t(`admin.channels.junctionKinds.${element.kind}`)
|
const kind = t(`admin.channels.junctionKinds.${element.kind}`)
|
||||||
return `${kind} · ${formatClock(seconds)}`
|
return `${kind} · ${formatClock(seconds)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Прикидка длины врезки для линейки. По длительности — точная, по количеству — среднее единицы
|
* Прикидка длины врезки для линейки. По длительности — точная, по количеству — среднее единицы
|
||||||
* группы: в смешанной группе «одна единица» это то ли ролик, то ли блок, поэтому число заведомо
|
* группы: в смешанной группе «одна единица» это то ли ролик, то ли блок, поэтому число заведомо
|
||||||
* приблизительное и помечается как оценка.
|
* приблизительное и помечается как оценка.
|
||||||
*/
|
*/
|
||||||
function estimateSeconds(
|
function estimateSeconds(
|
||||||
element: JunctionElementDto,
|
element: JunctionElementDto,
|
||||||
groups: GroupSummaryDto[] | undefined,
|
groups: GroupSummaryDto[] | undefined,
|
||||||
channel: ChannelDto,
|
channel: ChannelDto,
|
||||||
): { seconds: number; exact: boolean } {
|
): { seconds: number; exact: boolean } {
|
||||||
if (element.kind === 'Bumper') {
|
if (element.kind === 'Bumper') {
|
||||||
const template = channel.bumperTemplates.find((b) => b.id === element.bumperTemplateId)
|
const template = channel.bumperTemplates.find((b) => b.id === element.bumperTemplateId)
|
||||||
return { seconds: template?.audioDurationSeconds ?? DEFAULT_BUMPER_SECONDS, exact: true }
|
return { seconds: template?.audioDurationSeconds ?? DEFAULT_BUMPER_SECONDS, exact: true }
|
||||||
}
|
}
|
||||||
if (element.amountMode === 'Duration') return { seconds: element.amountValue * 60, exact: true }
|
if (element.amountMode === 'Duration') return { seconds: element.amountValue * 60, exact: true }
|
||||||
|
|
||||||
const group = groups?.find((g) => g.id === element.groupId)
|
const group = groups?.find((g) => g.id === element.groupId)
|
||||||
if (!group || group.unitCount === 0) return { seconds: 0, exact: false }
|
if (!group || group.unitCount === 0) return { seconds: 0, exact: false }
|
||||||
return {
|
return {
|
||||||
seconds: (element.amountValue * group.totalDurationSeconds) / group.unitCount,
|
seconds: (element.amountValue * group.totalDurationSeconds) / group.unitCount,
|
||||||
exact: false,
|
exact: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function JunctionsCard({
|
export function JunctionsCard({
|
||||||
channel,
|
channel,
|
||||||
template,
|
template,
|
||||||
bare,
|
bare,
|
||||||
onChanged,
|
onChanged,
|
||||||
onError,
|
onError,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
channel: ChannelDto
|
channel: ChannelDto
|
||||||
template: ScheduleTemplateDto | undefined
|
template: ScheduleTemplateDto | undefined
|
||||||
bare?: boolean
|
bare?: boolean
|
||||||
onChanged: () => void
|
onChanged: () => void
|
||||||
onError: (error: unknown) => void
|
onError: (error: unknown) => void
|
||||||
}>) {
|
}>) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
|
|
||||||
const { data: junctions } = useQuery({
|
const { data: junctions } = useQuery({
|
||||||
queryKey: qk.channels.junctions(channel.id),
|
queryKey: qk.channels.junctions(channel.id),
|
||||||
queryFn: () => listJunctions(channel.id),
|
queryFn: () => listJunctions(channel.id),
|
||||||
})
|
})
|
||||||
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
|
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: () => createJunction(channel.id, newName.trim()),
|
mutationFn: () => createJunction(channel.id, newName.trim()),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setNewName('')
|
setNewName('')
|
||||||
onChanged()
|
onChanged()
|
||||||
},
|
},
|
||||||
onError,
|
onError,
|
||||||
})
|
})
|
||||||
|
|
||||||
const defaultMutation = useMutation({
|
const defaultMutation = useMutation({
|
||||||
mutationFn: (junctionId: string | null) =>
|
mutationFn: (junctionId: string | null) =>
|
||||||
updateTemplate(template!.id, {
|
updateTemplate(template!.id, {
|
||||||
name: template!.name,
|
name: template!.name,
|
||||||
fallbackGroupId: template!.fallbackGroupId,
|
fallbackGroupId: template!.fallbackGroupId,
|
||||||
defaultJunctionId: junctionId,
|
defaultJunctionId: junctionId,
|
||||||
rules: template!.rules,
|
rules: template!.rules,
|
||||||
}),
|
}),
|
||||||
onSuccess: onChanged,
|
onSuccess: onChanged,
|
||||||
onError,
|
onError,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapsibleCard title={t('admin.channels.junctions')} bare={bare}>
|
<CollapsibleCard title={t('admin.channels.junctions')} bare={bare}>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.channels.junctionsHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('admin.channels.junctionsHint')}</p>
|
||||||
|
|
||||||
{template && (
|
{template && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.channels.defaultJunction')}</Label>
|
<Label>{t('admin.channels.defaultJunction')}</Label>
|
||||||
<select
|
<select
|
||||||
className="h-9 max-w-xs rounded-md border border-border bg-transparent px-2 text-sm"
|
className="h-9 max-w-xs rounded-md border border-border bg-transparent px-2 text-sm"
|
||||||
value={template.defaultJunctionId ?? ''}
|
value={template.defaultJunctionId ?? ''}
|
||||||
onChange={(e) => defaultMutation.mutate(e.target.value || null)}
|
onChange={(e) => defaultMutation.mutate(e.target.value || null)}
|
||||||
>
|
>
|
||||||
<option value="">{t('admin.channels.noJunction')}</option>
|
<option value="">{t('admin.channels.noJunction')}</option>
|
||||||
{(junctions ?? []).map((junction) => (
|
{(junctions ?? []).map((junction) => (
|
||||||
<option key={junction.id} value={junction.id}>
|
<option key={junction.id} value={junction.id}>
|
||||||
{junction.name}
|
{junction.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(junctions ?? []).map((junction) => (
|
{(junctions ?? []).map((junction) => (
|
||||||
<JunctionChain
|
<JunctionChain
|
||||||
key={junction.id}
|
key={junction.id}
|
||||||
junction={junction}
|
junction={junction}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
groups={groups}
|
groups={groups}
|
||||||
onChanged={onChanged}
|
onChanged={onChanged}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="flex max-w-md gap-2">
|
<div className="flex max-w-md gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('admin.channels.newJunctionName')}
|
placeholder={t('admin.channels.newJunctionName')}
|
||||||
value={newName}
|
value={newName}
|
||||||
maxLength={128}
|
maxLength={128}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={!newName.trim() || createMutation.isPending}
|
disabled={!newName.trim() || createMutation.isPending}
|
||||||
onClick={() => createMutation.mutate()}
|
onClick={() => createMutation.mutate()}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" /> {t('common.create')}
|
<Plus className="h-4 w-4" /> {t('common.create')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleCard>
|
</CollapsibleCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ADDABLE: JunctionElementKind[] = ['Ad', 'Promo', 'Bumper', 'Filler']
|
const ADDABLE: JunctionElementKind[] = ['Ad', 'Promo', 'Bumper', 'Filler']
|
||||||
|
|
||||||
function JunctionChain({
|
function JunctionChain({
|
||||||
junction,
|
junction,
|
||||||
channel,
|
channel,
|
||||||
groups,
|
groups,
|
||||||
onChanged,
|
onChanged,
|
||||||
onError,
|
onError,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
junction: JunctionTemplateDto
|
junction: JunctionTemplateDto
|
||||||
channel: ChannelDto
|
channel: ChannelDto
|
||||||
groups: GroupSummaryDto[] | undefined
|
groups: GroupSummaryDto[] | undefined
|
||||||
onChanged: () => void
|
onChanged: () => void
|
||||||
onError: (error: unknown) => void
|
onError: (error: unknown) => void
|
||||||
}>) {
|
}>) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [name, setName] = useState<string | null>(null)
|
const [name, setName] = useState<string | null>(null)
|
||||||
const [dragged, setDragged] = useState<string | null>(null)
|
const [dragged, setDragged] = useState<string | null>(null)
|
||||||
const [editing, setEditing] = useState<JunctionElementDto | null>(null)
|
const [editing, setEditing] = useState<JunctionElementDto | null>(null)
|
||||||
|
|
||||||
const renameMutation = useMutation({
|
const renameMutation = useMutation({
|
||||||
mutationFn: (value: string) => renameJunction(junction.id, value),
|
mutationFn: (value: string) => renameJunction(junction.id, value),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setName(null)
|
setName(null)
|
||||||
onChanged()
|
onChanged()
|
||||||
},
|
},
|
||||||
onError,
|
onError,
|
||||||
})
|
})
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: () => deleteJunction(junction.id),
|
mutationFn: () => deleteJunction(junction.id),
|
||||||
onSuccess: onChanged,
|
onSuccess: onChanged,
|
||||||
onError,
|
onError,
|
||||||
})
|
})
|
||||||
const addMutation = useMutation({
|
const addMutation = useMutation({
|
||||||
mutationFn: (kind: JunctionElementKind) => addJunctionElement(junction.id, kind),
|
mutationFn: (kind: JunctionElementKind) => addJunctionElement(junction.id, kind),
|
||||||
onSuccess: onChanged,
|
onSuccess: onChanged,
|
||||||
onError,
|
onError,
|
||||||
})
|
})
|
||||||
const reorderMutation = useMutation({
|
const reorderMutation = useMutation({
|
||||||
mutationFn: (order: string[]) => reorderJunction(junction.id, order),
|
mutationFn: (order: string[]) => reorderJunction(junction.id, order),
|
||||||
onSuccess: onChanged,
|
onSuccess: onChanged,
|
||||||
onError,
|
onError,
|
||||||
})
|
})
|
||||||
|
|
||||||
const elements = [...junction.elements].sort((a, b) => a.position - b.position)
|
const elements = [...junction.elements].sort((a, b) => a.position - b.position)
|
||||||
const estimates = elements.map((element) => estimateSeconds(element, groups, channel))
|
const estimates = elements.map((element) => estimateSeconds(element, groups, channel))
|
||||||
const total = estimates.reduce((sum, e) => sum + e.seconds, 0)
|
const total = estimates.reduce((sum, e) => sum + e.seconds, 0)
|
||||||
const exact = estimates.every((e) => e.exact)
|
const exact = estimates.every((e) => e.exact)
|
||||||
|
|
||||||
const dropOn = (targetId: string) => {
|
const dropOn = (targetId: string) => {
|
||||||
if (!dragged || dragged === targetId) return
|
if (!dragged || dragged === targetId) return
|
||||||
const order = elements.map((e) => e.id).filter((id) => id !== dragged)
|
const order = elements.map((e) => e.id).filter((id) => id !== dragged)
|
||||||
order.splice(order.indexOf(targetId), 0, dragged)
|
order.splice(order.indexOf(targetId), 0, dragged)
|
||||||
setDragged(null)
|
setDragged(null)
|
||||||
reorderMutation.mutate(order)
|
reorderMutation.mutate(order)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="crt-panel flex flex-col gap-2 rounded-md p-3">
|
<div className="crt-panel flex flex-col gap-2 rounded-md p-3">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
className="h-8 max-w-56"
|
className="h-8 max-w-56"
|
||||||
value={name ?? junction.name}
|
value={name ?? junction.name}
|
||||||
maxLength={128}
|
maxLength={128}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
onBlur={() =>
|
onBlur={() =>
|
||||||
name !== null && name.trim() && name !== junction.name
|
name !== null && name.trim() && name !== junction.name
|
||||||
? renameMutation.mutate(name.trim())
|
? renameMutation.mutate(name.trim())
|
||||||
: setName(null)
|
: setName(null)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
||||||
value=""
|
value=""
|
||||||
onChange={(e) => e.target.value && addMutation.mutate(e.target.value as JunctionElementKind)}
|
onChange={(e) =>
|
||||||
>
|
e.target.value && addMutation.mutate(e.target.value as JunctionElementKind)
|
||||||
<option value="">{t('admin.channels.addJunctionElement')}</option>
|
}
|
||||||
{ADDABLE.map((kind) => (
|
>
|
||||||
<option key={kind} value={kind}>
|
<option value="">{t('admin.channels.addJunctionElement')}</option>
|
||||||
{t(`admin.channels.junctionKinds.${kind}`)}
|
{ADDABLE.map((kind) => (
|
||||||
</option>
|
<option key={kind} value={kind}>
|
||||||
))}
|
{t(`admin.channels.junctionKinds.${kind}`)}
|
||||||
</select>
|
</option>
|
||||||
<span className="ml-auto text-xs text-muted-foreground">
|
))}
|
||||||
{exact ? '' : '≈ '}
|
</select>
|
||||||
{formatClock(total)}
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
</span>
|
{exact ? '' : '≈ '}
|
||||||
<Button size="sm" variant="ghost" onClick={() => deleteMutation.mutate()}>
|
{formatClock(total)}
|
||||||
<Trash2 className="h-4 w-4" />
|
</span>
|
||||||
</Button>
|
<Button size="sm" variant="ghost" onClick={() => deleteMutation.mutate()}>
|
||||||
</div>
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
{/* Цепочка: что играет между концом одной программы и началом следующей. */}
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-1 text-xs">
|
|
||||||
<span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground">
|
{/* Цепочка: что играет между концом одной программы и началом следующей. */}
|
||||||
{t('admin.channels.junctionFrom')}
|
<div className="flex flex-wrap items-center gap-1 text-xs">
|
||||||
</span>
|
<span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground">
|
||||||
{elements.length === 0 && (
|
{t('admin.channels.junctionFrom')}
|
||||||
<>
|
</span>
|
||||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
{elements.length === 0 && (
|
||||||
<span className="text-muted-foreground">{t('admin.channels.junctionEmpty')}</span>
|
<>
|
||||||
</>
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||||
)}
|
<span className="text-muted-foreground">{t('admin.channels.junctionEmpty')}</span>
|
||||||
{elements.map((element) => (
|
</>
|
||||||
<span key={element.id} className="flex items-center gap-1">
|
)}
|
||||||
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
|
{elements.map((element) => (
|
||||||
<button
|
<span key={element.id} className="flex items-center gap-1">
|
||||||
type="button"
|
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||||
draggable
|
<button
|
||||||
onDragStart={() => setDragged(element.id)}
|
type="button"
|
||||||
onDragOver={(e) => e.preventDefault()}
|
draggable
|
||||||
onDrop={() => dropOn(element.id)}
|
onDragStart={() => setDragged(element.id)}
|
||||||
onClick={() => setEditing(element)}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
className={cn(
|
onDrop={() => dropOn(element.id)}
|
||||||
'cursor-grab rounded border border-border px-2 py-1 hover:border-primary',
|
onClick={() => setEditing(element)}
|
||||||
element.isRequired && 'border-primary/70',
|
className={cn(
|
||||||
)}
|
'cursor-grab rounded border border-border px-2 py-1 hover:border-primary',
|
||||||
>
|
element.isRequired && 'border-primary/70',
|
||||||
{t(`admin.channels.junctionKinds.${element.kind}`)}
|
)}
|
||||||
{elementSuffix(element, t)}
|
>
|
||||||
{element.isRequired && ' *'}
|
{t(`admin.channels.junctionKinds.${element.kind}`)}
|
||||||
</button>
|
{elementSuffix(element, t)}
|
||||||
</span>
|
{element.isRequired && ' *'}
|
||||||
))}
|
</button>
|
||||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
</span>
|
||||||
<span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground">
|
))}
|
||||||
{t('admin.channels.junctionTo')}
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||||
</span>
|
<span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground">
|
||||||
</div>
|
{t('admin.channels.junctionTo')}
|
||||||
|
</span>
|
||||||
{/* Линейка: доля каждой врезки в стыке. Пустые (без группы) в неё не попадают. */}
|
</div>
|
||||||
{total > 0 && (
|
|
||||||
<div className="flex h-2 overflow-hidden rounded-full bg-muted/40">
|
{/* Линейка: доля каждой врезки в стыке. Пустые (без группы) в неё не попадают. */}
|
||||||
{elements.map((element, index) => (
|
{total > 0 && (
|
||||||
<div
|
<div className="flex h-2 overflow-hidden rounded-full bg-muted/40">
|
||||||
key={element.id}
|
{elements.map((element, index) => (
|
||||||
className={KIND_COLORS[element.kind]}
|
<div
|
||||||
style={{ width: `${(estimates[index].seconds / total) * 100}%` }}
|
key={element.id}
|
||||||
title={elementTitle(element, estimates[index].seconds, t)}
|
className={KIND_COLORS[element.kind]}
|
||||||
/>
|
style={{ width: `${(estimates[index].seconds / total) * 100}%` }}
|
||||||
))}
|
title={elementTitle(element, estimates[index].seconds, t)}
|
||||||
</div>
|
/>
|
||||||
)}
|
))}
|
||||||
|
</div>
|
||||||
{editing && (
|
)}
|
||||||
<JunctionElementDialog
|
|
||||||
junctionId={junction.id}
|
{editing && (
|
||||||
element={editing}
|
<JunctionElementDialog
|
||||||
bumperTemplates={channel.bumperTemplates}
|
junctionId={junction.id}
|
||||||
onClose={() => setEditing(null)}
|
element={editing}
|
||||||
onChanged={onChanged}
|
bumperTemplates={channel.bumperTemplates}
|
||||||
onError={onError}
|
onClose={() => setEditing(null)}
|
||||||
/>
|
onChanged={onChanged}
|
||||||
)}
|
onError={onError}
|
||||||
</div>
|
/>
|
||||||
)
|
)}
|
||||||
}
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -118,9 +118,7 @@ export function LayerApplicabilityDialog({
|
|||||||
type="date"
|
type="date"
|
||||||
className="w-40"
|
className="w-40"
|
||||||
value={range.from}
|
value={range.from}
|
||||||
onChange={(e) =>
|
onChange={(e) => dateRanges.patch(key, (r) => ({ ...r, from: e.target.value }))}
|
||||||
dateRanges.patch(key, (r) => ({ ...r, from: e.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
|
|||||||
@@ -1,259 +1,255 @@
|
|||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
SHOW_AUDIENCES,
|
SHOW_AUDIENCES,
|
||||||
type AudienceWindow,
|
type AudienceWindow,
|
||||||
type PlanningRules,
|
type PlanningRules,
|
||||||
type ScheduleTemplateDto,
|
type ScheduleTemplateDto,
|
||||||
type ShowAudience,
|
type ShowAudience,
|
||||||
} from '@/shared/api/types'
|
} from '@/shared/api/types'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { updateTemplate } from '../api'
|
import { updateTemplate } from '../api'
|
||||||
import { CollapsibleCard } from './CollapsibleCard'
|
import { CollapsibleCard } from './CollapsibleCard'
|
||||||
|
|
||||||
const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'PG-13' }
|
const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'PG-13' }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Окно со стабильным ключом. Индекс в качестве key не годится: строки удаляются из середины, и React
|
* Окно со стабильным ключом. Индекс в качестве key не годится: строки удаляются из середины, и React
|
||||||
* сопоставил бы уцелевшие узлы не с теми окнами — фокус и внутреннее состояние полей переехали бы
|
* сопоставил бы уцелевшие узлы не с теми окнами — фокус и внутреннее состояние полей переехали бы
|
||||||
* в соседнюю строку. Ключ живёт только на клиенте и в API не уезжает.
|
* в соседнюю строку. Ключ живёт только на клиенте и в API не уезжает.
|
||||||
*/
|
*/
|
||||||
type WindowRow = { key: string; window: AudienceWindow }
|
type WindowRow = { key: string; window: AudienceWindow }
|
||||||
|
|
||||||
const toRows = (windows: AudienceWindow[]): WindowRow[] =>
|
const toRows = (windows: AudienceWindow[]): WindowRow[] =>
|
||||||
windows.map((window) => ({ key: crypto.randomUUID(), window }))
|
windows.map((window) => ({ key: crypto.randomUUID(), window }))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
|
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
|
||||||
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
|
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
|
||||||
* воспроизводимость. Как и правка сетки, эфира сами по себе не двигают.
|
* воспроизводимость. Как и правка сетки, эфира сами по себе не двигают.
|
||||||
*/
|
*/
|
||||||
export function RulesCard({
|
export function RulesCard({
|
||||||
template,
|
template,
|
||||||
bare,
|
bare,
|
||||||
onChanged,
|
onChanged,
|
||||||
onError,
|
onError,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
template: ScheduleTemplateDto
|
template: ScheduleTemplateDto
|
||||||
bare?: boolean
|
bare?: boolean
|
||||||
onChanged: () => void
|
onChanged: () => void
|
||||||
onError: (error: unknown) => void
|
onError: (error: unknown) => void
|
||||||
}>) {
|
}>) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [windows, setWindows] = useState<WindowRow[]>(() =>
|
const [windows, setWindows] = useState<WindowRow[]>(() =>
|
||||||
toRows(template.rules?.maxAudienceByTime ?? []),
|
toRows(template.rules?.maxAudienceByTime ?? []),
|
||||||
)
|
)
|
||||||
const [limitOn, setLimitOn] = useState(() => template.rules?.maxRepeatsInWindow != null)
|
const [limitOn, setLimitOn] = useState(() => template.rules?.maxRepeatsInWindow != null)
|
||||||
const [windowDays, setWindowDays] = useState(
|
const [windowDays, setWindowDays] = useState(
|
||||||
() => template.rules?.maxRepeatsInWindow?.windowDays ?? 7,
|
() => template.rules?.maxRepeatsInWindow?.windowDays ?? 7,
|
||||||
)
|
)
|
||||||
const [max, setMax] = useState(() => template.rules?.maxRepeatsInWindow?.max ?? 2)
|
const [max, setMax] = useState(() => template.rules?.maxRepeatsInWindow?.max ?? 2)
|
||||||
const [breakCap, setBreakCap] = useState(() => template.rules?.maxBreakMinutesPerHour ?? 0)
|
const [breakCap, setBreakCap] = useState(() => template.rules?.maxBreakMinutesPerHour ?? 0)
|
||||||
const [genreCap, setGenreCap] = useState(() => template.rules?.maxGenreSharePercent ?? 0)
|
const [genreCap, setGenreCap] = useState(() => template.rules?.maxGenreSharePercent ?? 0)
|
||||||
const [fallbackCap, setFallbackCap] = useState(() => template.rules?.maxFallbackSharePercent ?? 0)
|
const [fallbackCap, setFallbackCap] = useState(() => template.rules?.maxFallbackSharePercent ?? 0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setWindows(toRows(template.rules?.maxAudienceByTime ?? []))
|
setWindows(toRows(template.rules?.maxAudienceByTime ?? []))
|
||||||
setLimitOn(template.rules?.maxRepeatsInWindow != null)
|
setLimitOn(template.rules?.maxRepeatsInWindow != null)
|
||||||
setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7)
|
setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7)
|
||||||
setMax(template.rules?.maxRepeatsInWindow?.max ?? 2)
|
setMax(template.rules?.maxRepeatsInWindow?.max ?? 2)
|
||||||
setBreakCap(template.rules?.maxBreakMinutesPerHour ?? 0)
|
setBreakCap(template.rules?.maxBreakMinutesPerHour ?? 0)
|
||||||
setGenreCap(template.rules?.maxGenreSharePercent ?? 0)
|
setGenreCap(template.rules?.maxGenreSharePercent ?? 0)
|
||||||
setFallbackCap(template.rules?.maxFallbackSharePercent ?? 0)
|
setFallbackCap(template.rules?.maxFallbackSharePercent ?? 0)
|
||||||
}, [template])
|
}, [template])
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
const rules: PlanningRules = {
|
const rules: PlanningRules = {
|
||||||
maxAudienceByTime: windows.length > 0 ? windows.map((row) => row.window) : null,
|
maxAudienceByTime: windows.length > 0 ? windows.map((row) => row.window) : null,
|
||||||
maxRepeatsInWindow: limitOn ? { windowDays, max } : null,
|
maxRepeatsInWindow: limitOn ? { windowDays, max } : null,
|
||||||
// Ноль означает «не проверять»: отдельного выключателя на каждый порог не нужно.
|
// Ноль означает «не проверять»: отдельного выключателя на каждый порог не нужно.
|
||||||
maxBreakMinutesPerHour: breakCap > 0 ? breakCap : null,
|
maxBreakMinutesPerHour: breakCap > 0 ? breakCap : null,
|
||||||
maxGenreSharePercent: genreCap > 0 ? genreCap : null,
|
maxGenreSharePercent: genreCap > 0 ? genreCap : null,
|
||||||
maxFallbackSharePercent: fallbackCap > 0 ? fallbackCap : null,
|
maxFallbackSharePercent: fallbackCap > 0 ? fallbackCap : null,
|
||||||
}
|
}
|
||||||
return updateTemplate(template.id, {
|
return updateTemplate(template.id, {
|
||||||
name: template.name,
|
name: template.name,
|
||||||
fallbackGroupId: template.fallbackGroupId,
|
fallbackGroupId: template.fallbackGroupId,
|
||||||
defaultJunctionId: template.defaultJunctionId,
|
defaultJunctionId: template.defaultJunctionId,
|
||||||
rules,
|
rules,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: onChanged,
|
onSuccess: onChanged,
|
||||||
onError,
|
onError,
|
||||||
})
|
})
|
||||||
|
|
||||||
const removeWindow = (key: string) =>
|
const removeWindow = (key: string) =>
|
||||||
setWindows((current) => current.filter((row) => row.key !== key))
|
setWindows((current) => current.filter((row) => row.key !== key))
|
||||||
|
|
||||||
const patchWindow = (key: string, part: Partial<AudienceWindow>) =>
|
const patchWindow = (key: string, part: Partial<AudienceWindow>) =>
|
||||||
setWindows((current) =>
|
setWindows((current) =>
|
||||||
current.map((row) =>
|
current.map((row) =>
|
||||||
row.key === key ? { ...row, window: { ...row.window, ...part } } : row,
|
row.key === key ? { ...row, window: { ...row.window, ...part } } : row,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapsibleCard title={t('admin.channels.rules')} bare={bare}>
|
<CollapsibleCard title={t('admin.channels.rules')} bare={bare}>
|
||||||
<div className="flex flex-col gap-4 text-sm">
|
<div className="flex flex-col gap-4 text-sm">
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.channels.rulesHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('admin.channels.rulesHint')}</p>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
{t('admin.channels.audienceWindows')}
|
{t('admin.channels.audienceWindows')}
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setWindows((c) => [...c, ...toRows([EMPTY_WINDOW])])}
|
onClick={() => setWindows((c) => [...c, ...toRows([EMPTY_WINDOW])])}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{windows.length === 0 ? (
|
{windows.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.channels.noAudienceWindows')}</p>
|
<p className="text-xs text-muted-foreground">{t('admin.channels.noAudienceWindows')}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex flex-col gap-2">
|
<ul className="flex flex-col gap-2">
|
||||||
{windows.map(({ key, window }) => (
|
{windows.map(({ key, window }) => (
|
||||||
<li key={key} className="flex flex-wrap items-end gap-2">
|
<li key={key} className="flex flex-wrap items-end gap-2">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.channels.from')}</Label>
|
<Label>{t('admin.channels.from')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="time"
|
type="time"
|
||||||
className="w-28"
|
className="w-28"
|
||||||
value={window.from.slice(0, 5)}
|
value={window.from.slice(0, 5)}
|
||||||
onChange={(e) => patchWindow(key, { from: `${e.target.value}:00` })}
|
onChange={(e) => patchWindow(key, { from: `${e.target.value}:00` })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.channels.to')}</Label>
|
<Label>{t('admin.channels.to')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="time"
|
type="time"
|
||||||
className="w-28"
|
className="w-28"
|
||||||
value={window.to.slice(0, 5)}
|
value={window.to.slice(0, 5)}
|
||||||
onChange={(e) => patchWindow(key, { to: `${e.target.value}:00` })}
|
onChange={(e) => patchWindow(key, { to: `${e.target.value}:00` })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.channels.maxAudience')}</Label>
|
<Label>{t('admin.channels.maxAudience')}</Label>
|
||||||
<select
|
<select
|
||||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||||
value={window.maxAudience}
|
value={window.maxAudience}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
patchWindow(key, { maxAudience: e.target.value as ShowAudience })
|
patchWindow(key, { maxAudience: e.target.value as ShowAudience })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{SHOW_AUDIENCES.map((value) => (
|
{SHOW_AUDIENCES.map((value) => (
|
||||||
<option key={value} value={value}>
|
<option key={value} value={value}>
|
||||||
{t(`admin.shows.audiences.${value}`)}
|
{t(`admin.shows.audiences.${value}`)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button size="sm" variant="ghost" onClick={() => removeWindow(key)}>
|
||||||
size="sm"
|
<Trash2 className="h-4 w-4" />
|
||||||
variant="ghost"
|
</Button>
|
||||||
onClick={() => removeWindow(key)}
|
</li>
|
||||||
>
|
))}
|
||||||
<Trash2 className="h-4 w-4" />
|
</ul>
|
||||||
</Button>
|
)}
|
||||||
</li>
|
<p className="text-xs text-muted-foreground">{t('admin.channels.audienceWindowsHint')}</p>
|
||||||
))}
|
</div>
|
||||||
</ul>
|
|
||||||
)}
|
<div className="flex flex-col gap-2 border-t border-border pt-4">
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.channels.audienceWindowsHint')}</p>
|
<label className="flex items-center gap-2">
|
||||||
</div>
|
<input
|
||||||
|
type="checkbox"
|
||||||
<div className="flex flex-col gap-2 border-t border-border pt-4">
|
checked={limitOn}
|
||||||
<label className="flex items-center gap-2">
|
onChange={(e) => setLimitOn(e.target.checked)}
|
||||||
<input
|
/>
|
||||||
type="checkbox"
|
{t('admin.channels.repeatLimit')}
|
||||||
checked={limitOn}
|
</label>
|
||||||
onChange={(e) => setLimitOn(e.target.checked)}
|
{limitOn && (
|
||||||
/>
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
{t('admin.channels.repeatLimit')}
|
<div className="flex flex-col gap-1.5">
|
||||||
</label>
|
<Label>{t('admin.channels.repeatWindowDays')}</Label>
|
||||||
{limitOn && (
|
<Input
|
||||||
<div className="flex flex-wrap items-end gap-2">
|
type="number"
|
||||||
<div className="flex flex-col gap-1.5">
|
min={1}
|
||||||
<Label>{t('admin.channels.repeatWindowDays')}</Label>
|
max={365}
|
||||||
<Input
|
className="w-28"
|
||||||
type="number"
|
value={windowDays}
|
||||||
min={1}
|
onChange={(e) => setWindowDays(Number(e.target.value))}
|
||||||
max={365}
|
/>
|
||||||
className="w-28"
|
</div>
|
||||||
value={windowDays}
|
<div className="flex flex-col gap-1.5">
|
||||||
onChange={(e) => setWindowDays(Number(e.target.value))}
|
<Label>{t('admin.channels.repeatMax')}</Label>
|
||||||
/>
|
<Input
|
||||||
</div>
|
type="number"
|
||||||
<div className="flex flex-col gap-1.5">
|
min={1}
|
||||||
<Label>{t('admin.channels.repeatMax')}</Label>
|
className="w-28"
|
||||||
<Input
|
value={max}
|
||||||
type="number"
|
onChange={(e) => setMax(Number(e.target.value))}
|
||||||
min={1}
|
/>
|
||||||
className="w-28"
|
</div>
|
||||||
value={max}
|
</div>
|
||||||
onChange={(e) => setMax(Number(e.target.value))}
|
)}
|
||||||
/>
|
<p className="text-xs text-muted-foreground">{t('admin.channels.repeatLimitHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
<div className="flex flex-col gap-2 border-t border-border pt-4">
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.channels.repeatLimitHint')}</p>
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
</div>
|
{t('admin.channels.postChecks')}
|
||||||
|
</h3>
|
||||||
<div className="flex flex-col gap-2 border-t border-border pt-4">
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<div className="flex flex-col gap-1.5">
|
||||||
{t('admin.channels.postChecks')}
|
<Label>{t('admin.channels.breakLimit')}</Label>
|
||||||
</h3>
|
<Input
|
||||||
<div className="flex flex-wrap items-end gap-2">
|
type="number"
|
||||||
<div className="flex flex-col gap-1.5">
|
min={0}
|
||||||
<Label>{t('admin.channels.breakLimit')}</Label>
|
className="w-28"
|
||||||
<Input
|
value={breakCap}
|
||||||
type="number"
|
onChange={(e) => setBreakCap(Number(e.target.value))}
|
||||||
min={0}
|
/>
|
||||||
className="w-28"
|
</div>
|
||||||
value={breakCap}
|
<div className="flex flex-col gap-1.5">
|
||||||
onChange={(e) => setBreakCap(Number(e.target.value))}
|
<Label>{t('admin.channels.genreShare')}</Label>
|
||||||
/>
|
<Input
|
||||||
</div>
|
type="number"
|
||||||
<div className="flex flex-col gap-1.5">
|
min={0}
|
||||||
<Label>{t('admin.channels.genreShare')}</Label>
|
max={100}
|
||||||
<Input
|
className="w-28"
|
||||||
type="number"
|
value={genreCap}
|
||||||
min={0}
|
onChange={(e) => setGenreCap(Number(e.target.value))}
|
||||||
max={100}
|
/>
|
||||||
className="w-28"
|
</div>
|
||||||
value={genreCap}
|
<div className="flex flex-col gap-1.5">
|
||||||
onChange={(e) => setGenreCap(Number(e.target.value))}
|
<Label>{t('admin.channels.fallbackShare')}</Label>
|
||||||
/>
|
<Input
|
||||||
</div>
|
type="number"
|
||||||
<div className="flex flex-col gap-1.5">
|
min={0}
|
||||||
<Label>{t('admin.channels.fallbackShare')}</Label>
|
max={100}
|
||||||
<Input
|
className="w-28"
|
||||||
type="number"
|
value={fallbackCap}
|
||||||
min={0}
|
onChange={(e) => setFallbackCap(Number(e.target.value))}
|
||||||
max={100}
|
/>
|
||||||
className="w-28"
|
</div>
|
||||||
value={fallbackCap}
|
</div>
|
||||||
onChange={(e) => setFallbackCap(Number(e.target.value))}
|
<p className="text-xs text-muted-foreground">{t('admin.channels.postChecksHint')}</p>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<div className="flex justify-end">
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.channels.postChecksHint')}</p>
|
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||||
</div>
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
<div className="flex justify-end">
|
</div>
|
||||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
</div>
|
||||||
{t('common.save')}
|
</CollapsibleCard>
|
||||||
</Button>
|
)
|
||||||
</div>
|
}
|
||||||
</div>
|
|
||||||
</CollapsibleCard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,379 +1,380 @@
|
|||||||
import { Anchor, CalendarRange, Copy, GripVertical, Plus, Repeat } from 'lucide-react'
|
import { Anchor, CalendarRange, Copy, GripVertical, Plus, Repeat } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types'
|
import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { cn } from '@/shared/lib/cn'
|
import { cn } from '@/shared/lib/cn'
|
||||||
import { coversDate, isEmpty } from '../lib/applicability'
|
import { coversDate, isEmpty } from '../lib/applicability'
|
||||||
|
|
||||||
const HOUR_HEIGHT = 44
|
const HOUR_HEIGHT = 44
|
||||||
|
|
||||||
/** Шаг сетки при перетаскивании и растягивании — минуты. */
|
/** Шаг сетки при перетаскивании и растягивании — минуты. */
|
||||||
const SNAP_MINUTES = 15
|
const SNAP_MINUTES = 15
|
||||||
|
|
||||||
const snap = (minutes: number) => Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES
|
const snap = (minutes: number) => Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES
|
||||||
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
|
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
|
||||||
|
|
||||||
/** Цвет блока — по дейпарту: сетка должна читаться одним взглядом, без легенды. */
|
/** Цвет блока — по дейпарту: сетка должна читаться одним взглядом, без легенды. */
|
||||||
const DAYPART_CLASS: Record<string, string> = {
|
const DAYPART_CLASS: Record<string, string> = {
|
||||||
Morning: 'bg-amber-500/20 border-amber-500/40',
|
Morning: 'bg-amber-500/20 border-amber-500/40',
|
||||||
Day: 'bg-sky-500/20 border-sky-500/40',
|
Day: 'bg-sky-500/20 border-sky-500/40',
|
||||||
Prime: 'bg-violet-500/25 border-violet-500/50',
|
Prime: 'bg-violet-500/25 border-violet-500/50',
|
||||||
Night: 'bg-slate-500/20 border-slate-500/40',
|
Night: 'bg-slate-500/20 border-slate-500/40',
|
||||||
}
|
}
|
||||||
|
|
||||||
function minutesOf(time: string): number {
|
function minutesOf(time: string): number {
|
||||||
const [h, m] = time.split(':')
|
const [h, m] = time.split(':')
|
||||||
return Number(h) * 60 + Number(m)
|
return Number(h) * 60 + Number(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Смещение слота от начала вещательных суток. Ночной блок (00:00–06:00 при старте суток в 06:00)
|
* Смещение слота от начала вещательных суток. Ночной блок (00:00–06:00 при старте суток в 06:00)
|
||||||
* принадлежит предыдущему дню, поэтому его смещение больше суточного, а не отрицательное.
|
* принадлежит предыдущему дню, поэтому его смещение больше суточного, а не отрицательное.
|
||||||
*/
|
*/
|
||||||
function offsetInDay(slotStart: string, dayStart: string): number {
|
function offsetInDay(slotStart: string, dayStart: string): number {
|
||||||
const diff = minutesOf(slotStart) - minutesOf(dayStart)
|
const diff = minutesOf(slotStart) - minutesOf(dayStart)
|
||||||
return diff >= 0 ? diff : diff + 24 * 60
|
return diff >= 0 ? diff : diff + 24 * 60
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Слоты, попадающие в колонку дня: слот без дня недели идёт каждый день. */
|
/** Слоты, попадающие в колонку дня: слот без дня недели идёт каждый день. */
|
||||||
function slotsOfDay(layers: GridLayerDto[], weekday: number) {
|
function slotsOfDay(layers: GridLayerDto[], weekday: number) {
|
||||||
return layers
|
return layers
|
||||||
.filter((layer) => layer.isEnabled)
|
.filter((layer) => layer.isEnabled)
|
||||||
.flatMap((layer) =>
|
.flatMap((layer) =>
|
||||||
layer.slots
|
layer.slots
|
||||||
.filter((slot) => slot.weekday === null || slot.weekday === weekday)
|
.filter((slot) => slot.weekday === null || slot.weekday === weekday)
|
||||||
.map((slot) => ({ slot, layer })),
|
.map((slot) => ({ slot, layer })),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScheduleGrid({
|
export function ScheduleGrid({
|
||||||
template,
|
template,
|
||||||
selectedSlotId,
|
selectedSlotId,
|
||||||
viewDate,
|
viewDate,
|
||||||
onSelectSlot,
|
onSelectSlot,
|
||||||
onAddSlot,
|
onAddSlot,
|
||||||
onMoveSlot,
|
onMoveSlot,
|
||||||
onResizeSlot,
|
onResizeSlot,
|
||||||
onCopyDay,
|
onCopyDay,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
template: ScheduleTemplateDto
|
template: ScheduleTemplateDto
|
||||||
selectedSlotId: string | null
|
selectedSlotId: string | null
|
||||||
/** Дата, на которую смотрим сетку («показать 25 декабря»); null — все слои разом. */
|
/** Дата, на которую смотрим сетку («показать 25 декабря»); null — все слои разом. */
|
||||||
viewDate: string | null
|
viewDate: string | null
|
||||||
onSelectSlot: (slot: SlotDto) => void
|
onSelectSlot: (slot: SlotDto) => void
|
||||||
onAddSlot: (weekday: number, startMinutes: number) => void
|
onAddSlot: (weekday: number, startMinutes: number) => void
|
||||||
/** Перенос слота: новое время старта и (для слота с днём недели) новый день. */
|
/** Перенос слота: новое время старта и (для слота с днём недели) новый день. */
|
||||||
onMoveSlot: (slot: SlotDto, weekday: number, startMinutes: number) => void
|
onMoveSlot: (slot: SlotDto, weekday: number, startMinutes: number) => void
|
||||||
onResizeSlot: (slot: SlotDto, durationMinutes: number) => void
|
onResizeSlot: (slot: SlotDto, durationMinutes: number) => void
|
||||||
onCopyDay: (fromWeekday: number) => void
|
onCopyDay: (fromWeekday: number) => void
|
||||||
}>) {
|
}>) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const dayStart = template.dayStartTime.slice(0, 5)
|
const dayStart = template.dayStartTime.slice(0, 5)
|
||||||
const dayStartMinutes = minutesOf(dayStart)
|
const dayStartMinutes = minutesOf(dayStart)
|
||||||
|
|
||||||
// Подписи часов идут от начала вещательных суток, а не от полуночи.
|
// Подписи часов идут от начала вещательных суток, а не от полуночи.
|
||||||
const hours = Array.from({ length: 24 }, (_, i) => (dayStartMinutes / 60 + i) % 24)
|
const hours = Array.from({ length: 24 }, (_, i) => (dayStartMinutes / 60 + i) % 24)
|
||||||
|
|
||||||
// На выбранную дату показываем только те слои, которые в этот день действуют, — иначе сетка
|
// На выбранную дату показываем только те слои, которые в этот день действуют, — иначе сетка
|
||||||
// «на 25 декабря» показывала бы и обычный день, и новогодний одновременно.
|
// «на 25 декабря» показывала бы и обычный день, и новогодний одновременно.
|
||||||
const day = viewDate ? parseIsoDate(viewDate) : null
|
const day = viewDate ? parseIsoDate(viewDate) : null
|
||||||
const applicable = day
|
const applicable = day
|
||||||
? template.layers.filter((layer) => coversDate(layer.applicability, day))
|
? template.layers.filter((layer) => coversDate(layer.applicability, day))
|
||||||
: template.layers
|
: template.layers
|
||||||
|
|
||||||
// Слои отсортированы по убыванию приоритета: слот, лежащий под более приоритетным, штрихуем.
|
// Слои отсортированы по убыванию приоритета: слот, лежащий под более приоритетным, штрихуем.
|
||||||
const ordered = [...applicable].sort((a, b) => b.priority - a.priority)
|
const ordered = [...applicable].sort((a, b) => b.priority - a.priority)
|
||||||
const highlightWeekday = day?.getDay() ?? null
|
const highlightWeekday = day?.getDay() ?? null
|
||||||
|
|
||||||
const [dragged, setDragged] = useState<SlotDto | null>(null)
|
const [dragged, setDragged] = useState<SlotDto | null>(null)
|
||||||
const [resizing, setResizing] = useState<{ slot: SlotDto; minutes: number } | null>(null)
|
const [resizing, setResizing] = useState<{ slot: SlotDto; minutes: number } | null>(null)
|
||||||
|
|
||||||
/** Позиция курсора в колонке дня — минуты суток, округлённые до шага сетки. */
|
/** Позиция курсора в колонке дня — минуты суток, округлённые до шага сетки. */
|
||||||
const minutesAt = (clientY: number, column: HTMLElement) => {
|
const minutesAt = (clientY: number, column: HTMLElement) => {
|
||||||
const rect = column.getBoundingClientRect()
|
const rect = column.getBoundingClientRect()
|
||||||
const offset = Math.max(0, Math.min(rect.height, clientY - rect.top))
|
const offset = Math.max(0, Math.min(rect.height, clientY - rect.top))
|
||||||
const fromDayStart = snap((offset / HOUR_HEIGHT) * 60)
|
const fromDayStart = snap((offset / HOUR_HEIGHT) * 60)
|
||||||
return (dayStartMinutes + fromDayStart) % (24 * 60)
|
return (dayStartMinutes + fromDayStart) % (24 * 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
const drop = (event: React.DragEvent<HTMLDivElement>, weekday: number) => {
|
const drop = (event: React.DragEvent<HTMLDivElement>, weekday: number) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!dragged) return
|
if (!dragged) return
|
||||||
// Слот «каждый день» при переносе таким и остаётся: молча превратить его в слот одного дня
|
// Слот «каждый день» при переносе таким и остаётся: молча превратить его в слот одного дня
|
||||||
// значило бы убрать его сразу из шести колонок.
|
// значило бы убрать его сразу из шести колонок.
|
||||||
onMoveSlot(dragged, dragged.weekday ?? weekday, minutesAt(event.clientY, event.currentTarget))
|
onMoveSlot(dragged, dragged.weekday ?? weekday, minutesAt(event.clientY, event.currentTarget))
|
||||||
setDragged(null)
|
setDragged(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Растягивание за нижний край: пока тянем — видно новую высоту, отпустили — сохраняем. */
|
/** Растягивание за нижний край: пока тянем — видно новую высоту, отпустили — сохраняем. */
|
||||||
const startResize = (event: React.MouseEvent, slot: SlotDto, column: HTMLElement) => {
|
const startResize = (event: React.MouseEvent, slot: SlotDto, column: HTMLElement) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
const from = offsetInDay(slot.targetStart, dayStart)
|
const from = offsetInDay(slot.targetStart, dayStart)
|
||||||
|
|
||||||
const move = (moveEvent: MouseEvent) => {
|
const move = (moveEvent: MouseEvent) => {
|
||||||
const rect = column.getBoundingClientRect()
|
const rect = column.getBoundingClientRect()
|
||||||
const offset = Math.max(0, Math.min(rect.height, moveEvent.clientY - rect.top))
|
const offset = Math.max(0, Math.min(rect.height, moveEvent.clientY - rect.top))
|
||||||
const end = snap((offset / HOUR_HEIGHT) * 60)
|
const end = snap((offset / HOUR_HEIGHT) * 60)
|
||||||
setResizing({ slot, minutes: Math.max(SNAP_MINUTES, end - from) })
|
setResizing({ slot, minutes: Math.max(SNAP_MINUTES, end - from) })
|
||||||
}
|
}
|
||||||
const up = () => {
|
const up = () => {
|
||||||
window.removeEventListener('mousemove', move)
|
window.removeEventListener('mousemove', move)
|
||||||
window.removeEventListener('mouseup', up)
|
window.removeEventListener('mouseup', up)
|
||||||
setResizing((current) => {
|
setResizing((current) => {
|
||||||
if (current && current.minutes !== slot.targetDurationMinutes)
|
if (current && current.minutes !== slot.targetDurationMinutes)
|
||||||
onResizeSlot(slot, current.minutes)
|
onResizeSlot(slot, current.minutes)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
window.addEventListener('mousemove', move)
|
window.addEventListener('mousemove', move)
|
||||||
window.addEventListener('mouseup', up)
|
window.addEventListener('mouseup', up)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCovered = (slot: SlotDto, layer: GridLayerDto, weekday: number) => {
|
const isCovered = (slot: SlotDto, layer: GridLayerDto, weekday: number) => {
|
||||||
const from = offsetInDay(slot.targetStart, dayStart)
|
const from = offsetInDay(slot.targetStart, dayStart)
|
||||||
const to = from + slot.targetDurationMinutes
|
const to = from + slot.targetDurationMinutes
|
||||||
return ordered
|
return ordered
|
||||||
.filter((other) => other.isEnabled && other.priority > layer.priority)
|
.filter((other) => other.isEnabled && other.priority > layer.priority)
|
||||||
.some((other) =>
|
.some((other) =>
|
||||||
other.slots
|
other.slots
|
||||||
.filter((s) => s.weekday === null || s.weekday === weekday)
|
.filter((s) => s.weekday === null || s.weekday === weekday)
|
||||||
.some((s) => {
|
.some((s) => {
|
||||||
const otherFrom = offsetInDay(s.targetStart, dayStart)
|
const otherFrom = offsetInDay(s.targetStart, dayStart)
|
||||||
return from < otherFrom + s.targetDurationMinutes && otherFrom < to
|
return from < otherFrom + s.targetDurationMinutes && otherFrom < to
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="crt-panel overflow-x-auto rounded-md">
|
<div className="crt-panel overflow-x-auto rounded-md">
|
||||||
<div className="min-w-[720px]">
|
<div className="min-w-[720px]">
|
||||||
<div className="grid grid-cols-[56px_repeat(7,1fr)] border-b border-border text-xs text-muted-foreground">
|
<div className="grid grid-cols-[56px_repeat(7,1fr)] border-b border-border text-xs text-muted-foreground">
|
||||||
<div className="px-2 py-1">{dayStart}</div>
|
<div className="px-2 py-1">{dayStart}</div>
|
||||||
{WEEKDAYS.map((weekday) => (
|
{WEEKDAYS.map((weekday) => (
|
||||||
<div
|
<div
|
||||||
key={weekday}
|
key={weekday}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-center gap-1 px-2 py-1 font-medium',
|
'flex items-center justify-center gap-1 px-2 py-1 font-medium',
|
||||||
highlightWeekday === weekday && 'text-primary',
|
highlightWeekday === weekday && 'text-primary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t(`admin.channels.weekdays.${weekday}`)}
|
{t(`admin.channels.weekdays.${weekday}`)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title={t('admin.channels.copyDay')}
|
title={t('admin.channels.copyDay')}
|
||||||
className="opacity-40 hover:opacity-100"
|
className="opacity-40 hover:opacity-100"
|
||||||
onClick={() => onCopyDay(weekday)}
|
onClick={() => onCopyDay(weekday)}
|
||||||
>
|
>
|
||||||
<Copy className="h-3 w-3" />
|
<Copy className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-[56px_repeat(7,1fr)]">
|
<div className="grid grid-cols-[56px_repeat(7,1fr)]">
|
||||||
<div>
|
<div>
|
||||||
{hours.map((hour) => (
|
{hours.map((hour) => (
|
||||||
<div
|
<div
|
||||||
key={hour}
|
key={hour}
|
||||||
className="border-b border-border/40 px-2 text-[11px] text-muted-foreground"
|
className="border-b border-border/40 px-2 text-[11px] text-muted-foreground"
|
||||||
style={{ height: HOUR_HEIGHT }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
{hour.toString().padStart(2, '0')}:00
|
{hour.toString().padStart(2, '0')}:00
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{WEEKDAYS.map((weekday) => (
|
{WEEKDAYS.map((weekday) => (
|
||||||
<div
|
<div
|
||||||
key={weekday}
|
key={weekday}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative border-l border-border',
|
'relative border-l border-border',
|
||||||
highlightWeekday === weekday && 'bg-primary/5',
|
highlightWeekday === weekday && 'bg-primary/5',
|
||||||
dragged && 'bg-primary/10',
|
dragged && 'bg-primary/10',
|
||||||
)}
|
)}
|
||||||
style={{ height: HOUR_HEIGHT * 24 }}
|
style={{ height: HOUR_HEIGHT * 24 }}
|
||||||
onDragOver={(e) => dragged && e.preventDefault()}
|
onDragOver={(e) => dragged && e.preventDefault()}
|
||||||
onDrop={(e) => drop(e, weekday)}
|
onDrop={(e) => drop(e, weekday)}
|
||||||
>
|
>
|
||||||
{hours.map((hour, index) => (
|
{hours.map((hour, index) => (
|
||||||
<button
|
<button
|
||||||
key={hour}
|
key={hour}
|
||||||
type="button"
|
type="button"
|
||||||
title={t('admin.channels.addSlotHere')}
|
title={t('admin.channels.addSlotHere')}
|
||||||
className="group absolute inset-x-0 border-b border-border/40 hover:bg-muted/30"
|
className="group absolute inset-x-0 border-b border-border/40 hover:bg-muted/30"
|
||||||
style={{ top: HOUR_HEIGHT * index, height: HOUR_HEIGHT }}
|
style={{ top: HOUR_HEIGHT * index, height: HOUR_HEIGHT }}
|
||||||
onClick={() => onAddSlot(weekday, (dayStartMinutes + index * 60) % (24 * 60))}
|
onClick={() => onAddSlot(weekday, (dayStartMinutes + index * 60) % (24 * 60))}
|
||||||
>
|
>
|
||||||
<Plus className="mx-auto h-3 w-3 opacity-0 group-hover:opacity-40" />
|
<Plus className="mx-auto h-3 w-3 opacity-0 group-hover:opacity-40" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{slotsOfDay(ordered, weekday).map(({ slot, layer }) => {
|
{slotsOfDay(ordered, weekday).map(({ slot, layer }) => {
|
||||||
const from = offsetInDay(slot.targetStart, dayStart)
|
const from = offsetInDay(slot.targetStart, dayStart)
|
||||||
const covered = isCovered(slot, layer, weekday)
|
const covered = isCovered(slot, layer, weekday)
|
||||||
const minutes =
|
const minutes =
|
||||||
resizing?.slot.id === slot.id ? resizing.minutes : slot.targetDurationMinutes
|
resizing?.slot.id === slot.id ? resizing.minutes : slot.targetDurationMinutes
|
||||||
return (
|
return (
|
||||||
// Кнопка, а не div: слот выбирается кликом, и с клавиатуры это должно работать
|
// Кнопка, а не div: слот выбирается кликом, и с клавиатуры это должно работать
|
||||||
// тоже. Перетаскивание на кнопке сохраняется — draggable к роли не привязан.
|
// тоже. Перетаскивание на кнопке сохраняется — draggable к роли не привязан.
|
||||||
<button
|
<button
|
||||||
key={`${slot.id}-${weekday}`}
|
key={`${slot.id}-${weekday}`}
|
||||||
type="button"
|
type="button"
|
||||||
draggable
|
draggable
|
||||||
onDragStart={() => setDragged(slot)}
|
onDragStart={() => setDragged(slot)}
|
||||||
onDragEnd={() => setDragged(null)}
|
onDragEnd={() => setDragged(null)}
|
||||||
onClick={() => onSelectSlot(slot)}
|
onClick={() => onSelectSlot(slot)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-x-1 cursor-grab overflow-hidden rounded border px-1.5 py-0.5 text-left text-[11px] leading-tight',
|
'absolute inset-x-1 cursor-grab overflow-hidden rounded border px-1.5 py-0.5 text-left text-[11px] leading-tight',
|
||||||
DAYPART_CLASS[slot.daypart] ?? DAYPART_CLASS.Day,
|
DAYPART_CLASS[slot.daypart] ?? DAYPART_CLASS.Day,
|
||||||
selectedSlotId === slot.id && 'ring-2 ring-primary',
|
selectedSlotId === slot.id && 'ring-2 ring-primary',
|
||||||
dragged?.id === slot.id && 'opacity-50',
|
dragged?.id === slot.id && 'opacity-50',
|
||||||
// Перекрытый слот виден, но приглушён: он не сыграет, пока лежит под старшим слоем.
|
// Перекрытый слот виден, но приглушён: он не сыграет, пока лежит под старшим слоем.
|
||||||
covered && 'opacity-40 [background-image:repeating-linear-gradient(45deg,transparent,transparent_4px,rgba(0,0,0,.15)_4px,rgba(0,0,0,.15)_8px)]',
|
covered &&
|
||||||
)}
|
'opacity-40 [background-image:repeating-linear-gradient(45deg,transparent,transparent_4px,rgba(0,0,0,.15)_4px,rgba(0,0,0,.15)_8px)]',
|
||||||
style={{
|
)}
|
||||||
top: (from / 60) * HOUR_HEIGHT,
|
style={{
|
||||||
height: Math.max(16, (minutes / 60) * HOUR_HEIGHT - 2),
|
top: (from / 60) * HOUR_HEIGHT,
|
||||||
}}
|
height: Math.max(16, (minutes / 60) * HOUR_HEIGHT - 2),
|
||||||
>
|
}}
|
||||||
<span className="flex items-center gap-1 font-medium">
|
>
|
||||||
{slot.isAnchor && <Anchor className="h-3 w-3 shrink-0" />}
|
<span className="flex items-center gap-1 font-medium">
|
||||||
{slot.targetStart.slice(0, 5)}
|
{slot.isAnchor && <Anchor className="h-3 w-3 shrink-0" />}
|
||||||
{slot.weekday === null && <Repeat className="h-3 w-3 shrink-0 opacity-60" />}
|
{slot.targetStart.slice(0, 5)}
|
||||||
</span>
|
{slot.weekday === null && <Repeat className="h-3 w-3 shrink-0 opacity-60" />}
|
||||||
<span className="block truncate">{slot.title}</span>
|
</span>
|
||||||
{/* Ручка растягивания — исключительно мышиная: role="presentation" на элементе
|
<span className="block truncate">{slot.title}</span>
|
||||||
с обработчиком противоречив (роль говорит «меня нет», а элемент реагирует),
|
{/* Ручка растягивания — исключительно мышиная: role="presentation" на элементе
|
||||||
поэтому прячем её от вспомогательных технологий. Длительность слота
|
с обработчиком противоречив (роль говорит «меня нет», а элемент реагирует),
|
||||||
правится с клавиатуры в инспекторе — доступный путь есть. */}
|
поэтому прячем её от вспомогательных технологий. Длительность слота
|
||||||
<span
|
правится с клавиатуры в инспекторе — доступный путь есть. */}
|
||||||
aria-hidden="true"
|
<span
|
||||||
title={t('admin.channels.resizeSlot')}
|
aria-hidden="true"
|
||||||
className="absolute inset-x-0 bottom-0 h-1.5 cursor-ns-resize hover:bg-primary/40"
|
title={t('admin.channels.resizeSlot')}
|
||||||
onMouseDown={(e) =>
|
className="absolute inset-x-0 bottom-0 h-1.5 cursor-ns-resize hover:bg-primary/40"
|
||||||
startResize(e, slot, e.currentTarget.parentElement!.parentElement!)
|
onMouseDown={(e) =>
|
||||||
}
|
startResize(e, slot, e.currentTarget.parentElement!.parentElement!)
|
||||||
/>
|
}
|
||||||
</button>
|
/>
|
||||||
)
|
</button>
|
||||||
})}
|
)
|
||||||
</div>
|
})}
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
)
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Панель слоёв: видимость, приоритет (перетаскиванием), применимость и выбор редактируемого.
|
/**
|
||||||
* Выше в списке — приоритетнее; фоновый слой всегда внизу и не двигается.
|
* Панель слоёв: видимость, приоритет (перетаскиванием), применимость и выбор редактируемого.
|
||||||
*/
|
* Выше в списке — приоритетнее; фоновый слой всегда внизу и не двигается.
|
||||||
export function LayerList({
|
*/
|
||||||
template,
|
export function LayerList({
|
||||||
activeLayerId,
|
template,
|
||||||
viewDate,
|
activeLayerId,
|
||||||
onSelect,
|
viewDate,
|
||||||
onDelete,
|
onSelect,
|
||||||
onToggle,
|
onDelete,
|
||||||
onReorder,
|
onToggle,
|
||||||
onEditApplicability,
|
onReorder,
|
||||||
}: Readonly<{
|
onEditApplicability,
|
||||||
template: ScheduleTemplateDto
|
}: Readonly<{
|
||||||
activeLayerId: string | null
|
template: ScheduleTemplateDto
|
||||||
viewDate: string | null
|
activeLayerId: string | null
|
||||||
onSelect: (layer: GridLayerDto) => void
|
viewDate: string | null
|
||||||
onDelete: (layer: GridLayerDto) => void
|
onSelect: (layer: GridLayerDto) => void
|
||||||
onToggle: (layer: GridLayerDto) => void
|
onDelete: (layer: GridLayerDto) => void
|
||||||
onReorder: (layerIdsTopFirst: string[]) => void
|
onToggle: (layer: GridLayerDto) => void
|
||||||
onEditApplicability: (layer: GridLayerDto) => void
|
onReorder: (layerIdsTopFirst: string[]) => void
|
||||||
}>) {
|
onEditApplicability: (layer: GridLayerDto) => void
|
||||||
const { t } = useTranslation()
|
}>) {
|
||||||
const [dragged, setDragged] = useState<string | null>(null)
|
const { t } = useTranslation()
|
||||||
|
const [dragged, setDragged] = useState<string | null>(null)
|
||||||
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
|
|
||||||
const day = viewDate ? parseIsoDate(viewDate) : null
|
const 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 dropOn = (targetId: string) => {
|
||||||
const movable = ordered.filter((l) => !l.isBackground).map((l) => l.id)
|
if (!dragged || dragged === targetId) return
|
||||||
const order = movable.filter((id) => id !== dragged)
|
const movable = ordered.filter((l) => !l.isBackground).map((l) => l.id)
|
||||||
const at = order.indexOf(targetId)
|
const order = movable.filter((id) => id !== dragged)
|
||||||
// Бросок на фоновый слой означает «в самый низ»: он в порядке не участвует.
|
const at = order.indexOf(targetId)
|
||||||
order.splice(at === -1 ? order.length : at, 0, dragged)
|
// Бросок на фоновый слой означает «в самый низ»: он в порядке не участвует.
|
||||||
setDragged(null)
|
order.splice(at === -1 ? order.length : at, 0, dragged)
|
||||||
onReorder(order)
|
setDragged(null)
|
||||||
}
|
onReorder(order)
|
||||||
|
}
|
||||||
return (
|
|
||||||
<ul className="divide-y divide-border text-sm">
|
return (
|
||||||
{ordered.map((layer) => {
|
<ul className="divide-y divide-border text-sm">
|
||||||
const inactiveToday = day !== null && !coversDate(layer.applicability, day)
|
{ordered.map((layer) => {
|
||||||
return (
|
const inactiveToday = day !== null && !coversDate(layer.applicability, day)
|
||||||
<li
|
return (
|
||||||
key={layer.id}
|
<li
|
||||||
draggable={!layer.isBackground}
|
key={layer.id}
|
||||||
onDragStart={() => setDragged(layer.id)}
|
draggable={!layer.isBackground}
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragStart={() => setDragged(layer.id)}
|
||||||
onDrop={() => dropOn(layer.id)}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
className={cn('flex items-center gap-1.5 py-1.5', inactiveToday && 'opacity-40')}
|
onDrop={() => dropOn(layer.id)}
|
||||||
>
|
className={cn('flex items-center gap-1.5 py-1.5', inactiveToday && 'opacity-40')}
|
||||||
{layer.isBackground ? (
|
>
|
||||||
<span className="w-4 shrink-0" />
|
{layer.isBackground ? (
|
||||||
) : (
|
<span className="w-4 shrink-0" />
|
||||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
) : (
|
||||||
)}
|
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
||||||
<input
|
)}
|
||||||
type="checkbox"
|
<input
|
||||||
className="shrink-0"
|
type="checkbox"
|
||||||
title={t('admin.channels.layerVisible')}
|
className="shrink-0"
|
||||||
checked={layer.isEnabled}
|
title={t('admin.channels.layerVisible')}
|
||||||
onChange={() => onToggle(layer)}
|
checked={layer.isEnabled}
|
||||||
/>
|
onChange={() => onToggle(layer)}
|
||||||
<button
|
/>
|
||||||
type="button"
|
<button
|
||||||
className={cn(
|
type="button"
|
||||||
'min-w-0 flex-1 truncate text-left',
|
className={cn(
|
||||||
activeLayerId === layer.id && 'text-primary',
|
'min-w-0 flex-1 truncate text-left',
|
||||||
)}
|
activeLayerId === layer.id && 'text-primary',
|
||||||
onClick={() => onSelect(layer)}
|
)}
|
||||||
>
|
onClick={() => onSelect(layer)}
|
||||||
{layer.name}
|
>
|
||||||
</button>
|
{layer.name}
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">
|
</button>
|
||||||
{layer.isBackground ? t('admin.channels.background') : layer.slots.length}
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
</span>
|
{layer.isBackground ? t('admin.channels.background') : layer.slots.length}
|
||||||
{!layer.isBackground && (
|
</span>
|
||||||
<>
|
{!layer.isBackground && (
|
||||||
<Button
|
<>
|
||||||
size="sm"
|
<Button
|
||||||
variant="ghost"
|
size="sm"
|
||||||
title={t('admin.channels.layerApplicability')}
|
variant="ghost"
|
||||||
onClick={() => onEditApplicability(layer)}
|
title={t('admin.channels.layerApplicability')}
|
||||||
>
|
onClick={() => onEditApplicability(layer)}
|
||||||
<CalendarRange
|
>
|
||||||
className={cn(
|
<CalendarRange
|
||||||
'h-4 w-4',
|
className={cn(
|
||||||
isEmpty(layer.applicability) ? 'text-muted-foreground' : 'text-primary',
|
'h-4 w-4',
|
||||||
)}
|
isEmpty(layer.applicability) ? 'text-muted-foreground' : 'text-primary',
|
||||||
/>
|
)}
|
||||||
</Button>
|
/>
|
||||||
<Button size="sm" variant="ghost" onClick={() => onDelete(layer)}>
|
</Button>
|
||||||
×
|
<Button size="sm" variant="ghost" onClick={() => onDelete(layer)}>
|
||||||
</Button>
|
×
|
||||||
</>
|
</Button>
|
||||||
)}
|
</>
|
||||||
</li>
|
)}
|
||||||
)
|
</li>
|
||||||
})}
|
)
|
||||||
</ul>
|
})}
|
||||||
)
|
</ul>
|
||||||
}
|
)
|
||||||
|
}
|
||||||
/** «2026-12-25» → локальная дата. `new Date(iso)` разобрал бы её как UTC и сместил день. */
|
|
||||||
function parseIsoDate(iso: string): Date {
|
/** «2026-12-25» → локальная дата. `new Date(iso)` разобрал бы её как UTC и сместил день. */
|
||||||
const [year, month, day] = iso.split('-').map(Number)
|
function parseIsoDate(iso: string): Date {
|
||||||
return new Date(year, month - 1, day)
|
const [year, month, day] = iso.split('-').map(Number)
|
||||||
}
|
return new Date(year, month - 1, day)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,136 +1,140 @@
|
|||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { ChannelDto } from '@/shared/api/types'
|
import type { ChannelDto } from '@/shared/api/types'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { updateChannelSettings, updateChannelTime } from '../api'
|
import { updateChannelSettings, updateChannelTime } from '../api'
|
||||||
import { CollapsibleCard } from './CollapsibleCard'
|
import { CollapsibleCard } from './CollapsibleCard'
|
||||||
|
|
||||||
export function SettingsCard({
|
export function SettingsCard({
|
||||||
channel,
|
channel,
|
||||||
readyAssets,
|
readyAssets,
|
||||||
bare,
|
bare,
|
||||||
onSaved,
|
onSaved,
|
||||||
onError,
|
onError,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
channel: ChannelDto
|
channel: ChannelDto
|
||||||
readyAssets: { id: string; originalFileName: string }[]
|
readyAssets: { id: string; originalFileName: string }[]
|
||||||
bare?: boolean
|
bare?: boolean
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
onError: (e: unknown) => void
|
onError: (e: unknown) => void
|
||||||
}>) {
|
}>) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [name, setName] = useState(channel.name)
|
const [name, setName] = useState(channel.name)
|
||||||
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
|
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
|
||||||
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
|
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
|
||||||
const [number, setNumber] = useState(channel.number?.toString() ?? '')
|
const [number, setNumber] = useState(channel.number?.toString() ?? '')
|
||||||
const [offsetHours, setOffsetHours] = useState(channel.utcOffsetMinutes / 60)
|
const [offsetHours, setOffsetHours] = useState(channel.utcOffsetMinutes / 60)
|
||||||
// Начало вещательных суток приходит как «06:00:00» — в поле нужен формат «06:00».
|
// Начало вещательных суток приходит как «06:00:00» — в поле нужен формат «06:00».
|
||||||
const [dayStart, setDayStart] = useState(channel.dayStartTime.slice(0, 5))
|
const [dayStart, setDayStart] = useState(channel.dayStartTime.slice(0, 5))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setName(channel.name)
|
setName(channel.name)
|
||||||
setIsEnabled(channel.isEnabled)
|
setIsEnabled(channel.isEnabled)
|
||||||
setFillerAssetId(channel.fillerAssetId ?? '')
|
setFillerAssetId(channel.fillerAssetId ?? '')
|
||||||
setNumber(channel.number?.toString() ?? '')
|
setNumber(channel.number?.toString() ?? '')
|
||||||
setOffsetHours(channel.utcOffsetMinutes / 60)
|
setOffsetHours(channel.utcOffsetMinutes / 60)
|
||||||
setDayStart(channel.dayStartTime.slice(0, 5))
|
setDayStart(channel.dayStartTime.slice(0, 5))
|
||||||
}, [channel])
|
}, [channel])
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
// Время канала живёт отдельной командой — сохраняем обе за одно нажатие.
|
// Время канала живёт отдельной командой — сохраняем обе за одно нажатие.
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
await updateChannelSettings(channel.id, {
|
await updateChannelSettings(channel.id, {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
isEnabled,
|
isEnabled,
|
||||||
// Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений.
|
// Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений.
|
||||||
bumpersEnabled: channel.bumpersEnabled,
|
bumpersEnabled: channel.bumpersEnabled,
|
||||||
bumper: channel.bumper,
|
bumper: channel.bumper,
|
||||||
fillerAssetId: fillerAssetId || null,
|
fillerAssetId: fillerAssetId || null,
|
||||||
})
|
})
|
||||||
await updateChannelTime(channel.id, {
|
await updateChannelTime(channel.id, {
|
||||||
number: number.trim() === '' ? null : Number(number),
|
number: number.trim() === '' ? null : Number(number),
|
||||||
utcOffsetMinutes: Math.round(offsetHours * 60),
|
utcOffsetMinutes: Math.round(offsetHours * 60),
|
||||||
dayStartTime: `${dayStart}:00`,
|
dayStartTime: `${dayStart}:00`,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(t('settings.saved'))
|
toast.success(t('settings.saved'))
|
||||||
onSaved()
|
onSaved()
|
||||||
},
|
},
|
||||||
onError,
|
onError,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapsibleCard
|
<CollapsibleCard
|
||||||
title={t('admin.channels.settings')}
|
title={t('admin.channels.settings')}
|
||||||
defaultOpen
|
defaultOpen
|
||||||
bare={bare}
|
bare={bare}
|
||||||
contentClassName="grid gap-4 sm:grid-cols-2"
|
contentClassName="grid gap-4 sm:grid-cols-2"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.channels.name')}</Label>
|
<Label>{t('admin.channels.name')}</Label>
|
||||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.channels.number')}</Label>
|
<Label>{t('admin.channels.number')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={number}
|
value={number}
|
||||||
placeholder={t('admin.channels.numberPlaceholder')}
|
placeholder={t('admin.channels.numberPlaceholder')}
|
||||||
onChange={(e) => setNumber(e.target.value)}
|
onChange={(e) => setNumber(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.channels.utcOffset')}</Label>
|
<Label>{t('admin.channels.utcOffset')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={-12}
|
min={-12}
|
||||||
max={14}
|
max={14}
|
||||||
step={1}
|
step={1}
|
||||||
value={offsetHours}
|
value={offsetHours}
|
||||||
onChange={(e) => setOffsetHours(Number(e.target.value))}
|
onChange={(e) => setOffsetHours(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.channels.utcOffsetHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('admin.channels.utcOffsetHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.channels.dayStart')}</Label>
|
<Label>{t('admin.channels.dayStart')}</Label>
|
||||||
<Input type="time" value={dayStart} onChange={(e) => setDayStart(e.target.value)} />
|
<Input type="time" value={dayStart} onChange={(e) => setDayStart(e.target.value)} />
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.channels.dayStartHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('admin.channels.dayStartHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.channels.filler')}</Label>
|
<Label>{t('admin.channels.filler')}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={fillerAssetId || 'none'}
|
value={fillerAssetId || 'none'}
|
||||||
onValueChange={(v) => setFillerAssetId(v === 'none' ? '' : v)}
|
onValueChange={(v) => setFillerAssetId(v === 'none' ? '' : v)}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">{t('admin.channels.noFiller')}</SelectItem>
|
<SelectItem value="none">{t('admin.channels.noFiller')}</SelectItem>
|
||||||
{readyAssets.map((a) => (
|
{readyAssets.map((a) => (
|
||||||
<SelectItem key={a.id} value={a.id}>
|
<SelectItem key={a.id} value={a.id}>
|
||||||
{a.originalFileName}
|
{a.originalFileName}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
<input
|
||||||
{t('admin.channels.enabledLabel')}
|
type="checkbox"
|
||||||
</label>
|
checked={isEnabled}
|
||||||
<div className="flex items-end justify-end sm:col-span-2">
|
onChange={(e) => setIsEnabled(e.target.checked)}
|
||||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
/>
|
||||||
{t('common.save')}
|
{t('admin.channels.enabledLabel')}
|
||||||
</Button>
|
</label>
|
||||||
</div>
|
<div className="flex items-end justify-end sm:col-span-2">
|
||||||
</CollapsibleCard>
|
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||||
)
|
{t('common.save')}
|
||||||
}
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CollapsibleCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,334 +1,338 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Eye } from 'lucide-react'
|
import { Eye } from 'lucide-react'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { qk } from '@/shared/api/query-keys'
|
import { qk } from '@/shared/api/query-keys'
|
||||||
import type { PlannedItemKind, PreviewItemDto, SchedulePreviewDto } from '@/shared/api/types'
|
import type { PlannedItemKind, PreviewItemDto, SchedulePreviewDto } from '@/shared/api/types'
|
||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { cn } from '@/shared/lib/cn'
|
import { cn } from '@/shared/lib/cn'
|
||||||
import { previewTemplate } from '../api'
|
import { previewTemplate } from '../api'
|
||||||
import { channelTime, formatChannelTime } from '../lib/format'
|
import { channelTime, formatChannelTime } from '../lib/format'
|
||||||
import { toIsoDate } from '../lib/applicability'
|
import { toIsoDate } from '../lib/applicability'
|
||||||
|
|
||||||
const KIND_COLORS: Record<PlannedItemKind, string> = {
|
const KIND_COLORS: Record<PlannedItemKind, string> = {
|
||||||
Program: 'bg-primary/70',
|
Program: 'bg-primary/70',
|
||||||
Fallback: 'bg-muted-foreground/40',
|
Fallback: 'bg-muted-foreground/40',
|
||||||
SignOff: 'bg-slate-500/60',
|
SignOff: 'bg-slate-500/60',
|
||||||
Ad: 'bg-amber-500/70',
|
Ad: 'bg-amber-500/70',
|
||||||
Promo: 'bg-sky-500/70',
|
Promo: 'bg-sky-500/70',
|
||||||
Bumper: 'bg-violet-500/70',
|
Bumper: 'bg-violet-500/70',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Что видит зритель как программу — врезки в программу передач не попадают. */
|
/** Что видит зритель как программу — врезки в программу передач не попадают. */
|
||||||
const PROGRAMME_KINDS = new Set<PlannedItemKind>(['Program', 'Fallback', 'SignOff'])
|
const PROGRAMME_KINDS = new Set<PlannedItemKind>(['Program', 'Fallback', 'SignOff'])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
|
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
|
||||||
* курсоров. Заставки приходят резервом известной длины — реальный рендер только при применении.
|
* курсоров. Заставки приходят резервом известной длины — реальный рендер только при применении.
|
||||||
*/
|
*/
|
||||||
export function TemplatePreview({ channelId }: Readonly<{ channelId: string }>) {
|
export function TemplatePreview({ channelId }: Readonly<{ channelId: string }>) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [days, setDays] = useState(1)
|
const [days, setDays] = useState(1)
|
||||||
const [tab, setTab] = useState<'programme' | 'tape' | 'problems'>('programme')
|
const [tab, setTab] = useState<'programme' | 'tape' | 'problems'>('programme')
|
||||||
|
|
||||||
const { data, isFetching } = useQuery({
|
const { data, isFetching } = useQuery({
|
||||||
queryKey: qk.channels.preview(channelId, days),
|
queryKey: qk.channels.preview(channelId, days),
|
||||||
queryFn: () => previewTemplate(channelId, days),
|
queryFn: () => previewTemplate(channelId, days),
|
||||||
enabled: open,
|
enabled: open,
|
||||||
// Черновик правил может меняться между открытиями — кэшировать прогон смысла нет.
|
// Черновик правил может меняться между открытиями — кэшировать прогон смысла нет.
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
gcTime: 0,
|
gcTime: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button size="sm" variant="outline" onClick={() => setOpen((v) => !v)}>
|
<Button size="sm" variant="outline" onClick={() => setOpen((v) => !v)}>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
{open ? t('admin.channels.previewHide') : t('admin.channels.preview')}
|
{open ? t('admin.channels.previewHide') : t('admin.channels.preview')}
|
||||||
</Button>
|
</Button>
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
<select
|
<select
|
||||||
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
||||||
value={days}
|
value={days}
|
||||||
onChange={(e) => setDays(Number(e.target.value))}
|
onChange={(e) => setDays(Number(e.target.value))}
|
||||||
>
|
>
|
||||||
{[1, 3, 7].map((value) => (
|
{[1, 3, 7].map((value) => (
|
||||||
<option key={value} value={value}>
|
<option key={value} value={value}>
|
||||||
{t('admin.channels.previewDays', { count: value })}
|
{t('admin.channels.previewDays', { count: value })}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{isFetching ? t('common.loading') : t('admin.channels.previewHint')}
|
{isFetching ? t('common.loading') : t('admin.channels.previewHint')}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{open && data && (
|
{open && data && (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex gap-2 border-b border-border text-xs uppercase tracking-wide">
|
<div className="flex gap-2 border-b border-border text-xs uppercase tracking-wide">
|
||||||
{(['programme', 'tape', 'problems'] as const).map((value) => (
|
{(['programme', 'tape', 'problems'] as const).map((value) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab(value)}
|
onClick={() => setTab(value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'pb-1 text-muted-foreground hover:text-foreground',
|
'pb-1 text-muted-foreground hover:text-foreground',
|
||||||
tab === value && 'border-b-2 border-primary text-primary',
|
tab === value && 'border-b-2 border-primary text-primary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t(`admin.channels.previewTabs.${value}`)}
|
{t(`admin.channels.previewTabs.${value}`)}
|
||||||
{value === 'problems' && data.warnings.length > 0 && ` · ${data.warnings.length}`}
|
{value === 'problems' && data.warnings.length > 0 && ` · ${data.warnings.length}`}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'programme' && <Programme preview={data} />}
|
{tab === 'programme' && <Programme preview={data} />}
|
||||||
{tab === 'tape' && <Tape preview={data} />}
|
{tab === 'tape' && <Tape preview={data} />}
|
||||||
{tab === 'problems' && <Problems preview={data} />}
|
{tab === 'problems' && <Problems preview={data} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Programme({ preview }: Readonly<{ preview: SchedulePreviewDto }>) {
|
function Programme({ preview }: Readonly<{ preview: SchedulePreviewDto }>) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const items = preview.items.filter((i) => PROGRAMME_KINDS.has(i.kind))
|
const items = preview.items.filter((i) => PROGRAMME_KINDS.has(i.kind))
|
||||||
|
|
||||||
if (items.length === 0)
|
if (items.length === 0)
|
||||||
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="flex flex-col divide-y divide-border text-sm">
|
<ul className="flex flex-col divide-y divide-border text-sm">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<li key={`${item.startsAtUtc}-${index}`} className="flex items-center gap-3 py-1.5">
|
<li key={`${item.startsAtUtc}-${index}`} className="flex items-center gap-3 py-1.5">
|
||||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||||
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
|
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1 truncate">
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{item.title ?? t(`admin.channels.previewKinds.${item.kind}`)}
|
{item.title ?? t(`admin.channels.previewKinds.${item.kind}`)}
|
||||||
</span>
|
</span>
|
||||||
{item.slotTitle && (
|
{item.slotTitle && (
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">{item.slotTitle}</span>
|
<span className="shrink-0 text-xs text-muted-foreground">{item.slotTitle}</span>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Час вещания → сколько в нём минут врезок. По ним же строится гистограмма нагрузки. */
|
/** Час вещания → сколько в нём минут врезок. По ним же строится гистограмма нагрузки. */
|
||||||
function loadByHour(preview: SchedulePreviewDto): { hour: Date; minutes: number }[] {
|
function loadByHour(preview: SchedulePreviewDto): { hour: Date; minutes: number }[] {
|
||||||
const buckets = new Map<number, number>()
|
const buckets = new Map<number, number>()
|
||||||
for (const item of preview.items) {
|
for (const item of preview.items) {
|
||||||
if (item.kind !== 'Ad' && item.kind !== 'Promo' && item.kind !== 'Bumper') continue
|
if (item.kind !== 'Ad' && item.kind !== 'Promo' && item.kind !== 'Bumper') continue
|
||||||
const start = channelTime(item.startsAtUtc, preview.utcOffsetMinutes)
|
const start = channelTime(item.startsAtUtc, preview.utcOffsetMinutes)
|
||||||
const hour = Date.UTC(
|
const hour = Date.UTC(
|
||||||
start.getUTCFullYear(),
|
start.getUTCFullYear(),
|
||||||
start.getUTCMonth(),
|
start.getUTCMonth(),
|
||||||
start.getUTCDate(),
|
start.getUTCDate(),
|
||||||
start.getUTCHours(),
|
start.getUTCHours(),
|
||||||
)
|
)
|
||||||
const minutes = (new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
|
const minutes =
|
||||||
buckets.set(hour, (buckets.get(hour) ?? 0) + 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])
|
return [...buckets.entries()]
|
||||||
.map(([hour, minutes]) => ({ hour: new Date(hour), minutes }))
|
.sort((a, b) => a[0] - b[0])
|
||||||
}
|
.map(([hour, minutes]) => ({ hour: new Date(hour), minutes }))
|
||||||
|
}
|
||||||
function Tape({ preview }: Readonly<{ preview: SchedulePreviewDto }>) {
|
|
||||||
const { t } = useTranslation()
|
function Tape({ preview }: Readonly<{ preview: SchedulePreviewDto }>) {
|
||||||
const load = useMemo(() => loadByHour(preview), [preview])
|
const { t } = useTranslation()
|
||||||
const peak = Math.max(1, ...load.map((l) => l.minutes))
|
const load = useMemo(() => loadByHour(preview), [preview])
|
||||||
|
const peak = Math.max(1, ...load.map((l) => l.minutes))
|
||||||
if (preview.items.length === 0)
|
|
||||||
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
if (preview.items.length === 0)
|
||||||
|
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
return (
|
||||||
{load.length > 0 && (
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
{load.length > 0 && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="flex flex-col gap-1">
|
||||||
{t('admin.channels.previewLoad', { peak: Math.round(peak) })}
|
<span className="text-xs text-muted-foreground">
|
||||||
</span>
|
{t('admin.channels.previewLoad', { peak: Math.round(peak) })}
|
||||||
<div className="flex h-16 items-end gap-px">
|
</span>
|
||||||
{load.map((bucket) => (
|
<div className="flex h-16 items-end gap-px">
|
||||||
<div
|
{load.map((bucket) => (
|
||||||
key={bucket.hour.toISOString()}
|
<div
|
||||||
className="flex-1 bg-amber-500/70"
|
key={bucket.hour.toISOString()}
|
||||||
style={{ height: `${(bucket.minutes / peak) * 100}%` }}
|
className="flex-1 bg-amber-500/70"
|
||||||
title={`${String(bucket.hour.getUTCHours()).padStart(2, '0')}:00 · ${Math.round(bucket.minutes)} ${t('admin.groups.minutesShort')}`}
|
style={{ height: `${(bucket.minutes / peak) * 100}%` }}
|
||||||
/>
|
title={`${String(bucket.hour.getUTCHours()).padStart(2, '0')}:00 · ${Math.round(bucket.minutes)} ${t('admin.groups.minutesShort')}`}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
<ul className="flex flex-col gap-0.5 text-xs">
|
|
||||||
{preview.items.map((item, index) => (
|
<ul className="flex flex-col gap-0.5 text-xs">
|
||||||
<TapeRow key={`${item.startsAtUtc}-${index}`} item={item} preview={preview} />
|
{preview.items.map((item, index) => (
|
||||||
))}
|
<TapeRow key={`${item.startsAtUtc}-${index}`} item={item} preview={preview} />
|
||||||
</ul>
|
))}
|
||||||
</div>
|
</ul>
|
||||||
)
|
</div>
|
||||||
}
|
)
|
||||||
|
}
|
||||||
function TapeRow({
|
|
||||||
item,
|
function TapeRow({
|
||||||
preview,
|
item,
|
||||||
}: Readonly<{ item: PreviewItemDto; preview: SchedulePreviewDto }>) {
|
preview,
|
||||||
const { t } = useTranslation()
|
}: Readonly<{ item: PreviewItemDto; preview: SchedulePreviewDto }>) {
|
||||||
const minutes =
|
const { t } = useTranslation()
|
||||||
(new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
|
const minutes =
|
||||||
|
(new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
|
||||||
return (
|
|
||||||
<li className="flex items-center gap-2">
|
return (
|
||||||
<span className="w-10 shrink-0 tabular-nums text-muted-foreground">
|
<li className="flex items-center gap-2">
|
||||||
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
|
<span className="w-10 shrink-0 tabular-nums text-muted-foreground">
|
||||||
</span>
|
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
|
||||||
<span className={cn('h-2 shrink-0 rounded-sm', KIND_COLORS[item.kind])} style={{ width: `${Math.max(4, minutes * 2)}px` }} />
|
</span>
|
||||||
<Badge variant="muted" className="shrink-0">
|
<span
|
||||||
{t(`admin.channels.previewKinds.${item.kind}`)}
|
className={cn('h-2 shrink-0 rounded-sm', KIND_COLORS[item.kind])}
|
||||||
</Badge>
|
style={{ width: `${Math.max(4, minutes * 2)}px` }}
|
||||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">{item.title ?? ''}</span>
|
/>
|
||||||
</li>
|
<Badge variant="muted" className="shrink-0">
|
||||||
)
|
{t(`admin.channels.previewKinds.${item.kind}`)}
|
||||||
}
|
</Badge>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-muted-foreground">{item.title ?? ''}</span>
|
||||||
/** Предупреждения, сгруппированные по виду: десять однотипных строк читаются как одна проблема. */
|
</li>
|
||||||
function Problems({ preview }: Readonly<{ preview: SchedulePreviewDto }>) {
|
)
|
||||||
const { t } = useTranslation()
|
}
|
||||||
// Ключ строки — «вид + порядковый номер внутри вида»: у предупреждения нет своего id, а текст
|
|
||||||
// повторяется (одна и та же причина на разных слотах), и позиция здесь — единственное, что его
|
/** Предупреждения, сгруппированные по виду: десять однотипных строк читаются как одна проблема. */
|
||||||
// отличает. Считаем ключ при группировке, чтобы список не пересобирался на каждый рендер.
|
function Problems({ preview }: Readonly<{ preview: SchedulePreviewDto }>) {
|
||||||
const grouped = useMemo(() => {
|
const { t } = useTranslation()
|
||||||
const map = new Map<string, { key: string; text: string }[]>()
|
// Ключ строки — «вид + порядковый номер внутри вида»: у предупреждения нет своего id, а текст
|
||||||
for (const warning of preview.warnings) {
|
// повторяется (одна и та же причина на разных слотах), и позиция здесь — единственное, что его
|
||||||
const list = map.get(warning.kind) ?? []
|
// отличает. Считаем ключ при группировке, чтобы список не пересобирался на каждый рендер.
|
||||||
list.push({ key: `${warning.kind}#${list.length}`, text: warning.details })
|
const grouped = useMemo(() => {
|
||||||
map.set(warning.kind, list)
|
const map = new Map<string, { key: string; text: string }[]>()
|
||||||
}
|
for (const warning of preview.warnings) {
|
||||||
return [...map.entries()]
|
const list = map.get(warning.kind) ?? []
|
||||||
}, [preview])
|
list.push({ key: `${warning.kind}#${list.length}`, text: warning.details })
|
||||||
|
map.set(warning.kind, list)
|
||||||
return (
|
}
|
||||||
<div className="flex flex-col gap-4">
|
return [...map.entries()]
|
||||||
{grouped.length === 0 ? (
|
}, [preview])
|
||||||
<p className="text-sm text-muted-foreground">{t('admin.channels.noProblems')}</p>
|
|
||||||
) : (
|
return (
|
||||||
<ul className="flex flex-col gap-2 text-xs">
|
<div className="flex flex-col gap-4">
|
||||||
{grouped.map(([kind, details]) => (
|
{grouped.length === 0 ? (
|
||||||
<li key={kind} className="flex flex-col gap-0.5">
|
<p className="text-sm text-muted-foreground">{t('admin.channels.noProblems')}</p>
|
||||||
<span className="font-medium text-amber-500">
|
) : (
|
||||||
{t(`admin.channels.warnings.${kind}`)} · {details.length}
|
<ul className="flex flex-col gap-2 text-xs">
|
||||||
</span>
|
{grouped.map(([kind, details]) => (
|
||||||
{details.slice(0, 20).map((detail) => (
|
<li key={kind} className="flex flex-col gap-0.5">
|
||||||
<span key={detail.key} className="text-muted-foreground">
|
<span className="font-medium text-amber-500">
|
||||||
{detail.text}
|
{t(`admin.channels.warnings.${kind}`)} · {details.length}
|
||||||
</span>
|
</span>
|
||||||
))}
|
{details.slice(0, 20).map((detail) => (
|
||||||
{details.length > 20 && (
|
<span key={detail.key} className="text-muted-foreground">
|
||||||
<span className="text-muted-foreground">
|
{detail.text}
|
||||||
{t('admin.channels.andMore', { count: details.length - 20 })}
|
</span>
|
||||||
</span>
|
))}
|
||||||
)}
|
{details.length > 20 && (
|
||||||
</li>
|
<span className="text-muted-foreground">
|
||||||
))}
|
{t('admin.channels.andMore', { count: details.length - 20 })}
|
||||||
</ul>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</li>
|
||||||
<RepeatHeatmap preview={preview} />
|
))}
|
||||||
</div>
|
</ul>
|
||||||
)
|
)}
|
||||||
}
|
|
||||||
|
<RepeatHeatmap preview={preview} />
|
||||||
/**
|
</div>
|
||||||
* Тепловая карта повторов: матрица «шоу × вещательные сутки», яркость — число показов. Сразу видно,
|
)
|
||||||
* что один фильм крутится четыре раза за неделю.
|
}
|
||||||
*/
|
|
||||||
function RepeatHeatmap({ preview }: Readonly<{ preview: SchedulePreviewDto }>) {
|
/**
|
||||||
const { t } = useTranslation()
|
* Тепловая карта повторов: матрица «шоу × вещательные сутки», яркость — число показов. Сразу видно,
|
||||||
|
* что один фильм крутится четыре раза за неделю.
|
||||||
const { days, rows } = useMemo(() => {
|
*/
|
||||||
const counts = new Map<string, Map<string, number>>()
|
function RepeatHeatmap({ preview }: Readonly<{ preview: SchedulePreviewDto }>) {
|
||||||
const dayKeys = new Set<string>()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
for (const item of preview.items) {
|
const { days, rows } = useMemo(() => {
|
||||||
if (item.kind !== 'Program' || !item.title) continue
|
const counts = new Map<string, Map<string, number>>()
|
||||||
const day = toIsoDate(channelTime(item.startsAtUtc, preview.utcOffsetMinutes))
|
const dayKeys = new Set<string>()
|
||||||
dayKeys.add(day)
|
|
||||||
const row = counts.get(item.title) ?? new Map<string, number>()
|
for (const item of preview.items) {
|
||||||
row.set(day, (row.get(day) ?? 0) + 1)
|
if (item.kind !== 'Program' || !item.title) continue
|
||||||
counts.set(item.title, row)
|
const day = toIsoDate(channelTime(item.startsAtUtc, preview.utcOffsetMinutes))
|
||||||
}
|
dayKeys.add(day)
|
||||||
|
const row = counts.get(item.title) ?? new Map<string, number>()
|
||||||
const sortedDays = [...dayKeys].sort((a, b) => a.localeCompare(b))
|
row.set(day, (row.get(day) ?? 0) + 1)
|
||||||
const sortedRows = [...counts.entries()]
|
counts.set(item.title, row)
|
||||||
.map(([title, byDay]) => ({
|
}
|
||||||
title,
|
|
||||||
byDay,
|
const sortedDays = [...dayKeys].sort((a, b) => a.localeCompare(b))
|
||||||
total: [...byDay.values()].reduce((sum, n) => sum + n, 0),
|
const sortedRows = [...counts.entries()]
|
||||||
}))
|
.map(([title, byDay]) => ({
|
||||||
.sort((a, b) => b.total - a.total)
|
title,
|
||||||
.slice(0, 25)
|
byDay,
|
||||||
|
total: [...byDay.values()].reduce((sum, n) => sum + n, 0),
|
||||||
return { days: sortedDays, rows: sortedRows }
|
}))
|
||||||
}, [preview])
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.slice(0, 25)
|
||||||
if (rows.length === 0) return null
|
|
||||||
|
return { days: sortedDays, rows: sortedRows }
|
||||||
const peak = Math.max(1, ...rows.flatMap((row) => [...row.byDay.values()]))
|
}, [preview])
|
||||||
|
|
||||||
return (
|
if (rows.length === 0) return null
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
const peak = Math.max(1, ...rows.flatMap((row) => [...row.byDay.values()]))
|
||||||
{t('admin.channels.heatmap')}
|
|
||||||
</span>
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="flex flex-col gap-1">
|
||||||
<table className="text-[11px]">
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
<thead className="text-muted-foreground">
|
{t('admin.channels.heatmap')}
|
||||||
<tr>
|
</span>
|
||||||
<th className="px-1 text-left font-medium" />
|
<div className="overflow-x-auto">
|
||||||
{days.map((day) => (
|
<table className="text-[11px]">
|
||||||
<th key={day} className="px-1 font-medium">
|
<thead className="text-muted-foreground">
|
||||||
{day.slice(8)}.{day.slice(5, 7)}
|
<tr>
|
||||||
</th>
|
<th className="px-1 text-left font-medium" />
|
||||||
))}
|
{days.map((day) => (
|
||||||
<th className="px-1 font-medium">{t('admin.channels.heatmapTotal')}</th>
|
<th key={day} className="px-1 font-medium">
|
||||||
</tr>
|
{day.slice(8)}.{day.slice(5, 7)}
|
||||||
</thead>
|
</th>
|
||||||
<tbody>
|
))}
|
||||||
{rows.map((row) => (
|
<th className="px-1 font-medium">{t('admin.channels.heatmapTotal')}</th>
|
||||||
<tr key={row.title}>
|
</tr>
|
||||||
<td className="max-w-56 truncate px-1" title={row.title}>
|
</thead>
|
||||||
{row.title}
|
<tbody>
|
||||||
</td>
|
{rows.map((row) => (
|
||||||
{days.map((day) => {
|
<tr key={row.title}>
|
||||||
const count = row.byDay.get(day) ?? 0
|
<td className="max-w-56 truncate px-1" title={row.title}>
|
||||||
return (
|
{row.title}
|
||||||
<td key={day} className="px-0.5 py-0.5">
|
</td>
|
||||||
<span
|
{days.map((day) => {
|
||||||
className="block h-4 w-6 rounded-sm bg-primary text-center text-[10px] leading-4"
|
const count = row.byDay.get(day) ?? 0
|
||||||
style={{ opacity: count === 0 ? 0.06 : 0.25 + (count / peak) * 0.75 }}
|
return (
|
||||||
>
|
<td key={day} className="px-0.5 py-0.5">
|
||||||
{count > 0 ? count : ''}
|
<span
|
||||||
</span>
|
className="block h-4 w-6 rounded-sm bg-primary text-center text-[10px] leading-4"
|
||||||
</td>
|
style={{ opacity: count === 0 ? 0.06 : 0.25 + (count / peak) * 0.75 }}
|
||||||
)
|
>
|
||||||
})}
|
{count > 0 ? count : ''}
|
||||||
<td className="px-1 tabular-nums text-muted-foreground">{row.total}</td>
|
</span>
|
||||||
</tr>
|
</td>
|
||||||
))}
|
)
|
||||||
</tbody>
|
})}
|
||||||
</table>
|
<td className="px-1 tabular-nums text-muted-foreground">{row.total}</td>
|
||||||
</div>
|
</tr>
|
||||||
</div>
|
))}
|
||||||
)
|
</tbody>
|
||||||
}
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -149,7 +149,11 @@ export function CollectionDetail({ collectionId }: Readonly<{ collectionId: stri
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Button size="sm" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,13 +10,7 @@ import type { GenreDto } from '@/shared/api/types'
|
|||||||
import { useApiError } from '@/shared/lib/use-api-error'
|
import { useApiError } from '@/shared/lib/use-api-error'
|
||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import {
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/shared/ui/dialog'
|
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { sortRows, useTableSort } from '@/shared/lib/table-sort'
|
import { sortRows, useTableSort } from '@/shared/lib/table-sort'
|
||||||
|
|||||||
@@ -170,7 +170,12 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
|
|||||||
<GroupFilterPanel filter={filter ?? EMPTY_FILTER} onChange={setFilter} />
|
<GroupFilterPanel filter={filter ?? EMPTY_FILTER} onChange={setFilter} />
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button size="sm" variant="outline" disabled={findMutation.isPending} onClick={() => findMutation.mutate()}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={findMutation.isPending}
|
||||||
|
onClick={() => findMutation.mutate()}
|
||||||
|
>
|
||||||
<Search className="h-4 w-4" /> {t('admin.groups.find')}
|
<Search className="h-4 w-4" /> {t('admin.groups.find')}
|
||||||
</Button>
|
</Button>
|
||||||
{candidates !== null && (
|
{candidates !== null && (
|
||||||
|
|||||||
@@ -109,11 +109,7 @@ export function BlockBuilder({
|
|||||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||||
{formatClock(item.seconds)}
|
{formatClock(item.seconds)}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button size="sm" variant="ghost" onClick={() => removeAt(index)}>
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => removeAt(index)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ export function MaintenancePanel() {
|
|||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={clearMedia.isPending}
|
disabled={clearMedia.isPending}
|
||||||
onClick={() => confirmed(t('admin.maintenance.confirmClearMedia'), () => clearMedia.mutate())}
|
onClick={() =>
|
||||||
|
confirmed(t('admin.maintenance.confirmClearMedia'), () => clearMedia.mutate())
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t('admin.maintenance.clearMedia')}
|
{t('admin.maintenance.clearMedia')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -121,7 +123,9 @@ export function MaintenancePanel() {
|
|||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={clearShows.isPending}
|
disabled={clearShows.isPending}
|
||||||
onClick={() => confirmed(t('admin.maintenance.confirmDeleteShows'), () => clearShows.mutate())}
|
onClick={() =>
|
||||||
|
confirmed(t('admin.maintenance.confirmDeleteShows'), () => clearShows.mutate())
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t('admin.maintenance.deleteShows')}
|
{t('admin.maintenance.deleteShows')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,455 +1,461 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { ChevronDown, ChevronRight, Folder } from 'lucide-react'
|
import { ChevronDown, ChevronRight, Folder } from 'lucide-react'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { listShows } from '@/features/admin/shows/api'
|
import { listShows } from '@/features/admin/shows/api'
|
||||||
import { qk } from '@/shared/api/query-keys'
|
import { qk } from '@/shared/api/query-keys'
|
||||||
import type { ManualInboxFileDto } from '@/shared/api/types'
|
import type { ManualInboxFileDto } from '@/shared/api/types'
|
||||||
import { useApiError } from '@/shared/lib/use-api-error'
|
import { useApiError } from '@/shared/lib/use-api-error'
|
||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/shared/ui/dialog'
|
} from '@/shared/ui/dialog'
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { importManualInbox, listManualInbox } from './api'
|
import { importManualInbox, listManualInbox } from './api'
|
||||||
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
|
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
|
||||||
import { buildEpisodeRegex, findNumbers, REGEX_PRESETS } from './episode-regex'
|
import { buildEpisodeRegex, findNumbers, REGEX_PRESETS } from './episode-regex'
|
||||||
import { matchShowByName } from './match-show'
|
import { matchShowByName } from './match-show'
|
||||||
|
|
||||||
/** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */
|
/** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
const units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ']
|
const units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ']
|
||||||
let value = bytes
|
let value = bytes
|
||||||
let unit = 0
|
let unit = 0
|
||||||
while (value >= 1024 && unit < units.length - 1) {
|
while (value >= 1024 && unit < units.length - 1) {
|
||||||
value /= 1024
|
value /= 1024
|
||||||
unit++
|
unit++
|
||||||
}
|
}
|
||||||
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`
|
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ручной разбор `manual/`: каталог не сканируется, файлы выбирает админ и сразу указывает шоу.
|
* Ручной разбор `manual/`: каталог не сканируется, файлы выбирает админ и сразу указывает шоу.
|
||||||
* Импортированные файлы уходят из каталога — ровно как из обычного inbox, — а спутники (субтитры,
|
* Импортированные файлы уходят из каталога — ровно как из обычного inbox, — а спутники (субтитры,
|
||||||
* nfo) удаляются, чтобы не оставалось мусора.
|
* nfo) удаляются, чтобы не оставалось мусора.
|
||||||
*
|
*
|
||||||
* Номера сезона и серии распознаются здесь же и в таком виде и уходят на сервер: что показано,
|
* Номера сезона и серии распознаются здесь же и в таком виде и уходят на сервер: что показано,
|
||||||
* то и сохранится.
|
* то и сохранится.
|
||||||
*/
|
*/
|
||||||
export function ManualInboxDialog({ onClose }: Readonly<{ onClose: () => void }>) {
|
export function ManualInboxDialog({ onClose }: Readonly<{ onClose: () => void }>) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [selected, setSelected] = useState<string[]>([])
|
const [selected, setSelected] = useState<string[]>([])
|
||||||
const [showId, setShowId] = useState('')
|
const [showId, setShowId] = useState('')
|
||||||
// Ручной выбор шоу отключает автоопределение: перебивать решение человека нельзя.
|
// Ручной выбор шоу отключает автоопределение: перебивать решение человека нельзя.
|
||||||
const [showPicked, setShowPicked] = useState(false)
|
const [showPicked, setShowPicked] = useState(false)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [seasonStr, setSeasonStr] = useState('')
|
const [seasonStr, setSeasonStr] = useState('')
|
||||||
const [regexStr, setRegexStr] = useState('')
|
const [regexStr, setRegexStr] = useState('')
|
||||||
const [collapsed, setCollapsed] = useState<string[]>([])
|
const [collapsed, setCollapsed] = useState<string[]>([])
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: qk.media.manual,
|
queryKey: qk.media.manual,
|
||||||
queryFn: listManualInbox,
|
queryFn: listManualInbox,
|
||||||
})
|
})
|
||||||
const { data: shows } = useQuery({ queryKey: qk.shows.all, queryFn: () => listShows() })
|
const { data: shows } = useQuery({ queryKey: qk.shows.all, queryFn: () => listShows() })
|
||||||
|
|
||||||
const regexOk = isValidRegex(regexStr)
|
const regexOk = isValidRegex(regexStr)
|
||||||
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
|
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
|
||||||
|
|
||||||
// Распознанные номера считаются один раз на всё: их показывает список и их же уходит на сервер.
|
// Распознанные номера считаются один раз на всё: их показывает список и их же уходит на сервер.
|
||||||
const parsedByPath = useMemo(() => {
|
const parsedByPath = useMemo(() => {
|
||||||
const options = {
|
const options = {
|
||||||
seasonOverride:
|
seasonOverride:
|
||||||
seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null,
|
seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null,
|
||||||
episodeRegex: regexOk ? regexStr : null,
|
episodeRegex: regexOk ? regexStr : null,
|
||||||
}
|
}
|
||||||
const map = new Map<string, ReturnType<typeof parseEpisodeName>>()
|
const map = new Map<string, ReturnType<typeof parseEpisodeName>>()
|
||||||
for (const file of data?.files ?? []) map.set(file.relativePath, parseEpisodeName(file.name, options))
|
for (const file of data?.files ?? [])
|
||||||
return map
|
map.set(file.relativePath, parseEpisodeName(file.name, options))
|
||||||
}, [data, seasonOverride, regexStr, regexOk])
|
return map
|
||||||
|
}, [data, seasonOverride, regexStr, regexOk])
|
||||||
const folders = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase()
|
const folders = useMemo(() => {
|
||||||
const matched = (data?.files ?? []).filter((file) =>
|
const q = query.trim().toLowerCase()
|
||||||
q ? file.relativePath.toLowerCase().includes(q) : true,
|
const matched = (data?.files ?? []).filter((file) =>
|
||||||
)
|
q ? file.relativePath.toLowerCase().includes(q) : true,
|
||||||
|
)
|
||||||
const grouped = new Map<string, ManualInboxFileDto[]>()
|
|
||||||
for (const file of matched) {
|
const grouped = new Map<string, ManualInboxFileDto[]>()
|
||||||
const list = grouped.get(file.folder) ?? []
|
for (const file of matched) {
|
||||||
list.push(file)
|
const list = grouped.get(file.folder) ?? []
|
||||||
grouped.set(file.folder, list)
|
list.push(file)
|
||||||
}
|
grouped.set(file.folder, list)
|
||||||
|
}
|
||||||
// Внутри папки — в порядке серий: так сразу видно пропуски и что регулярка сработала.
|
|
||||||
return [...grouped.entries()]
|
// Внутри папки — в порядке серий: так сразу видно пропуски и что регулярка сработала.
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
return [...grouped.entries()]
|
||||||
.map(([folder, files]) => ({
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
folder,
|
.map(([folder, files]) => ({
|
||||||
files: [...files].sort((a, b) =>
|
folder,
|
||||||
compareParsed(
|
files: [...files].sort((a, b) =>
|
||||||
{ name: a.name, parsed: parsedByPath.get(a.relativePath) ?? { season: null, episode: null } },
|
compareParsed(
|
||||||
{ name: b.name, parsed: parsedByPath.get(b.relativePath) ?? { season: null, episode: null } },
|
{
|
||||||
),
|
name: a.name,
|
||||||
),
|
parsed: parsedByPath.get(a.relativePath) ?? { season: null, episode: null },
|
||||||
}))
|
},
|
||||||
}, [data, query, parsedByPath])
|
{
|
||||||
|
name: b.name,
|
||||||
const selectable = folders.flatMap((g) => g.files.filter((f) => !f.alreadyImported))
|
parsed: parsedByPath.get(b.relativePath) ?? { season: null, episode: null },
|
||||||
|
},
|
||||||
// Образец для конструктора — первый файл списка: по нему и указывают, где номер серии.
|
),
|
||||||
const sample = selectable[0] ?? folders[0]?.files[0]
|
),
|
||||||
const sampleParts = useMemo(() => {
|
}))
|
||||||
if (!sample) return []
|
}, [data, query, parsedByPath])
|
||||||
const numbers = findNumbers(sample.name)
|
|
||||||
// start — позиция куска в имени файла: она уникальна в пределах образца и годится как key,
|
const selectable = folders.flatMap((g) => g.files.filter((f) => !f.alreadyImported))
|
||||||
// в отличие от индекса (куски одинакового текста встречаются в имени по нескольку раз).
|
|
||||||
const parts: { start: number; text: string; number: number | null }[] = []
|
// Образец для конструктора — первый файл списка: по нему и указывают, где номер серии.
|
||||||
let cursor = 0
|
const sample = selectable[0] ?? folders[0]?.files[0]
|
||||||
for (const number of numbers) {
|
const sampleParts = useMemo(() => {
|
||||||
if (number.start > cursor)
|
if (!sample) return []
|
||||||
parts.push({
|
const numbers = findNumbers(sample.name)
|
||||||
start: cursor,
|
// start — позиция куска в имени файла: она уникальна в пределах образца и годится как key,
|
||||||
text: sample.name.slice(cursor, number.start),
|
// в отличие от индекса (куски одинакового текста встречаются в имени по нескольку раз).
|
||||||
number: null,
|
const parts: { start: number; text: string; number: number | null }[] = []
|
||||||
})
|
let cursor = 0
|
||||||
parts.push({ start: number.start, text: number.text, number: number.index })
|
for (const number of numbers) {
|
||||||
cursor = number.start + number.text.length
|
if (number.start > cursor)
|
||||||
}
|
parts.push({
|
||||||
if (cursor < sample.name.length)
|
start: cursor,
|
||||||
parts.push({ start: cursor, text: sample.name.slice(cursor), number: null })
|
text: sample.name.slice(cursor, number.start),
|
||||||
return parts
|
number: null,
|
||||||
}, [sample])
|
})
|
||||||
/**
|
parts.push({ start: number.start, text: number.text, number: number.index })
|
||||||
* Автоопределение шоу по имени релиза — то же, что в загрузке в шоу. Сначала пробуем имя файла,
|
cursor = number.start + number.text.length
|
||||||
* затем имя папки: в раздачах название сериала обычно есть и там, и там («Mr.Pickles.S01.1080p»).
|
}
|
||||||
*/
|
if (cursor < sample.name.length)
|
||||||
const detectedShowId = useMemo(
|
parts.push({ start: cursor, text: sample.name.slice(cursor), number: null })
|
||||||
() =>
|
return parts
|
||||||
shows && sample
|
}, [sample])
|
||||||
? (matchShowByName(sample.name, shows) ?? matchShowByName(sample.folder, shows))
|
/**
|
||||||
: undefined,
|
* Автоопределение шоу по имени релиза — то же, что в загрузке в шоу. Сначала пробуем имя файла,
|
||||||
[shows, sample],
|
* затем имя папки: в раздачах название сериала обычно есть и там, и там («Mr.Pickles.S01.1080p»).
|
||||||
)
|
*/
|
||||||
|
const detectedShowId = useMemo(
|
||||||
useEffect(() => {
|
() =>
|
||||||
if (showPicked || showId || !detectedShowId) return
|
shows && sample
|
||||||
setShowId(detectedShowId)
|
? (matchShowByName(sample.name, shows) ?? matchShowByName(sample.folder, shows))
|
||||||
}, [detectedShowId, showPicked, showId])
|
: undefined,
|
||||||
|
[shows, sample],
|
||||||
const autoDetected = !showPicked && !!detectedShowId && showId === detectedShowId
|
)
|
||||||
|
|
||||||
const recognized = selectable.filter(
|
useEffect(() => {
|
||||||
(f) => parsedByPath.get(f.relativePath)?.episode != null,
|
if (showPicked || showId || !detectedShowId) return
|
||||||
).length
|
setShowId(detectedShowId)
|
||||||
|
}, [detectedShowId, showPicked, showId])
|
||||||
const onError = useApiError()
|
|
||||||
|
const autoDetected = !showPicked && !!detectedShowId && showId === detectedShowId
|
||||||
const importMutation = useMutation({
|
|
||||||
mutationFn: () =>
|
const recognized = selectable.filter(
|
||||||
importManualInbox(
|
(f) => parsedByPath.get(f.relativePath)?.episode != null,
|
||||||
selected.map((relativePath) => {
|
).length
|
||||||
const parsed = parsedByPath.get(relativePath)
|
|
||||||
return {
|
const onError = useApiError()
|
||||||
relativePath,
|
|
||||||
season: parsed?.episode != null ? (parsed.season ?? 1) : null,
|
const importMutation = useMutation({
|
||||||
episode: parsed?.episode ?? null,
|
mutationFn: () =>
|
||||||
}
|
importManualInbox(
|
||||||
}),
|
selected.map((relativePath) => {
|
||||||
showId,
|
const parsed = parsedByPath.get(relativePath)
|
||||||
),
|
return {
|
||||||
onSuccess: (result) => {
|
relativePath,
|
||||||
if (result.imported > 0)
|
season: parsed?.episode != null ? (parsed.season ?? 1) : null,
|
||||||
toast.success(t('admin.media.manualImported', { count: result.imported }))
|
episode: parsed?.episode ?? null,
|
||||||
// Отказы показываем по одному: у каждого своя причина, и файл остаётся в каталоге.
|
}
|
||||||
for (const failure of result.failed)
|
}),
|
||||||
toast.error(`${failure.relativePath}: ${failure.reason}`)
|
showId,
|
||||||
|
),
|
||||||
setSelected([])
|
onSuccess: (result) => {
|
||||||
void queryClient.invalidateQueries({ queryKey: qk.media.all })
|
if (result.imported > 0)
|
||||||
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
|
toast.success(t('admin.media.manualImported', { count: result.imported }))
|
||||||
if (result.failed.length === 0) onClose()
|
// Отказы показываем по одному: у каждого своя причина, и файл остаётся в каталоге.
|
||||||
},
|
for (const failure of result.failed) toast.error(`${failure.relativePath}: ${failure.reason}`)
|
||||||
onError,
|
|
||||||
})
|
setSelected([])
|
||||||
|
void queryClient.invalidateQueries({ queryKey: qk.media.all })
|
||||||
const toggle = (path: string) =>
|
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
|
||||||
setSelected((current) =>
|
if (result.failed.length === 0) onClose()
|
||||||
current.includes(path) ? current.filter((p) => p !== path) : [...current, path],
|
},
|
||||||
)
|
onError,
|
||||||
|
})
|
||||||
const toggleCollapsed = (folder: string) =>
|
|
||||||
setCollapsed((current) =>
|
const toggle = (path: string) =>
|
||||||
current.includes(folder) ? current.filter((f) => f !== folder) : [...current, folder],
|
setSelected((current) =>
|
||||||
)
|
current.includes(path) ? current.filter((p) => p !== path) : [...current, path],
|
||||||
|
)
|
||||||
const toggleFolder = (files: ManualInboxFileDto[]) => {
|
|
||||||
const paths = files.filter((f) => !f.alreadyImported).map((f) => f.relativePath)
|
const toggleCollapsed = (folder: string) =>
|
||||||
const allSelected = paths.every((p) => selected.includes(p))
|
setCollapsed((current) =>
|
||||||
setSelected((current) =>
|
current.includes(folder) ? current.filter((f) => f !== folder) : [...current, folder],
|
||||||
allSelected
|
)
|
||||||
? current.filter((p) => !paths.includes(p))
|
|
||||||
: [...new Set([...current, ...paths])],
|
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) =>
|
||||||
return (
|
allSelected
|
||||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
? current.filter((p) => !paths.includes(p))
|
||||||
<DialogContent className="max-w-4xl">
|
: [...new Set([...current, ...paths])],
|
||||||
<DialogHeader>
|
)
|
||||||
<DialogTitle>{t('admin.media.manualTitle')}</DialogTitle>
|
}
|
||||||
<DialogDescription>{t('admin.media.manualHint')}</DialogDescription>
|
|
||||||
</DialogHeader>
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
<div className="flex flex-col gap-3">
|
<DialogContent className="max-w-4xl">
|
||||||
{/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */}
|
<DialogHeader>
|
||||||
<div className="flex flex-col gap-1.5">
|
<DialogTitle>{t('admin.media.manualTitle')}</DialogTitle>
|
||||||
<Label>{t('admin.media.manualShow')}</Label>
|
<DialogDescription>{t('admin.media.manualHint')}</DialogDescription>
|
||||||
<Select
|
</DialogHeader>
|
||||||
value={showId}
|
|
||||||
onValueChange={(value) => {
|
<div className="flex flex-col gap-3">
|
||||||
setShowPicked(true)
|
{/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */}
|
||||||
setShowId(value)
|
<div className="flex flex-col gap-1.5">
|
||||||
}}
|
<Label>{t('admin.media.manualShow')}</Label>
|
||||||
>
|
<Select
|
||||||
<SelectTrigger>
|
value={showId}
|
||||||
<SelectValue placeholder={t('admin.media.manualPickShow')} />
|
onValueChange={(value) => {
|
||||||
</SelectTrigger>
|
setShowPicked(true)
|
||||||
<SelectContent>
|
setShowId(value)
|
||||||
{(shows ?? []).map((show) => (
|
}}
|
||||||
<SelectItem key={show.id} value={show.id}>
|
>
|
||||||
{show.name}
|
<SelectTrigger>
|
||||||
</SelectItem>
|
<SelectValue placeholder={t('admin.media.manualPickShow')} />
|
||||||
))}
|
</SelectTrigger>
|
||||||
</SelectContent>
|
<SelectContent>
|
||||||
</Select>
|
{(shows ?? []).map((show) => (
|
||||||
{autoDetected && (
|
<SelectItem key={show.id} value={show.id}>
|
||||||
<p className="text-xs text-muted-foreground">
|
{show.name}
|
||||||
{t('admin.media.manualDetected')}
|
</SelectItem>
|
||||||
</p>
|
))}
|
||||||
)}
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
|
{autoDetected && (
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<p className="text-xs text-muted-foreground">{t('admin.media.manualDetected')}</p>
|
||||||
<div className="flex flex-col gap-1.5">
|
)}
|
||||||
<Label>{t('common.search')}</Label>
|
</div>
|
||||||
<Input value={query} onChange={(e) => setQuery(e.target.value)} />
|
|
||||||
</div>
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.media.toShowSeason')}</Label>
|
<Label>{t('common.search')}</Label>
|
||||||
{/* Ноль — законный сезон: в нём живут спецвыпуски и пилоты. */}
|
<Input value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||||
<Input
|
</div>
|
||||||
type="number"
|
<div className="flex flex-col gap-1.5">
|
||||||
min={0}
|
<Label>{t('admin.media.toShowSeason')}</Label>
|
||||||
placeholder={t('admin.media.toShowAuto')}
|
{/* Ноль — законный сезон: в нём живут спецвыпуски и пилоты. */}
|
||||||
value={seasonStr}
|
<Input
|
||||||
onChange={(e) => setSeasonStr(e.target.value)}
|
type="number"
|
||||||
/>
|
min={0}
|
||||||
</div>
|
placeholder={t('admin.media.toShowAuto')}
|
||||||
<div className="flex flex-col gap-1.5">
|
value={seasonStr}
|
||||||
<Label>{t('admin.media.toShowRegex')}</Label>
|
onChange={(e) => setSeasonStr(e.target.value)}
|
||||||
<Input
|
/>
|
||||||
placeholder="^(\d+)"
|
</div>
|
||||||
value={regexStr}
|
<div className="flex flex-col gap-1.5">
|
||||||
onChange={(e) => setRegexStr(e.target.value)}
|
<Label>{t('admin.media.toShowRegex')}</Label>
|
||||||
className={!regexOk ? 'border-red-500' : undefined}
|
<Input
|
||||||
/>
|
placeholder="^(\d+)"
|
||||||
</div>
|
value={regexStr}
|
||||||
</div>
|
onChange={(e) => setRegexStr(e.target.value)}
|
||||||
{!regexOk && <p className="text-xs text-red-500">{t('admin.media.toShowRegexInvalid')}</p>}
|
className={!regexOk ? 'border-red-500' : undefined}
|
||||||
|
/>
|
||||||
{/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */}
|
</div>
|
||||||
{sample && (
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
{!regexOk && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<p className="text-xs text-red-500">{t('admin.media.toShowRegexInvalid')}</p>
|
||||||
{t('admin.media.regexPickHint')}
|
)}
|
||||||
</span>
|
|
||||||
<div className="flex flex-wrap items-center gap-0.5 font-mono text-xs">
|
{/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */}
|
||||||
{sampleParts.map((part) =>
|
{sample && (
|
||||||
part.number === null ? (
|
<div className="flex flex-col gap-1.5">
|
||||||
<span key={part.start} className="text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{part.text}
|
{t('admin.media.regexPickHint')}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
<div className="flex flex-wrap items-center gap-0.5 font-mono text-xs">
|
||||||
<button
|
{sampleParts.map((part) =>
|
||||||
key={part.start}
|
part.number === null ? (
|
||||||
type="button"
|
<span key={part.start} className="text-muted-foreground">
|
||||||
title={t('admin.media.regexPickTitle')}
|
{part.text}
|
||||||
className="rounded border border-primary/60 bg-primary/10 px-1 text-primary hover:bg-primary/25"
|
</span>
|
||||||
onClick={() => setRegexStr(buildEpisodeRegex(sample.name, part.number!))}
|
) : (
|
||||||
>
|
<button
|
||||||
{part.text}
|
key={part.start}
|
||||||
</button>
|
type="button"
|
||||||
),
|
title={t('admin.media.regexPickTitle')}
|
||||||
)}
|
className="rounded border border-primary/60 bg-primary/10 px-1 text-primary hover:bg-primary/25"
|
||||||
</div>
|
onClick={() => setRegexStr(buildEpisodeRegex(sample.name, part.number!))}
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
>
|
||||||
<span className="text-xs text-muted-foreground">
|
{part.text}
|
||||||
{t('admin.media.regexPresets')}
|
</button>
|
||||||
</span>
|
),
|
||||||
{REGEX_PRESETS.map((preset) => (
|
)}
|
||||||
<button
|
</div>
|
||||||
key={preset.key}
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
type="button"
|
<span className="text-xs text-muted-foreground">
|
||||||
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
{t('admin.media.regexPresets')}
|
||||||
onClick={() => setRegexStr(preset.pattern)}
|
</span>
|
||||||
>
|
{REGEX_PRESETS.map((preset) => (
|
||||||
{t(`admin.media.regexPresetNames.${preset.key}`)}
|
<button
|
||||||
</button>
|
key={preset.key}
|
||||||
))}
|
type="button"
|
||||||
{regexStr && (
|
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
||||||
<button
|
onClick={() => setRegexStr(preset.pattern)}
|
||||||
type="button"
|
>
|
||||||
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
{t(`admin.media.regexPresetNames.${preset.key}`)}
|
||||||
onClick={() => setRegexStr('')}
|
</button>
|
||||||
>
|
))}
|
||||||
{t('admin.media.regexClear')}
|
{regexStr && (
|
||||||
</button>
|
<button
|
||||||
)}
|
type="button"
|
||||||
</div>
|
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
||||||
</div>
|
onClick={() => setRegexStr('')}
|
||||||
)}
|
>
|
||||||
|
{t('admin.media.regexClear')}
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
</button>
|
||||||
<Button
|
)}
|
||||||
size="sm"
|
</div>
|
||||||
variant="outline"
|
</div>
|
||||||
disabled={selectable.length === 0}
|
)}
|
||||||
onClick={() =>
|
|
||||||
setSelected(
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
selected.length === selectable.length
|
<Button
|
||||||
? []
|
size="sm"
|
||||||
: selectable.map((f) => f.relativePath),
|
variant="outline"
|
||||||
)
|
disabled={selectable.length === 0}
|
||||||
}
|
onClick={() =>
|
||||||
>
|
setSelected(
|
||||||
{t('admin.media.manualSelectAll')}
|
selected.length === selectable.length
|
||||||
</Button>
|
? []
|
||||||
<span className="text-muted-foreground">
|
: selectable.map((f) => f.relativePath),
|
||||||
{t('admin.media.manualSelected', { count: selected.length })}
|
)
|
||||||
</span>
|
}
|
||||||
<span className="text-muted-foreground">
|
>
|
||||||
{t('admin.media.manualRecognized', {
|
{t('admin.media.manualSelectAll')}
|
||||||
count: recognized,
|
</Button>
|
||||||
total: selectable.length,
|
<span className="text-muted-foreground">
|
||||||
})}
|
{t('admin.media.manualSelected', { count: selected.length })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span className="text-muted-foreground">
|
||||||
|
{t('admin.media.manualRecognized', {
|
||||||
<div className="crt-panel max-h-80 overflow-y-auto rounded-md text-sm">
|
count: recognized,
|
||||||
{isLoading && <p className="px-3 py-2 text-muted-foreground">{t('common.loading')}</p>}
|
total: selectable.length,
|
||||||
{!isLoading && folders.length === 0 && (
|
})}
|
||||||
<p className="px-3 py-2 text-muted-foreground">{t('admin.media.manualEmpty')}</p>
|
</span>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{folders.map(({ folder, files }) => {
|
<div className="crt-panel max-h-80 overflow-y-auto rounded-md text-sm">
|
||||||
const isCollapsed = collapsed.includes(folder)
|
{isLoading && <p className="px-3 py-2 text-muted-foreground">{t('common.loading')}</p>}
|
||||||
return (
|
{!isLoading && folders.length === 0 && (
|
||||||
<div key={folder || '/'} className="border-b border-border last:border-0">
|
<p className="px-3 py-2 text-muted-foreground">{t('admin.media.manualEmpty')}</p>
|
||||||
<div className="flex items-center gap-2 bg-muted/30 px-3 py-1.5">
|
)}
|
||||||
<button
|
|
||||||
type="button"
|
{folders.map(({ folder, files }) => {
|
||||||
className="text-muted-foreground hover:text-foreground"
|
const isCollapsed = collapsed.includes(folder)
|
||||||
onClick={() => toggleCollapsed(folder)}
|
return (
|
||||||
>
|
<div key={folder || '/'} className="border-b border-border last:border-0">
|
||||||
{isCollapsed ? (
|
<div className="flex items-center gap-2 bg-muted/30 px-3 py-1.5">
|
||||||
<ChevronRight className="h-4 w-4" />
|
<button
|
||||||
) : (
|
type="button"
|
||||||
<ChevronDown className="h-4 w-4" />
|
className="text-muted-foreground hover:text-foreground"
|
||||||
)}
|
onClick={() => toggleCollapsed(folder)}
|
||||||
</button>
|
>
|
||||||
<input
|
{isCollapsed ? (
|
||||||
type="checkbox"
|
<ChevronRight className="h-4 w-4" />
|
||||||
className="shrink-0"
|
) : (
|
||||||
checked={files
|
<ChevronDown className="h-4 w-4" />
|
||||||
.filter((f) => !f.alreadyImported)
|
)}
|
||||||
.every((f) => selected.includes(f.relativePath))}
|
</button>
|
||||||
onChange={() => toggleFolder(files)}
|
<input
|
||||||
/>
|
type="checkbox"
|
||||||
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
className="shrink-0"
|
||||||
<span className="min-w-0 flex-1 truncate font-medium" title={folder}>
|
checked={files
|
||||||
{folder || t('admin.media.manualRoot')}
|
.filter((f) => !f.alreadyImported)
|
||||||
</span>
|
.every((f) => selected.includes(f.relativePath))}
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">{files.length}</span>
|
onChange={() => toggleFolder(files)}
|
||||||
</div>
|
/>
|
||||||
|
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
{!isCollapsed && (
|
<span className="min-w-0 flex-1 truncate font-medium" title={folder}>
|
||||||
<ul className="divide-y divide-border">
|
{folder || t('admin.media.manualRoot')}
|
||||||
{files.map((file) => {
|
</span>
|
||||||
const label = formatSeasonEpisode(
|
<span className="shrink-0 text-xs text-muted-foreground">{files.length}</span>
|
||||||
parsedByPath.get(file.relativePath) ?? { season: null, episode: null },
|
</div>
|
||||||
)
|
|
||||||
return (
|
{!isCollapsed && (
|
||||||
<li
|
<ul className="divide-y divide-border">
|
||||||
key={file.relativePath}
|
{files.map((file) => {
|
||||||
className="flex items-center gap-2 px-3 py-1.5 pl-9"
|
const label = formatSeasonEpisode(
|
||||||
>
|
parsedByPath.get(file.relativePath) ?? { season: null, episode: null },
|
||||||
<input
|
)
|
||||||
type="checkbox"
|
return (
|
||||||
className="shrink-0"
|
<li
|
||||||
disabled={file.alreadyImported}
|
key={file.relativePath}
|
||||||
checked={selected.includes(file.relativePath)}
|
className="flex items-center gap-2 px-3 py-1.5 pl-9"
|
||||||
onChange={() => toggle(file.relativePath)}
|
>
|
||||||
/>
|
<input
|
||||||
{label ? (
|
type="checkbox"
|
||||||
<Badge className="shrink-0">{label}</Badge>
|
className="shrink-0"
|
||||||
) : (
|
disabled={file.alreadyImported}
|
||||||
<Badge variant="muted" className="shrink-0">
|
checked={selected.includes(file.relativePath)}
|
||||||
{t('admin.media.toShowUnknown')}
|
onChange={() => toggle(file.relativePath)}
|
||||||
</Badge>
|
/>
|
||||||
)}
|
{label ? (
|
||||||
<span
|
<Badge className="shrink-0">{label}</Badge>
|
||||||
className={`min-w-0 flex-1 truncate ${file.alreadyImported ? 'text-muted-foreground' : ''}`}
|
) : (
|
||||||
title={file.name}
|
<Badge variant="muted" className="shrink-0">
|
||||||
>
|
{t('admin.media.toShowUnknown')}
|
||||||
{file.name}
|
</Badge>
|
||||||
</span>
|
)}
|
||||||
{file.alreadyImported && (
|
<span
|
||||||
<Badge variant="muted">{t('admin.media.manualAlready')}</Badge>
|
className={`min-w-0 flex-1 truncate ${file.alreadyImported ? 'text-muted-foreground' : ''}`}
|
||||||
)}
|
title={file.name}
|
||||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
>
|
||||||
{formatSize(file.sizeBytes)}
|
{file.name}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
{file.alreadyImported && (
|
||||||
)
|
<Badge variant="muted">{t('admin.media.manualAlready')}</Badge>
|
||||||
})}
|
)}
|
||||||
</ul>
|
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||||
)}
|
{formatSize(file.sizeBytes)}
|
||||||
</div>
|
</span>
|
||||||
)
|
</li>
|
||||||
})}
|
)
|
||||||
</div>
|
})}
|
||||||
|
</ul>
|
||||||
{data?.truncated && (
|
)}
|
||||||
<p className="text-xs text-amber-500">{t('admin.media.manualTruncated')}</p>
|
</div>
|
||||||
)}
|
)
|
||||||
|
})}
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.media.manualCleanupHint')}</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
{data?.truncated && (
|
||||||
<DialogFooter>
|
<p className="text-xs text-amber-500">{t('admin.media.manualTruncated')}</p>
|
||||||
<Button size="sm" variant="outline" onClick={onClose}>
|
)}
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
<p className="text-xs text-muted-foreground">{t('admin.media.manualCleanupHint')}</p>
|
||||||
<Button
|
</div>
|
||||||
size="sm"
|
|
||||||
disabled={selected.length === 0 || !showId || importMutation.isPending}
|
<DialogFooter>
|
||||||
onClick={() => importMutation.mutate()}
|
<Button size="sm" variant="outline" onClick={onClose}>
|
||||||
>
|
{t('common.cancel')}
|
||||||
{t('admin.media.manualImport')}
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
</DialogFooter>
|
size="sm"
|
||||||
</DialogContent>
|
disabled={selected.length === 0 || !showId || importMutation.isPending}
|
||||||
</Dialog>
|
onClick={() => importMutation.mutate()}
|
||||||
)
|
>
|
||||||
}
|
{t('admin.media.manualImport')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
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 { cn } from '@/shared/lib/cn'
|
||||||
import { type UploadItem, useUploadStore } from './upload-store'
|
import { type UploadItem, useUploadStore } from './upload-store'
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ export function UploadToShowDialog({
|
|||||||
// Предпросмотр: что распарсим для каждого файла при текущих настройках, в порядке добавления.
|
// Предпросмотр: что распарсим для каждого файла при текущих настройках, в порядке добавления.
|
||||||
const previews = useMemo(() => {
|
const previews = useMemo(() => {
|
||||||
const opts = {
|
const opts = {
|
||||||
seasonOverride: seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null,
|
seasonOverride:
|
||||||
|
seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null,
|
||||||
episodeRegex: regexOk ? regexStr : null,
|
episodeRegex: regexOk ? regexStr : null,
|
||||||
}
|
}
|
||||||
return files
|
return files
|
||||||
@@ -157,7 +158,9 @@ export function UploadToShowDialog({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={LIBRARY_VALUE}>{t('admin.media.toShowLibrary')}</SelectItem>
|
<SelectItem value={LIBRARY_VALUE}>
|
||||||
|
{t('admin.media.toShowLibrary')}
|
||||||
|
</SelectItem>
|
||||||
{shows?.map((s) => (
|
{shows?.map((s) => (
|
||||||
<SelectItem key={s.id} value={s.id}>
|
<SelectItem key={s.id} value={s.id}>
|
||||||
{s.name}
|
{s.name}
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ const MIN_CANDIDATE_LENGTH = 2
|
|||||||
* Возвращает id наиболее подходящего шоу для имени файла или undefined, если совпадений нет.
|
* Возвращает 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)} `
|
const haystack = ` ${normalize(fileName)} `
|
||||||
let best: { id: string; length: number } | undefined
|
let best: { id: string; length: number } | undefined
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ export function RolesPanel() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const { register, handleSubmit, reset } = useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema) })
|
const { register, handleSubmit, reset } = useForm<z.infer<typeof schema>>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
})
|
||||||
|
|
||||||
const onCreate = async (values: z.infer<typeof schema>) => {
|
const onCreate = async (values: z.infer<typeof schema>) => {
|
||||||
try {
|
try {
|
||||||
@@ -128,7 +130,11 @@ export function RolesPanel() {
|
|||||||
<tr key={role.id} className="border-b border-border last:border-0">
|
<tr key={role.id} className="border-b border-border last:border-0">
|
||||||
<td className="px-4 py-2">{role.name}</td>
|
<td className="px-4 py-2">{role.name}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
{role.isSystem ? <Badge variant="muted">{t('common.yes')}</Badge> : t('common.no')}
|
{role.isSystem ? (
|
||||||
|
<Badge variant="muted">{t('common.yes')}</Badge>
|
||||||
|
) : (
|
||||||
|
t('common.no')
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -240,30 +240,36 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
|
|||||||
>
|
>
|
||||||
{t('admin.shows.deselectAll')}
|
{t('admin.shows.deselectAll')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" disabled={selected.length === 0 || adding != null} onClick={() => void bulkAdd()}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={selected.length === 0 || adding != null}
|
||||||
|
onClick={() => void bulkAdd()}
|
||||||
|
>
|
||||||
{addButtonLabel(adding, isSingle ? Math.min(1, selected.length) : selected.length, t)}
|
{addButtonLabel(adding, isSingle ? Math.min(1, selected.length) : selected.length, t)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="crt-panel max-h-72 overflow-y-auto rounded-md">
|
<div className="crt-panel max-h-72 overflow-y-auto rounded-md">
|
||||||
{candidates.length === 0 ? (
|
{candidates.length === 0 ? (
|
||||||
<p className="px-4 py-3 text-sm text-muted-foreground">{t('admin.shows.noMatches')}</p>
|
<p className="px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
{t('admin.shows.noMatches')}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border text-sm">
|
<ul className="divide-y divide-border text-sm">
|
||||||
{candItems.map(({ asset, parsed }) => {
|
{candItems.map(({ asset, parsed }) => {
|
||||||
const label = formatSeasonEpisode(parsed)
|
const label = formatSeasonEpisode(parsed)
|
||||||
return (
|
return (
|
||||||
<li key={asset.id}>
|
<li key={asset.id}>
|
||||||
<label className="flex cursor-pointer items-center gap-3 px-4 py-2 hover:bg-muted">
|
<label className="flex cursor-pointer items-center gap-3 px-4 py-2 hover:bg-muted">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!deselected.has(asset.id)}
|
checked={!deselected.has(asset.id)}
|
||||||
onChange={() => toggle(asset.id)}
|
onChange={() => toggle(asset.id)}
|
||||||
/>
|
/>
|
||||||
{label ? <Badge>{label}</Badge> : <Badge variant="muted">—</Badge>}
|
{label ? <Badge>{label}</Badge> : <Badge variant="muted">—</Badge>}
|
||||||
<span className="truncate">{asset.originalFileName}</span>
|
<span className="truncate">{asset.originalFileName}</span>
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -295,48 +301,48 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
|
|||||||
: parseEpisodeName(episode.assetName ?? '')
|
: parseEpisodeName(episode.assetName ?? '')
|
||||||
const label = formatSeasonEpisode(parsed)
|
const label = formatSeasonEpisode(parsed)
|
||||||
return (
|
return (
|
||||||
<tr key={episode.id} className="border-b border-border last:border-0">
|
<tr key={episode.id} className="border-b border-border last:border-0">
|
||||||
<td className="px-4 py-2 text-muted-foreground">{epOffset + index + 1}</td>
|
<td className="px-4 py-2 text-muted-foreground">{epOffset + index + 1}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{episode.stillImageId && (
|
{episode.stillImageId && (
|
||||||
<img
|
<img
|
||||||
src={imageUrl(episode.stillImageId)}
|
src={imageUrl(episode.stillImageId)}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-9 w-16 shrink-0 rounded object-cover"
|
className="h-9 w-16 shrink-0 rounded object-cover"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{label && <Badge>{label}</Badge>}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate">{episode.title ?? episode.assetName ?? '—'}</div>
|
|
||||||
{episode.title && (
|
|
||||||
<div className="truncate text-xs text-muted-foreground">
|
|
||||||
{episode.assetName}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
{label && <Badge>{label}</Badge>}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate">{episode.title ?? episode.assetName ?? '—'}</div>
|
||||||
|
{episode.title && (
|
||||||
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
|
{episode.assetName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
<td className="px-4 py-2 text-muted-foreground">
|
||||||
<td className="px-4 py-2 text-muted-foreground">
|
{formatDuration(episode.durationSeconds)}
|
||||||
{formatDuration(episode.durationSeconds)}
|
</td>
|
||||||
</td>
|
<td className="px-4 py-2">
|
||||||
<td className="px-4 py-2">
|
{episode.assetStatus && (
|
||||||
{episode.assetStatus && (
|
<Badge variant={episode.assetStatus === 'Ready' ? 'default' : 'muted'}>
|
||||||
<Badge variant={episode.assetStatus === 'Ready' ? 'default' : 'muted'}>
|
{t(`admin.media.statuses.${episode.assetStatus}`)}
|
||||||
{t(`admin.media.statuses.${episode.assetStatus}`)}
|
</Badge>
|
||||||
</Badge>
|
)}
|
||||||
)}
|
</td>
|
||||||
</td>
|
<td className="px-4 py-2">
|
||||||
<td className="px-4 py-2">
|
<Button
|
||||||
<Button
|
size="sm"
|
||||||
size="sm"
|
variant="destructive"
|
||||||
variant="destructive"
|
onClick={() => removeMutation.mutate(episode.id)}
|
||||||
onClick={() => removeMutation.mutate(episode.id)}
|
>
|
||||||
>
|
{t('common.delete')}
|
||||||
{t('common.delete')}
|
</Button>
|
||||||
</Button>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{show.episodes.length === 0 && (
|
{show.episodes.length === 0 && (
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ export function ShowMetadataCard({
|
|||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const yearNum = year.trim() ? Number(year) : null
|
const yearNum = year.trim() ? Number(year) : null
|
||||||
const infoChanged =
|
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 (name.trim() && name.trim() !== show.name) await renameShow(show.id, name.trim())
|
||||||
if (originalName.trim() !== (show.originalName ?? ''))
|
if (originalName.trim() !== (show.originalName ?? ''))
|
||||||
await setShowOriginalName(show.id, originalName.trim() || null)
|
await setShowOriginalName(show.id, originalName.trim() || null)
|
||||||
@@ -137,226 +138,238 @@ export function ShowMetadataCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('admin.metadata.title')}</CardTitle>
|
<CardTitle>{t('admin.metadata.title')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4 sm:flex-row">
|
<CardContent className="flex flex-col gap-4 sm:flex-row">
|
||||||
{/* Постер */}
|
{/* Постер */}
|
||||||
<div className="flex w-40 shrink-0 flex-col gap-2">
|
<div className="flex w-40 shrink-0 flex-col gap-2">
|
||||||
<div className="flex aspect-[2/3] items-center justify-center overflow-hidden rounded-md border border-border bg-muted/30">
|
<div className="flex aspect-[2/3] items-center justify-center overflow-hidden rounded-md border border-border bg-muted/30">
|
||||||
{show.posterImageId ? (
|
{show.posterImageId ? (
|
||||||
<img
|
<img
|
||||||
src={imageUrl(show.posterImageId)}
|
src={imageUrl(show.posterImageId)}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">{t('admin.metadata.noPoster')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
|
|
||||||
{t('admin.metadata.pickPoster')}
|
|
||||||
</Button>
|
|
||||||
<ImageGallery
|
|
||||||
open={galleryOpen}
|
|
||||||
onOpenChange={setGalleryOpen}
|
|
||||||
category="ShowPoster"
|
|
||||||
onSelect={(img) => setPoster.mutate(img.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Название + поиск + ручная правка */}
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>{t('admin.metadata.name')}</Label>
|
|
||||||
<Input value={name} maxLength={256} onChange={(e) => setName(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>{t('admin.metadata.originalName')}</Label>
|
|
||||||
<Input
|
|
||||||
value={originalName}
|
|
||||||
maxLength={256}
|
|
||||||
placeholder={t('admin.metadata.originalNamePlaceholder')}
|
|
||||||
onChange={(e) => {
|
|
||||||
setOriginalName(e.target.value)
|
|
||||||
setSearched(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.metadata.originalNameHint')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{providers && providers.length > 0 && (searched || results.length > 0) && (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{searched && results.length === 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.metadata.nothingFound')}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{results.length > 0 && (
|
|
||||||
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md">
|
|
||||||
{results.map((r) => (
|
|
||||||
<li key={r.externalId} className="flex items-start gap-3 p-2">
|
|
||||||
{r.posterUrl ? (
|
|
||||||
<img src={r.posterUrl} alt="" className="h-16 w-11 shrink-0 rounded object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="h-16 w-11 shrink-0 rounded bg-muted/40" />
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm font-medium">
|
|
||||||
<span>
|
|
||||||
{r.title}
|
|
||||||
{r.year != null && (
|
|
||||||
<span className="text-muted-foreground"> ({r.year})</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{/* В выдаче OMDb сериалы и полнометражки идут вперемешку — без метки
|
|
||||||
одноимённые фильм и сериал не различить. */}
|
|
||||||
{r.kind && (
|
|
||||||
<Badge variant="muted">{t(`admin.shows.kinds.${r.kind}`)}</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{r.overview && (
|
|
||||||
<p className="line-clamp-2 text-xs text-muted-foreground">{r.overview}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={apply.isPending}
|
|
||||||
onClick={() => apply.mutate(r.externalId)}
|
|
||||||
>
|
|
||||||
{t('admin.metadata.apply')}
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
<div className="flex flex-1 flex-col gap-1.5">
|
|
||||||
<Label>{t('admin.metadata.overview')}</Label>
|
|
||||||
<textarea
|
|
||||||
className="min-h-20 w-full rounded-sm border border-border bg-transparent px-3 py-2 text-sm"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
<div className="flex w-24 flex-col gap-1.5">
|
|
||||||
<Label>{t('admin.metadata.year')}</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={year}
|
|
||||||
onChange={(e) => setYear(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{providers && providers.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Select value={effectiveProvider} onValueChange={setProvider}>
|
|
||||||
<SelectTrigger className="h-9 w-24">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{providers.map((p) => (
|
|
||||||
<SelectItem key={p} value={p}>
|
|
||||||
{p.toUpperCase()}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={search.isPending || !searchTerm}
|
|
||||||
onClick={() => search.mutate()}
|
|
||||||
>
|
|
||||||
{t('admin.metadata.searchBtn')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button size="sm" disabled={save.isPending || !name.trim()} onClick={() => save.mutate()}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
{linked && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={refreshEpisodes.isPending}
|
|
||||||
onClick={() => refreshEpisodes.mutate()}
|
|
||||||
>
|
|
||||||
{refreshEpisodes.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
||||||
{refreshEpisodes.isPending
|
|
||||||
? t('admin.metadata.refreshing')
|
|
||||||
: t('admin.metadata.refreshEpisodes')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{linked && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={findMissing.isPending}
|
|
||||||
onClick={() => findMissing.mutate()}
|
|
||||||
>
|
|
||||||
{findMissing.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
||||||
{t('admin.metadata.findMissing')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{(show.metadataProvider || show.posterImageId) && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={clear.isPending}
|
|
||||||
onClick={() => clear.mutate()}
|
|
||||||
>
|
|
||||||
{t('admin.metadata.clear')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{show.metadataProvider && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{t('admin.metadata.sourceLabel')}: {show.metadataProvider}
|
{t('admin.metadata.noPoster')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
|
||||||
|
{t('admin.metadata.pickPoster')}
|
||||||
|
</Button>
|
||||||
|
<ImageGallery
|
||||||
|
open={galleryOpen}
|
||||||
|
onOpenChange={setGalleryOpen}
|
||||||
|
category="ShowPoster"
|
||||||
|
onSelect={(img) => setPoster.mutate(img.id)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Dialog open={missing != null} onOpenChange={(open) => !open && setMissing(null)}>
|
{/* Название + поиск + ручная правка */}
|
||||||
<DialogContent className="max-w-lg">
|
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||||
<DialogHeader>
|
<div className="flex flex-col gap-1.5">
|
||||||
<DialogTitle>{t('admin.metadata.missingTitle')}</DialogTitle>
|
<Label>{t('admin.metadata.name')}</Label>
|
||||||
</DialogHeader>
|
<Input value={name} maxLength={256} onChange={(e) => setName(e.target.value)} />
|
||||||
{missing && missing.seasons.length === 0 && (
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{t('admin.metadata.missingNoSeasons')}</p>
|
|
||||||
)}
|
<div className="flex flex-col gap-1.5">
|
||||||
{missing && missing.seasons.length > 0 && (
|
<Label>{t('admin.metadata.originalName')}</Label>
|
||||||
<div className="flex max-h-[60vh] flex-col gap-3 overflow-y-auto">
|
<Input
|
||||||
{missing.seasons.map((s) => (
|
value={originalName}
|
||||||
<div key={s.season} className="rounded-md border border-border p-3 text-sm">
|
maxLength={256}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
placeholder={t('admin.metadata.originalNamePlaceholder')}
|
||||||
<span className="font-medium">
|
onChange={(e) => {
|
||||||
{t('admin.metadata.seasonN', { n: s.season })}
|
setOriginalName(e.target.value)
|
||||||
</span>
|
setSearched(false)
|
||||||
<span className="text-xs text-muted-foreground">
|
}}
|
||||||
{t('admin.metadata.loadedOf', {
|
/>
|
||||||
loaded: s.loaded,
|
<p className="text-xs text-muted-foreground">
|
||||||
total: s.expected ?? '?',
|
{t('admin.metadata.originalNameHint')}
|
||||||
})}
|
</p>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
<SeasonGapNote gap={s} />
|
{providers && providers.length > 0 && (searched || results.length > 0) && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{searched && results.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.metadata.nothingFound')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md">
|
||||||
|
{results.map((r) => (
|
||||||
|
<li key={r.externalId} className="flex items-start gap-3 p-2">
|
||||||
|
{r.posterUrl ? (
|
||||||
|
<img
|
||||||
|
src={r.posterUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-16 w-11 shrink-0 rounded object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-16 w-11 shrink-0 rounded bg-muted/40" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm font-medium">
|
||||||
|
<span>
|
||||||
|
{r.title}
|
||||||
|
{r.year != null && (
|
||||||
|
<span className="text-muted-foreground"> ({r.year})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{/* В выдаче OMDb сериалы и полнометражки идут вперемешку — без метки
|
||||||
|
одноимённые фильм и сериал не различить. */}
|
||||||
|
{r.kind && (
|
||||||
|
<Badge variant="muted">{t(`admin.shows.kinds.${r.kind}`)}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{r.overview && (
|
||||||
|
<p className="line-clamp-2 text-xs text-muted-foreground">
|
||||||
|
{r.overview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={apply.isPending}
|
||||||
|
onClick={() => apply.mutate(r.externalId)}
|
||||||
|
>
|
||||||
|
{t('admin.metadata.apply')}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex flex-1 flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.metadata.overview')}</Label>
|
||||||
|
<textarea
|
||||||
|
className="min-h-20 w-full rounded-sm border border-border bg-transparent px-3 py-2 text-sm"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-24 flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.metadata.year')}</Label>
|
||||||
|
<Input type="number" value={year} onChange={(e) => setYear(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{providers && providers.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Select value={effectiveProvider} onValueChange={setProvider}>
|
||||||
|
<SelectTrigger className="h-9 w-24">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<SelectItem key={p} value={p}>
|
||||||
|
{p.toUpperCase()}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={search.isPending || !searchTerm}
|
||||||
|
onClick={() => search.mutate()}
|
||||||
|
>
|
||||||
|
{t('admin.metadata.searchBtn')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={save.isPending || !name.trim()}
|
||||||
|
onClick={() => save.mutate()}
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
{linked && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={refreshEpisodes.isPending}
|
||||||
|
onClick={() => refreshEpisodes.mutate()}
|
||||||
|
>
|
||||||
|
{refreshEpisodes.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{refreshEpisodes.isPending
|
||||||
|
? t('admin.metadata.refreshing')
|
||||||
|
: t('admin.metadata.refreshEpisodes')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{linked && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={findMissing.isPending}
|
||||||
|
onClick={() => findMissing.mutate()}
|
||||||
|
>
|
||||||
|
{findMissing.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{t('admin.metadata.findMissing')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(show.metadataProvider || show.posterImageId) && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={clear.isPending}
|
||||||
|
onClick={() => clear.mutate()}
|
||||||
|
>
|
||||||
|
{t('admin.metadata.clear')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{show.metadataProvider && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.metadata.sourceLabel')}: {show.metadataProvider}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardContent>
|
||||||
</DialogContent>
|
</Card>
|
||||||
</Dialog>
|
|
||||||
|
<Dialog open={missing != null} onOpenChange={(open) => !open && setMissing(null)}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('admin.metadata.missingTitle')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{missing && missing.seasons.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.metadata.missingNoSeasons')}</p>
|
||||||
|
)}
|
||||||
|
{missing && missing.seasons.length > 0 && (
|
||||||
|
<div className="flex max-h-[60vh] flex-col gap-3 overflow-y-auto">
|
||||||
|
{missing.seasons.map((s) => (
|
||||||
|
<div key={s.season} className="rounded-md border border-border p-3 text-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
{t('admin.metadata.seasonN', { n: s.season })}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.metadata.loadedOf', {
|
||||||
|
loaded: s.loaded,
|
||||||
|
total: s.expected ?? '?',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<SeasonGapNote gap={s} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,10 @@ export function applyMetadata(showId: string, provider: string, externalId: stri
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateMetadata(showId: string, body: { description: string | null; year: number | null }) {
|
export function updateMetadata(
|
||||||
|
showId: string,
|
||||||
|
body: { description: string | null; year: number | null },
|
||||||
|
) {
|
||||||
return apiRequest<void>(`/admin/metadata/shows/${showId}`, { method: 'PUT', body })
|
return apiRequest<void>(`/admin/metadata/shows/${showId}`, { method: 'PUT', body })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,7 @@ import { useApiError } from '@/shared/lib/use-api-error'
|
|||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||||
import {
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/shared/ui/dialog'
|
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
@@ -224,7 +218,9 @@ export function UsersPanel() {
|
|||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<Select
|
<Select
|
||||||
value={roles?.find((r) => r.name === user.role)?.id}
|
value={roles?.find((r) => r.name === user.role)?.id}
|
||||||
onValueChange={(newRoleId) => changeRoleMutation.mutate({ userId: user.id, roleId: newRoleId })}
|
onValueChange={(newRoleId) =>
|
||||||
|
changeRoleMutation.mutate({ userId: user.id, roleId: newRoleId })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-32">
|
<SelectTrigger className="h-8 w-32">
|
||||||
<SelectValue>{user.role}</SelectValue>
|
<SelectValue>{user.role}</SelectValue>
|
||||||
@@ -251,18 +247,30 @@ export function UsersPanel() {
|
|||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{user.isBlocked ? (
|
{user.isBlocked ? (
|
||||||
<Button size="sm" variant="outline" onClick={() => unblockMutation.mutate(user.id)}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => unblockMutation.mutate(user.id)}
|
||||||
|
>
|
||||||
{t('admin.users.unblock')}
|
{t('admin.users.unblock')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button size="sm" variant="outline" onClick={() => blockMutation.mutate(user.id)}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => blockMutation.mutate(user.id)}
|
||||||
|
>
|
||||||
{t('admin.users.block')}
|
{t('admin.users.block')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button size="sm" variant="outline" onClick={() => setResetTarget(user)}>
|
<Button size="sm" variant="outline" onClick={() => setResetTarget(user)}>
|
||||||
{t('admin.users.resetPassword')}
|
{t('admin.users.resetPassword')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="destructive" onClick={() => deleteMutation.mutate(user.id)}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteMutation.mutate(user.id)}
|
||||||
|
>
|
||||||
{t('common.delete')}
|
{t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,13 +283,23 @@ export function UsersPanel() {
|
|||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-center gap-2 text-sm">
|
<div className="flex items-center justify-center gap-2 text-sm">
|
||||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
‹
|
‹
|
||||||
</Button>
|
</Button>
|
||||||
<span>
|
<span>
|
||||||
{page} / {totalPages}
|
{page} / {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
›
|
›
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,7 +47,12 @@ export function LoginForm({ onSuccess }: Readonly<{ onSuccess: () => void }>) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="password">{t('auth.password')}</Label>
|
<Label htmlFor="password">{t('auth.password')}</Label>
|
||||||
<Input id="password" type="password" autoComplete="current-password" {...registerField('password')} />
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
{...registerField('password')}
|
||||||
|
/>
|
||||||
{errors.password && <p className="text-xs text-red-500">{errors.password.message}</p>}
|
{errors.password && <p className="text-xs text-red-500">{errors.password.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
|||||||
@@ -47,7 +47,12 @@ export function RegisterForm({ onSuccess }: Readonly<{ onSuccess: () => void }>)
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="password">{t('auth.password')}</Label>
|
<Label htmlFor="password">{t('auth.password')}</Label>
|
||||||
<Input id="password" type="password" autoComplete="new-password" {...registerField('password')} />
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
{...registerField('password')}
|
||||||
|
/>
|
||||||
{errors.password && <p className="text-xs text-red-500">{errors.password.message}</p>}
|
{errors.password && <p className="text-xs text-red-500">{errors.password.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export function login(userName: string, password: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function register(userName: string, password: string) {
|
export function register(userName: string, password: string) {
|
||||||
return apiRequest<AuthResponse>('/auth/register', { method: 'POST', body: { userName, password } })
|
return apiRequest<AuthResponse>('/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { userName, password },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logout() {
|
export function logout() {
|
||||||
@@ -20,7 +23,10 @@ export function logout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function changePassword(currentPassword: string, newPassword: string) {
|
export function changePassword(currentPassword: string, newPassword: string) {
|
||||||
return apiRequest<void>('/auth/change-password', { method: 'POST', body: { currentPassword, newPassword } })
|
return apiRequest<void>('/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { currentPassword, newPassword },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function changeUserName(newUserName: string) {
|
export function changeUserName(newUserName: string) {
|
||||||
@@ -39,7 +45,10 @@ export function applyAuthResponse(auth: AuthResponse) {
|
|||||||
/** Тихая попытка восстановить сессию по refresh-cookie при загрузке приложения. */
|
/** Тихая попытка восстановить сессию по refresh-cookie при загрузке приложения. */
|
||||||
export async function bootstrapSession() {
|
export async function bootstrapSession() {
|
||||||
try {
|
try {
|
||||||
const auth = await apiRequest<AuthResponse>('/auth/refresh', { method: 'POST', skipRefresh: true })
|
const auth = await apiRequest<AuthResponse>('/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
skipRefresh: true,
|
||||||
|
})
|
||||||
applyAuthResponse(auth)
|
applyAuthResponse(auth)
|
||||||
} catch {
|
} catch {
|
||||||
setAccessToken(null)
|
setAccessToken(null)
|
||||||
|
|||||||
@@ -1,339 +1,332 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Radio, RotateCw } from 'lucide-react'
|
import { Radio, RotateCw } from 'lucide-react'
|
||||||
import { qk } from '@/shared/api/query-keys'
|
import { qk } from '@/shared/api/query-keys'
|
||||||
import type { PublicEpgEntryDto } from '@/shared/api/types'
|
import type { PublicEpgEntryDto } from '@/shared/api/types'
|
||||||
import { cn } from '@/shared/lib/cn'
|
import { cn } from '@/shared/lib/cn'
|
||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { ChannelPlayer } from './ChannelPlayer'
|
import { ChannelPlayer } from './ChannelPlayer'
|
||||||
import { getEpg, getViewerFeatures, imageUrl, listChannels, watchChannel } from './api'
|
import { getEpg, getViewerFeatures, imageUrl, listChannels, watchChannel } from './api'
|
||||||
|
|
||||||
function formatTime(iso: string) {
|
function formatTime(iso: string) {
|
||||||
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Кадр серии, если он есть; иначе постер шоу (он вертикальный, отсюда другая ширина). */
|
/** Кадр серии, если он есть; иначе постер шоу (он вертикальный, отсюда другая ширина). */
|
||||||
function EntryThumb({ entry }: Readonly<{ entry: PublicEpgEntryDto | undefined }>) {
|
function EntryThumb({ entry }: Readonly<{ entry: PublicEpgEntryDto | undefined }>) {
|
||||||
if (entry?.episodeStillImageId)
|
if (entry?.episodeStillImageId)
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={imageUrl(entry.episodeStillImageId)}
|
src={imageUrl(entry.episodeStillImageId)}
|
||||||
alt=""
|
alt=""
|
||||||
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
|
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (entry?.showPosterImageId)
|
if (entry?.showPosterImageId)
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={imageUrl(entry.showPosterImageId)}
|
src={imageUrl(entry.showPosterImageId)}
|
||||||
alt=""
|
alt=""
|
||||||
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
|
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AirPage() {
|
export function AirPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
const [watchReady, setWatchReady] = useState(false)
|
const [watchReady, setWatchReady] = useState(false)
|
||||||
const [playerError, setPlayerError] = useState(false)
|
const [playerError, setPlayerError] = useState(false)
|
||||||
const [attempt, setAttempt] = useState(0)
|
const [attempt, setAttempt] = useState(0)
|
||||||
const [flash, setFlash] = useState(false)
|
const [flash, setFlash] = useState(false)
|
||||||
|
|
||||||
const handleUnavailable = useCallback(() => setPlayerError(true), [])
|
const handleUnavailable = useCallback(() => setPlayerError(true), [])
|
||||||
const retry = () => {
|
const retry = () => {
|
||||||
setPlayerError(false)
|
setPlayerError(false)
|
||||||
setAttempt((a) => a + 1)
|
setAttempt((a) => a + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: channels, isLoading } = useQuery({
|
const { data: channels, isLoading } = useQuery({
|
||||||
queryKey: qk.air.channels,
|
queryKey: qk.air.channels,
|
||||||
queryFn: listChannels,
|
queryFn: listChannels,
|
||||||
})
|
})
|
||||||
const { data: features } = useQuery({ queryKey: qk.air.features, queryFn: getViewerFeatures })
|
const { data: features } = useQuery({ queryKey: qk.air.features, queryFn: getViewerFeatures })
|
||||||
|
|
||||||
const numbersEnabled = features?.channelNumbersEnabled ?? false
|
const numbersEnabled = features?.channelNumbersEnabled ?? false
|
||||||
const currentChannel = channels?.find((c) => c.slug === selected)
|
const currentChannel = channels?.find((c) => c.slug === selected)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Переключение по номерам: список уже отсортирован сервером, поэтому «вверх-вниз» — это шаг
|
* Переключение по номерам: список уже отсортирован сервером, поэтому «вверх-вниз» — это шаг
|
||||||
* по нему. Короткий чёрный кадр с номером ставится сразу, до готовности потока.
|
* по нему. Короткий чёрный кадр с номером ставится сразу, до готовности потока.
|
||||||
*/
|
*/
|
||||||
const step = useCallback(
|
const step = useCallback(
|
||||||
(delta: number) => {
|
(delta: number) => {
|
||||||
if (!channels || channels.length === 0) return
|
if (!channels || channels.length === 0) return
|
||||||
const index = channels.findIndex((c) => c.slug === selected)
|
const index = channels.findIndex((c) => c.slug === selected)
|
||||||
const next = channels[(index + delta + channels.length) % channels.length]
|
const next = channels[(index + delta + channels.length) % channels.length]
|
||||||
if (!next || next.slug === selected) return
|
if (!next || next.slug === selected) return
|
||||||
setFlash(true)
|
setFlash(true)
|
||||||
setSelected(next.slug)
|
setSelected(next.slug)
|
||||||
},
|
},
|
||||||
[channels, selected],
|
[channels, selected],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!flash) return
|
if (!flash) return
|
||||||
const id = window.setTimeout(() => setFlash(false), 900)
|
const id = window.setTimeout(() => setFlash(false), 900)
|
||||||
return () => window.clearTimeout(id)
|
return () => window.clearTimeout(id)
|
||||||
}, [flash, selected])
|
}, [flash, selected])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!numbersEnabled) return
|
if (!numbersEnabled) return
|
||||||
const onKey = (event: KeyboardEvent) => {
|
const onKey = (event: KeyboardEvent) => {
|
||||||
// Не перехватываем стрелки, пока фокус в поле ввода: там они двигают каретку.
|
// Не перехватываем стрелки, пока фокус в поле ввода: там они двигают каретку.
|
||||||
const target = event.target as HTMLElement | null
|
const target = event.target as HTMLElement | null
|
||||||
if (target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return
|
if (target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return
|
||||||
if (event.key === 'ArrowUp' || event.key === 'PageUp') {
|
if (event.key === 'ArrowUp' || event.key === 'PageUp') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
step(-1)
|
step(-1)
|
||||||
} else if (event.key === 'ArrowDown' || event.key === 'PageDown') {
|
} else if (event.key === 'ArrowDown' || event.key === 'PageDown') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
step(1)
|
step(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [numbersEnabled, step])
|
}, [numbersEnabled, step])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selected && channels && channels.length > 0) setSelected(channels[0].slug)
|
if (!selected && channels && channels.length > 0) setSelected(channels[0].slug)
|
||||||
}, [channels, selected])
|
}, [channels, selected])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selected) return
|
if (!selected) return
|
||||||
setWatchReady(false)
|
setWatchReady(false)
|
||||||
setPlayerError(false)
|
setPlayerError(false)
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void watchChannel(selected)
|
void watchChannel(selected)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (!cancelled) setWatchReady(true)
|
if (!cancelled) setWatchReady(true)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Выдача stream-cookie не удалась (403/500/сеть) — показываем offline-панель с кнопкой ретрая,
|
// Выдача stream-cookie не удалась (403/500/сеть) — показываем offline-панель с кнопкой ретрая,
|
||||||
// а не бесконечный скелетон. Ретрай (attempt) заново дёрнет watchChannel.
|
// а не бесконечный скелетон. Ретрай (attempt) заново дёрнет watchChannel.
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setWatchReady(true)
|
setWatchReady(true)
|
||||||
setPlayerError(true)
|
setPlayerError(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [selected, attempt])
|
}, [selected, attempt])
|
||||||
|
|
||||||
// Stream-cookie короткоживущий (TTL на сервере ~30 мин) — периодически перевыпускаем, пока смотрим,
|
// Stream-cookie короткоживущий (TTL на сервере ~30 мин) — периодически перевыпускаем, пока смотрим,
|
||||||
// иначе плейлист/сегменты начнут отдавать 401 посреди эфира. Тихо: ошибку словит перезагрузка плейлиста.
|
// иначе плейлист/сегменты начнут отдавать 401 посреди эфира. Тихо: ошибку словит перезагрузка плейлиста.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selected || playerError) return
|
if (!selected || playerError) return
|
||||||
const id = window.setInterval(
|
const id = window.setInterval(() => {
|
||||||
() => {
|
void watchChannel(selected).catch(() => undefined)
|
||||||
void watchChannel(selected).catch(() => undefined)
|
}, 20 * 60_000)
|
||||||
},
|
return () => window.clearInterval(id)
|
||||||
20 * 60_000,
|
}, [selected, playerError])
|
||||||
)
|
|
||||||
return () => window.clearInterval(id)
|
const { data: epg } = useQuery({
|
||||||
}, [selected, playerError])
|
queryKey: qk.air.epg(selected),
|
||||||
|
queryFn: () =>
|
||||||
const { data: epg } = useQuery({
|
getEpg(selected!, new Date(Date.now() - 30 * 60_000), new Date(Date.now() + 3 * 60 * 60_000)),
|
||||||
queryKey: qk.air.epg(selected),
|
enabled: !!selected,
|
||||||
queryFn: () =>
|
refetchInterval: 60_000,
|
||||||
getEpg(
|
})
|
||||||
selected!,
|
|
||||||
new Date(Date.now() - 30 * 60_000),
|
const { current, upcoming, currentEntry } = useMemo(() => buildGuide(epg ?? []), [epg])
|
||||||
new Date(Date.now() + 3 * 60 * 60_000),
|
|
||||||
),
|
// Плашка «Далее» — только на исходе программы: висеть весь эфир ей незачем.
|
||||||
enabled: !!selected,
|
const nextUp =
|
||||||
refetchInterval: 60_000,
|
current && upcoming.length > 0 && new Date(current.endsAtUtc).getTime() - Date.now() < 60_000
|
||||||
})
|
? upcoming[0].showName
|
||||||
|
: null
|
||||||
const { current, upcoming, currentEntry } = useMemo(() => buildGuide(epg ?? []), [epg])
|
|
||||||
|
if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||||
// Плашка «Далее» — только на исходе программы: висеть весь эфир ей незачем.
|
|
||||||
const nextUp =
|
if (!channels || channels.length === 0)
|
||||||
current && upcoming.length > 0 && new Date(current.endsAtUtc).getTime() - Date.now() < 60_000
|
return (
|
||||||
? upcoming[0].showName
|
<div className="flex flex-col gap-2">
|
||||||
: null
|
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
|
||||||
|
<p className="text-muted-foreground">{t('air.noChannels')}</p>
|
||||||
if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
</div>
|
||||||
|
)
|
||||||
if (!channels || channels.length === 0)
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-4">
|
||||||
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<p className="text-muted-foreground">{t('air.noChannels')}</p>
|
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
|
||||||
</div>
|
{numbersEnabled && (
|
||||||
)
|
<span className="text-xs text-muted-foreground">{t('air.numbersHint')}</span>
|
||||||
|
)}
|
||||||
return (
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
|
||||||
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
|
<aside className="flex gap-2 overflow-x-auto md:flex-col md:overflow-visible">
|
||||||
{numbersEnabled && (
|
{channels.map((channel) => (
|
||||||
<span className="text-xs text-muted-foreground">{t('air.numbersHint')}</span>
|
<button
|
||||||
)}
|
key={channel.id}
|
||||||
</div>
|
type="button"
|
||||||
|
onClick={() => setSelected(channel.slug)}
|
||||||
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
|
className={cn(
|
||||||
<aside className="flex gap-2 overflow-x-auto md:flex-col md:overflow-visible">
|
'flex shrink-0 items-center gap-2 rounded-sm border border-border px-3 py-2 text-left text-sm hover:bg-muted md:shrink',
|
||||||
{channels.map((channel) => (
|
selected === channel.slug && 'border-primary text-primary',
|
||||||
<button
|
)}
|
||||||
key={channel.id}
|
>
|
||||||
type="button"
|
{numbersEnabled && channel.number !== null && (
|
||||||
onClick={() => setSelected(channel.slug)}
|
<span className="w-6 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
|
||||||
className={cn(
|
{channel.number}
|
||||||
'flex shrink-0 items-center gap-2 rounded-sm border border-border px-3 py-2 text-left text-sm hover:bg-muted md:shrink',
|
</span>
|
||||||
selected === channel.slug && 'border-primary text-primary',
|
)}
|
||||||
)}
|
{channel.currentShowPosterImageId ? (
|
||||||
>
|
<img
|
||||||
{numbersEnabled && channel.number !== null && (
|
src={imageUrl(channel.currentShowPosterImageId)}
|
||||||
<span className="w-6 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
|
alt=""
|
||||||
{channel.number}
|
className="h-10 w-7 shrink-0 rounded object-cover"
|
||||||
</span>
|
/>
|
||||||
)}
|
) : (
|
||||||
{channel.currentShowPosterImageId ? (
|
<Radio className="h-4 w-4 shrink-0" />
|
||||||
<img
|
)}
|
||||||
src={imageUrl(channel.currentShowPosterImageId)}
|
<span className="flex min-w-0 flex-col">
|
||||||
alt=""
|
<span className="truncate">{channel.name}</span>
|
||||||
className="h-10 w-7 shrink-0 rounded object-cover"
|
{channel.currentShowName && (
|
||||||
/>
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
) : (
|
{channel.currentShowName}
|
||||||
<Radio className="h-4 w-4 shrink-0" />
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="flex min-w-0 flex-col">
|
</span>
|
||||||
<span className="truncate">{channel.name}</span>
|
</button>
|
||||||
{channel.currentShowName && (
|
))}
|
||||||
<span className="truncate text-xs text-muted-foreground">
|
</aside>
|
||||||
{channel.currentShowName}
|
|
||||||
</span>
|
<div className="flex flex-col gap-4">
|
||||||
)}
|
{/* До выдачи cookie tw_stream плеер грузить нечем — держим место заглушкой. */}
|
||||||
</span>
|
{(!selected || !watchReady) && (
|
||||||
</button>
|
<div className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
|
||||||
))}
|
)}
|
||||||
</aside>
|
{selected && watchReady && playerError && (
|
||||||
|
<div className="crt-panel flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-md text-center">
|
||||||
<div className="flex flex-col gap-4">
|
<Radio className="h-10 w-10 text-muted-foreground" strokeWidth={1} />
|
||||||
{/* До выдачи cookie tw_stream плеер грузить нечем — держим место заглушкой. */}
|
<div className="flex flex-col gap-1">
|
||||||
{(!selected || !watchReady) && (
|
<p className="font-medium">{t('air.offline')}</p>
|
||||||
<div className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
|
<p className="text-sm text-muted-foreground">{t('air.offlineHint')}</p>
|
||||||
)}
|
</div>
|
||||||
{selected && watchReady && playerError && (
|
<Button size="sm" variant="outline" onClick={retry}>
|
||||||
<div className="crt-panel flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-md text-center">
|
<RotateCw className="h-4 w-4" />
|
||||||
<Radio className="h-10 w-10 text-muted-foreground" strokeWidth={1} />
|
{t('air.retry')}
|
||||||
<div className="flex flex-col gap-1">
|
</Button>
|
||||||
<p className="font-medium">{t('air.offline')}</p>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{t('air.offlineHint')}</p>
|
)}
|
||||||
</div>
|
{selected && watchReady && !playerError && (
|
||||||
<Button size="sm" variant="outline" onClick={retry}>
|
<ChannelPlayer
|
||||||
<RotateCw className="h-4 w-4" />
|
key={`${selected}-${attempt}`}
|
||||||
{t('air.retry')}
|
slug={selected}
|
||||||
</Button>
|
channel={currentChannel}
|
||||||
</div>
|
nextUp={nextUp}
|
||||||
)}
|
flash={flash}
|
||||||
{selected && watchReady && !playerError && (
|
onUnavailable={handleUnavailable}
|
||||||
<ChannelPlayer
|
/>
|
||||||
key={`${selected}-${attempt}`}
|
)}
|
||||||
slug={selected}
|
|
||||||
channel={currentChannel}
|
<div className="flex flex-col gap-3">
|
||||||
nextUp={nextUp}
|
{current && (
|
||||||
flash={flash}
|
<div className="crt-panel flex gap-3 rounded-md p-3">
|
||||||
onUnavailable={handleUnavailable}
|
<EntryThumb entry={currentEntry} />
|
||||||
/>
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
)}
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge>{t('air.now')}</Badge>
|
||||||
<div className="flex flex-col gap-3">
|
<span className="font-medium">{current.showName}</span>
|
||||||
{current && (
|
</div>
|
||||||
<div className="crt-panel flex gap-3 rounded-md p-3">
|
{currentEntry?.episodeTitle && (
|
||||||
<EntryThumb entry={currentEntry} />
|
<span className="text-sm">{currentEntry.episodeTitle}</span>
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-xs text-muted-foreground">
|
||||||
<Badge>{t('air.now')}</Badge>
|
{formatTime(current.startsAtUtc)} – {formatTime(current.endsAtUtc)}
|
||||||
<span className="font-medium">{current.showName}</span>
|
</span>
|
||||||
</div>
|
{currentEntry?.episodeOverview && (
|
||||||
{currentEntry?.episodeTitle && (
|
<p className="line-clamp-3 text-xs text-muted-foreground">
|
||||||
<span className="text-sm">{currentEntry.episodeTitle}</span>
|
{currentEntry.episodeOverview}
|
||||||
)}
|
</p>
|
||||||
<span className="text-xs text-muted-foreground">
|
)}
|
||||||
{formatTime(current.startsAtUtc)} – {formatTime(current.endsAtUtc)}
|
</div>
|
||||||
</span>
|
</div>
|
||||||
{currentEntry?.episodeOverview && (
|
)}
|
||||||
<p className="line-clamp-3 text-xs text-muted-foreground">
|
|
||||||
{currentEntry.episodeOverview}
|
{upcoming.length > 0 && (
|
||||||
</p>
|
<div className="crt-panel rounded-md">
|
||||||
)}
|
<div className="border-b border-border px-4 py-2 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
</div>
|
{t('air.next')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<ul className="divide-y divide-border">
|
||||||
|
{upcoming.slice(0, 6).map((block) => (
|
||||||
{upcoming.length > 0 && (
|
<li key={block.key} className="flex items-center gap-3 px-4 py-2 text-sm">
|
||||||
<div className="crt-panel rounded-md">
|
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
||||||
<div className="border-b border-border px-4 py-2 text-xs uppercase tracking-wide text-muted-foreground">
|
{formatTime(block.startsAtUtc)} – {formatTime(block.endsAtUtc)}
|
||||||
{t('air.next')}
|
</span>
|
||||||
</div>
|
<span>{block.showName}</span>
|
||||||
<ul className="divide-y divide-border">
|
</li>
|
||||||
{upcoming.slice(0, 6).map((block) => (
|
))}
|
||||||
<li key={block.key} className="flex items-center gap-3 px-4 py-2 text-sm">
|
</ul>
|
||||||
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
</div>
|
||||||
{formatTime(block.startsAtUtc)} – {formatTime(block.endsAtUtc)}
|
)}
|
||||||
</span>
|
</div>
|
||||||
<span>{block.showName}</span>
|
</div>
|
||||||
</li>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</ul>
|
)
|
||||||
</div>
|
}
|
||||||
)}
|
|
||||||
</div>
|
type GuideBlock = {
|
||||||
</div>
|
key: string
|
||||||
</div>
|
showId: string | null
|
||||||
</div>
|
showName: string
|
||||||
)
|
startsAtUtc: string
|
||||||
}
|
endsAtUtc: string
|
||||||
|
}
|
||||||
type GuideBlock = {
|
|
||||||
key: string
|
/**
|
||||||
showId: string | null
|
* Строит телегид: рекламу/заставки не показываем, а подряд идущие серии одного шоу склеиваем в один
|
||||||
showName: string
|
* блок с диапазоном «с – по». Отдельно возвращаем текущую серию (для метаданных карточки «сейчас»).
|
||||||
startsAtUtc: string
|
*/
|
||||||
endsAtUtc: string
|
function buildGuide(entries: PublicEpgEntryDto[]): {
|
||||||
}
|
current?: GuideBlock
|
||||||
|
upcoming: GuideBlock[]
|
||||||
/**
|
currentEntry?: PublicEpgEntryDto
|
||||||
* Строит телегид: рекламу/заставки не показываем, а подряд идущие серии одного шоу склеиваем в один
|
} {
|
||||||
* блок с диапазоном «с – по». Отдельно возвращаем текущую серию (для метаданных карточки «сейчас»).
|
const blocks: GuideBlock[] = []
|
||||||
*/
|
for (const entry of entries) {
|
||||||
function buildGuide(entries: PublicEpgEntryDto[]): {
|
if (entry.kind !== 'Program') continue
|
||||||
current?: GuideBlock
|
const last = blocks[blocks.length - 1]
|
||||||
upcoming: GuideBlock[]
|
if (last && last.showId === entry.showId) {
|
||||||
currentEntry?: PublicEpgEntryDto
|
last.endsAtUtc = entry.endsAtUtc
|
||||||
} {
|
} else {
|
||||||
const blocks: GuideBlock[] = []
|
blocks.push({
|
||||||
for (const entry of entries) {
|
key: entry.startsAtUtc,
|
||||||
if (entry.kind !== 'Program') continue
|
showId: entry.showId,
|
||||||
const last = blocks[blocks.length - 1]
|
showName: entry.showName ?? '—',
|
||||||
if (last && last.showId === entry.showId) {
|
startsAtUtc: entry.startsAtUtc,
|
||||||
last.endsAtUtc = entry.endsAtUtc
|
endsAtUtc: entry.endsAtUtc,
|
||||||
} else {
|
})
|
||||||
blocks.push({
|
}
|
||||||
key: entry.startsAtUtc,
|
}
|
||||||
showId: entry.showId,
|
|
||||||
showName: entry.showName ?? '—',
|
const now = Date.now()
|
||||||
startsAtUtc: entry.startsAtUtc,
|
const active = (start: string, end: string) =>
|
||||||
endsAtUtc: entry.endsAtUtc,
|
new Date(start).getTime() <= now && new Date(end).getTime() > now
|
||||||
})
|
const current = blocks.find((b) => active(b.startsAtUtc, b.endsAtUtc))
|
||||||
}
|
const upcoming = blocks.filter((b) => new Date(b.startsAtUtc).getTime() > now)
|
||||||
}
|
const currentEntry = entries.find(
|
||||||
|
(e) => e.kind === 'Program' && active(e.startsAtUtc, e.endsAtUtc),
|
||||||
const now = Date.now()
|
)
|
||||||
const active = (start: string, end: string) =>
|
return { current, upcoming, currentEntry }
|
||||||
new Date(start).getTime() <= now && new Date(end).getTime() > now
|
}
|
||||||
const current = blocks.find((b) => active(b.startsAtUtc, b.endsAtUtc))
|
|
||||||
const upcoming = blocks.filter((b) => new Date(b.startsAtUtc).getTime() > now)
|
|
||||||
const currentEntry = entries.find(
|
|
||||||
(e) => e.kind === 'Program' && active(e.startsAtUtc, e.endsAtUtc),
|
|
||||||
)
|
|
||||||
return { current, upcoming, currentEntry }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,286 +1,285 @@
|
|||||||
import Hls from 'hls.js'
|
import Hls from 'hls.js'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Maximize, Volume2, VolumeX } from 'lucide-react'
|
import { Maximize, Volume2, VolumeX } from 'lucide-react'
|
||||||
import type { PublicChannelDto } from '@/shared/api/types'
|
import type { PublicChannelDto } from '@/shared/api/types'
|
||||||
import {
|
import {
|
||||||
AnalogFilter,
|
AnalogFilter,
|
||||||
ChannelFlash,
|
ChannelFlash,
|
||||||
ChannelLogo,
|
ChannelLogo,
|
||||||
NextUpBanner,
|
NextUpBanner,
|
||||||
ScreenClock,
|
ScreenClock,
|
||||||
} from './PlayerOverlays'
|
} from './PlayerOverlays'
|
||||||
|
|
||||||
const STORAGE_KEY = 'tw:player'
|
const STORAGE_KEY = 'tw:player'
|
||||||
|
|
||||||
/** Читает сохранённые громкость/mute из localStorage (с валидацией и дефолтами). */
|
/** Читает сохранённые громкость/mute из localStorage (с валидацией и дефолтами). */
|
||||||
function readStoredAudio(): { volume: number; muted: boolean } {
|
function readStoredAudio(): { volume: number; muted: boolean } {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const parsed = JSON.parse(raw) as { volume?: unknown; muted?: unknown }
|
const parsed = JSON.parse(raw) as { volume?: unknown; muted?: unknown }
|
||||||
const volume =
|
const volume = typeof parsed.volume === 'number' ? Math.min(1, Math.max(0, parsed.volume)) : 1
|
||||||
typeof parsed.volume === 'number' ? Math.min(1, Math.max(0, parsed.volume)) : 1
|
const muted = typeof parsed.muted === 'boolean' ? parsed.muted : true
|
||||||
const muted = typeof parsed.muted === 'boolean' ? parsed.muted : true
|
return { volume, muted }
|
||||||
return { volume, muted }
|
}
|
||||||
}
|
} catch {
|
||||||
} catch {
|
/* недоступен/битый localStorage — дефолты */
|
||||||
/* недоступен/битый localStorage — дефолты */
|
}
|
||||||
}
|
return { volume: 1, muted: true }
|
||||||
return { volume: 1, muted: true }
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
/**
|
* Восстанавливает сохранённую громкость/mute и запускает воспроизведение; если браузер блокирует
|
||||||
* Восстанавливает сохранённую громкость/mute и запускает воспроизведение; если браузер блокирует
|
* автоплей со звуком — откатывается на воспроизведение без звука (о чём сообщает <c>onMuted</c>).
|
||||||
* автоплей со звуком — откатывается на воспроизведение без звука (о чём сообщает <c>onMuted</c>).
|
*/
|
||||||
*/
|
function startPlaybackWithAudio(
|
||||||
function startPlaybackWithAudio(
|
video: HTMLVideoElement,
|
||||||
video: HTMLVideoElement,
|
audio: { volume: number; muted: boolean },
|
||||||
audio: { volume: number; muted: boolean },
|
onMuted: (muted: boolean) => void,
|
||||||
onMuted: (muted: boolean) => void,
|
) {
|
||||||
) {
|
video.volume = audio.volume
|
||||||
video.volume = audio.volume
|
video.muted = audio.muted
|
||||||
video.muted = audio.muted
|
video.play().catch(() => {
|
||||||
video.play().catch(() => {
|
video.muted = true
|
||||||
video.muted = true
|
onMuted(true)
|
||||||
onMuted(true)
|
void video.play().catch(() => undefined)
|
||||||
void video.play().catch(() => undefined)
|
})
|
||||||
})
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
/**
|
* HLS-плеер линейного канала. Это живой эфир: ни перемотки, ни паузы — только звук, громкость и
|
||||||
* HLS-плеер линейного канала. Это живой эфир: ни перемотки, ни паузы — только звук, громкость и
|
* полноэкранный режим. Cookie tw_stream уже выдана к монтированию.
|
||||||
* полноэкранный режим. Cookie tw_stream уже выдана к монтированию.
|
*/
|
||||||
*/
|
export function ChannelPlayer({
|
||||||
export function ChannelPlayer({
|
slug,
|
||||||
slug,
|
channel,
|
||||||
channel,
|
nextUp,
|
||||||
nextUp,
|
flash,
|
||||||
flash,
|
onUnavailable,
|
||||||
onUnavailable,
|
}: Readonly<{
|
||||||
}: Readonly<{
|
slug: string
|
||||||
slug: string
|
/** Канал, чьи оверлеи рисуем. Всё опционально: канал без логотипа и без шума — норма. */
|
||||||
/** Канал, чьи оверлеи рисуем. Всё опционально: канал без логотипа и без шума — норма. */
|
channel?: PublicChannelDto
|
||||||
channel?: PublicChannelDto
|
/** Название следующей программы, когда до неё осталось меньше минуты. */
|
||||||
/** Название следующей программы, когда до неё осталось меньше минуты. */
|
nextUp?: string | null
|
||||||
nextUp?: string | null
|
/** Показать чёрный кадр с номером — переключение по номерам, как на телевизоре. */
|
||||||
/** Показать чёрный кадр с номером — переключение по номерам, как на телевизоре. */
|
flash?: boolean
|
||||||
flash?: boolean
|
onUnavailable?: () => void
|
||||||
onUnavailable?: () => void
|
}>) {
|
||||||
}>) {
|
const { t } = useTranslation()
|
||||||
const { t } = useTranslation()
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
const hlsRef = useRef<Hls | null>(null)
|
||||||
const hlsRef = useRef<Hls | null>(null)
|
const [muted, setMuted] = useState(() => readStoredAudio().muted)
|
||||||
const [muted, setMuted] = useState(() => readStoredAudio().muted)
|
const [volume, setVolume] = useState(() => readStoredAudio().volume)
|
||||||
const [volume, setVolume] = useState(() => readStoredAudio().volume)
|
|
||||||
|
// Последние настройки звука без пересоздания HLS-эффекта + сохранение в localStorage.
|
||||||
// Последние настройки звука без пересоздания HLS-эффекта + сохранение в localStorage.
|
const audioRef = useRef({ volume, muted })
|
||||||
const audioRef = useRef({ volume, muted })
|
useEffect(() => {
|
||||||
useEffect(() => {
|
audioRef.current = { volume, muted }
|
||||||
audioRef.current = { volume, muted }
|
try {
|
||||||
try {
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ volume, muted }))
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ volume, muted }))
|
} catch {
|
||||||
} catch {
|
/* localStorage недоступен — не критично */
|
||||||
/* localStorage недоступен — не критично */
|
}
|
||||||
}
|
}, [volume, muted])
|
||||||
}, [volume, muted])
|
const [controlsVisible, setControlsVisible] = useState(true)
|
||||||
const [controlsVisible, setControlsVisible] = useState(true)
|
const hideTimerRef = useRef<number | null>(null)
|
||||||
const hideTimerRef = useRef<number | null>(null)
|
const overControlsRef = useRef(false)
|
||||||
const overControlsRef = useRef(false)
|
|
||||||
|
const clearHideTimer = useCallback(() => {
|
||||||
const clearHideTimer = useCallback(() => {
|
if (hideTimerRef.current !== null) {
|
||||||
if (hideTimerRef.current !== null) {
|
window.clearTimeout(hideTimerRef.current)
|
||||||
window.clearTimeout(hideTimerRef.current)
|
hideTimerRef.current = null
|
||||||
hideTimerRef.current = null
|
}
|
||||||
}
|
}, [])
|
||||||
}, [])
|
|
||||||
|
// Прячем панель и курсор после бездействия — но не когда курсор на панели или видео на паузе.
|
||||||
// Прячем панель и курсор после бездействия — но не когда курсор на панели или видео на паузе.
|
const scheduleHide = useCallback(() => {
|
||||||
const scheduleHide = useCallback(() => {
|
clearHideTimer()
|
||||||
clearHideTimer()
|
hideTimerRef.current = window.setTimeout(() => {
|
||||||
hideTimerRef.current = window.setTimeout(() => {
|
if (!overControlsRef.current && videoRef.current && !videoRef.current.paused) {
|
||||||
if (!overControlsRef.current && videoRef.current && !videoRef.current.paused) {
|
setControlsVisible(false)
|
||||||
setControlsVisible(false)
|
}
|
||||||
}
|
}, 2500)
|
||||||
}, 2500)
|
}, [clearHideTimer])
|
||||||
}, [clearHideTimer])
|
|
||||||
|
const revealControls = useCallback(() => {
|
||||||
const revealControls = useCallback(() => {
|
setControlsVisible(true)
|
||||||
setControlsVisible(true)
|
scheduleHide()
|
||||||
scheduleHide()
|
}, [scheduleHide])
|
||||||
}, [scheduleHide])
|
|
||||||
|
useEffect(() => clearHideTimer, [clearHideTimer])
|
||||||
useEffect(() => clearHideTimer, [clearHideTimer])
|
|
||||||
|
useEffect(() => {
|
||||||
useEffect(() => {
|
const video = videoRef.current
|
||||||
const video = videoRef.current
|
if (!video) return
|
||||||
if (!video) return
|
const src = `/api/channels/${slug}/live.m3u8`
|
||||||
const src = `/api/channels/${slug}/live.m3u8`
|
let hls: Hls | null = null
|
||||||
let hls: Hls | null = null
|
|
||||||
|
const startPlayback = () => startPlaybackWithAudio(video, audioRef.current, setMuted)
|
||||||
const startPlayback = () => startPlaybackWithAudio(video, audioRef.current, setMuted)
|
|
||||||
|
// Слушатели нативной ветки — держим ссылки, чтобы снять их в cleanup (симметрично hls.destroy()).
|
||||||
// Слушатели нативной ветки — держим ссылки, чтобы снять их в cleanup (симметрично hls.destroy()).
|
const onNativeError = () => onUnavailable?.()
|
||||||
const onNativeError = () => onUnavailable?.()
|
|
||||||
|
if (Hls.isSupported()) {
|
||||||
if (Hls.isSupported()) {
|
hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false })
|
||||||
hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false })
|
hlsRef.current = hls
|
||||||
hlsRef.current = hls
|
hls.loadSource(src)
|
||||||
hls.loadSource(src)
|
hls.attachMedia(video)
|
||||||
hls.attachMedia(video)
|
hls.on(Hls.Events.MANIFEST_PARSED, startPlayback)
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, startPlayback)
|
|
||||||
|
// Живой эфир: краткий сетевой сбой сегмента или media-ошибку сперва пробуем восстановить
|
||||||
// Живой эфир: краткий сетевой сбой сегмента или media-ошибку сперва пробуем восстановить
|
// (hls.js рекомендует startLoad / recoverMediaError), и только исчерпав попытки — уходим в offline.
|
||||||
// (hls.js рекомендует startLoad / recoverMediaError), и только исчерпав попытки — уходим в offline.
|
let recoverAttempts = 0
|
||||||
let recoverAttempts = 0
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
if (!data.fatal) return
|
||||||
if (!data.fatal) return
|
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && recoverAttempts < 3) {
|
||||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && recoverAttempts < 3) {
|
recoverAttempts += 1
|
||||||
recoverAttempts += 1
|
hls?.startLoad()
|
||||||
hls?.startLoad()
|
return
|
||||||
return
|
}
|
||||||
}
|
if (data.type === Hls.ErrorTypes.MEDIA_ERROR && recoverAttempts < 3) {
|
||||||
if (data.type === Hls.ErrorTypes.MEDIA_ERROR && recoverAttempts < 3) {
|
recoverAttempts += 1
|
||||||
recoverAttempts += 1
|
hls?.recoverMediaError()
|
||||||
hls?.recoverMediaError()
|
return
|
||||||
return
|
}
|
||||||
}
|
hls?.destroy()
|
||||||
hls?.destroy()
|
hls = null
|
||||||
hls = null
|
hlsRef.current = null
|
||||||
hlsRef.current = null
|
onUnavailable?.()
|
||||||
onUnavailable?.()
|
})
|
||||||
})
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
video.src = src
|
||||||
video.src = src
|
video.addEventListener('loadedmetadata', startPlayback)
|
||||||
video.addEventListener('loadedmetadata', startPlayback)
|
video.addEventListener('error', onNativeError)
|
||||||
video.addEventListener('error', onNativeError)
|
} else {
|
||||||
} else {
|
onUnavailable?.()
|
||||||
onUnavailable?.()
|
}
|
||||||
}
|
|
||||||
|
return () => {
|
||||||
return () => {
|
hls?.destroy()
|
||||||
hls?.destroy()
|
hlsRef.current = null
|
||||||
hlsRef.current = null
|
video.removeEventListener('loadedmetadata', startPlayback)
|
||||||
video.removeEventListener('loadedmetadata', startPlayback)
|
video.removeEventListener('error', onNativeError)
|
||||||
video.removeEventListener('error', onNativeError)
|
}
|
||||||
}
|
}, [slug, onUnavailable])
|
||||||
}, [slug, onUnavailable])
|
|
||||||
|
// Установить громкость (0..1); 0 = mute, >0 запоминаем как последний уровень для «размьютить».
|
||||||
// Установить громкость (0..1); 0 = mute, >0 запоминаем как последний уровень для «размьютить».
|
const applyVolume = (value: number) => {
|
||||||
const applyVolume = (value: number) => {
|
const video = videoRef.current
|
||||||
const video = videoRef.current
|
if (!video) return
|
||||||
if (!video) return
|
const clamped = Math.min(1, Math.max(0, value))
|
||||||
const clamped = Math.min(1, Math.max(0, value))
|
video.volume = clamped
|
||||||
video.volume = clamped
|
video.muted = clamped === 0
|
||||||
video.muted = clamped === 0
|
setMuted(clamped === 0)
|
||||||
setMuted(clamped === 0)
|
if (clamped > 0) setVolume(clamped)
|
||||||
if (clamped > 0) setVolume(clamped)
|
}
|
||||||
}
|
|
||||||
|
const toggleMute = () => {
|
||||||
const toggleMute = () => {
|
const video = videoRef.current
|
||||||
const video = videoRef.current
|
if (!video) return
|
||||||
if (!video) return
|
if (video.muted || video.volume === 0) {
|
||||||
if (video.muted || video.volume === 0) {
|
applyVolume(volume > 0 ? volume : 0.5)
|
||||||
applyVolume(volume > 0 ? volume : 0.5)
|
} else {
|
||||||
} else {
|
video.muted = true
|
||||||
video.muted = true
|
setMuted(true)
|
||||||
setMuted(true)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
const toggleFullscreen = () => {
|
const container = containerRef.current
|
||||||
const container = containerRef.current
|
if (!container) return
|
||||||
if (!container) return
|
if (document.fullscreenElement) void document.exitFullscreen()
|
||||||
if (document.fullscreenElement) void document.exitFullscreen()
|
else void container.requestFullscreen().catch(() => undefined)
|
||||||
else void container.requestFullscreen().catch(() => undefined)
|
}
|
||||||
}
|
|
||||||
|
return (
|
||||||
return (
|
<div
|
||||||
<div
|
ref={containerRef}
|
||||||
ref={containerRef}
|
onMouseMove={revealControls}
|
||||||
onMouseMove={revealControls}
|
onMouseLeave={() => {
|
||||||
onMouseLeave={() => {
|
clearHideTimer()
|
||||||
clearHideTimer()
|
if (videoRef.current && !videoRef.current.paused) setControlsVisible(false)
|
||||||
if (videoRef.current && !videoRef.current.paused) setControlsVisible(false)
|
}}
|
||||||
}}
|
className={`relative aspect-video w-full overflow-hidden rounded-md border border-border bg-black ${
|
||||||
className={`relative aspect-video w-full overflow-hidden rounded-md border border-border bg-black ${
|
controlsVisible ? '' : 'cursor-none'
|
||||||
controlsVisible ? '' : 'cursor-none'
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
<video
|
||||||
<video
|
ref={videoRef}
|
||||||
ref={videoRef}
|
playsInline
|
||||||
playsInline
|
muted
|
||||||
muted
|
className="h-full w-full"
|
||||||
className="h-full w-full"
|
style={
|
||||||
style={
|
channel && channel.analogFilterStrength > 0
|
||||||
channel && channel.analogFilterStrength > 0
|
? {
|
||||||
? {
|
filter: `saturate(${1 + channel.analogFilterStrength * 0.4}) contrast(${1 + channel.analogFilterStrength * 0.15}) blur(${channel.analogFilterStrength * 0.6}px)`,
|
||||||
filter: `saturate(${1 + channel.analogFilterStrength * 0.4}) contrast(${1 + channel.analogFilterStrength * 0.15}) blur(${channel.analogFilterStrength * 0.6}px)`,
|
}
|
||||||
}
|
: undefined
|
||||||
: undefined
|
}
|
||||||
}
|
onDoubleClick={toggleFullscreen}
|
||||||
onDoubleClick={toggleFullscreen}
|
onPlay={scheduleHide}
|
||||||
onPlay={scheduleHide}
|
onPause={() => {
|
||||||
onPause={() => {
|
clearHideTimer()
|
||||||
clearHideTimer()
|
setControlsVisible(true)
|
||||||
setControlsVisible(true)
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
|
{channel && channel.analogFilterStrength > 0 && (
|
||||||
{channel && channel.analogFilterStrength > 0 && (
|
<AnalogFilter strength={channel.analogFilterStrength} />
|
||||||
<AnalogFilter strength={channel.analogFilterStrength} />
|
)}
|
||||||
)}
|
{channel?.logoImageId && (
|
||||||
{channel?.logoImageId && (
|
<ChannelLogo
|
||||||
<ChannelLogo
|
imageId={channel.logoImageId}
|
||||||
imageId={channel.logoImageId}
|
corner={channel.logoCorner}
|
||||||
corner={channel.logoCorner}
|
opacity={channel.logoOpacity}
|
||||||
opacity={channel.logoOpacity}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
{channel?.showClock && <ScreenClock />}
|
||||||
{channel?.showClock && <ScreenClock />}
|
{nextUp && <NextUpBanner title={nextUp} />}
|
||||||
{nextUp && <NextUpBanner title={nextUp} />}
|
{flash && channel && <ChannelFlash number={channel.number} name={channel.name} />}
|
||||||
{flash && channel && <ChannelFlash number={channel.number} name={channel.name} />}
|
|
||||||
|
<div
|
||||||
<div
|
onMouseEnter={() => {
|
||||||
onMouseEnter={() => {
|
overControlsRef.current = true
|
||||||
overControlsRef.current = true
|
clearHideTimer()
|
||||||
clearHideTimer()
|
setControlsVisible(true)
|
||||||
setControlsVisible(true)
|
}}
|
||||||
}}
|
onMouseLeave={() => {
|
||||||
onMouseLeave={() => {
|
overControlsRef.current = false
|
||||||
overControlsRef.current = false
|
scheduleHide()
|
||||||
scheduleHide()
|
}}
|
||||||
}}
|
className={`absolute inset-x-0 bottom-0 flex items-center gap-3 bg-gradient-to-t from-black/70 to-transparent px-3 py-2 text-white transition-opacity ${
|
||||||
className={`absolute inset-x-0 bottom-0 flex items-center gap-3 bg-gradient-to-t from-black/70 to-transparent px-3 py-2 text-white transition-opacity ${
|
controlsVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||||
controlsVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<button type="button" onClick={toggleMute} aria-label="mute">
|
||||||
<button type="button" onClick={toggleMute} aria-label="mute">
|
{muted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
||||||
{muted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
</button>
|
||||||
</button>
|
<input
|
||||||
<input
|
type="range"
|
||||||
type="range"
|
min={0}
|
||||||
min={0}
|
max={1}
|
||||||
max={1}
|
step={0.05}
|
||||||
step={0.05}
|
value={muted ? 0 : volume}
|
||||||
value={muted ? 0 : volume}
|
onChange={(e) => applyVolume(Number(e.target.value))}
|
||||||
onChange={(e) => applyVolume(Number(e.target.value))}
|
aria-label={t('air.volume')}
|
||||||
aria-label={t('air.volume')}
|
className="h-1 w-20 cursor-pointer accent-emerald-400"
|
||||||
className="h-1 w-20 cursor-pointer accent-emerald-400"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
|
<span className="ml-auto flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-red-500">
|
||||||
<span className="ml-auto flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-red-500">
|
<span className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
|
||||||
<span className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
|
{t('air.live')}
|
||||||
{t('air.live')}
|
</span>
|
||||||
</span>
|
|
||||||
|
<button type="button" onClick={toggleFullscreen} aria-label="fullscreen">
|
||||||
<button type="button" onClick={toggleFullscreen} aria-label="fullscreen">
|
<Maximize className="h-5 w-5" />
|
||||||
<Maximize className="h-5 w-5" />
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ export function ChannelLogo({
|
|||||||
src={imageUrl(imageId)}
|
src={imageUrl(imageId)}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ opacity }}
|
style={{ opacity }}
|
||||||
className={cn('pointer-events-none absolute h-10 w-auto max-w-24 object-contain', CORNER_CLASS[corner])}
|
className={cn(
|
||||||
|
'pointer-events-none absolute h-10 w-auto max-w-24 object-contain',
|
||||||
|
CORNER_CLASS[corner],
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,13 @@ function RootLayout() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
<header className="border-b border-border">
|
<header className="border-b border-border">
|
||||||
<div className={cn('mx-auto flex items-center justify-between gap-4 px-4 py-3', containerMax)}>
|
<div
|
||||||
<Link to="/" className="crt-glow flex items-center gap-2 text-lg font-bold tracking-widest">
|
className={cn('mx-auto flex items-center justify-between gap-4 px-4 py-3', containerMax)}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="crt-glow flex items-center gap-2 text-lg font-bold tracking-widest"
|
||||||
|
>
|
||||||
<Radio className="h-5 w-5" />
|
<Radio className="h-5 w-5" />
|
||||||
{t('appName')}
|
{t('appName')}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -45,11 +50,19 @@ function RootLayout() {
|
|||||||
<nav className="hidden items-center gap-4 text-sm md:flex">
|
<nav className="hidden items-center gap-4 text-sm md:flex">
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
<Link to="/dashboard" className="hover:text-primary" activeProps={{ className: 'text-primary' }}>
|
<Link
|
||||||
|
to="/dashboard"
|
||||||
|
className="hover:text-primary"
|
||||||
|
activeProps={{ className: 'text-primary' }}
|
||||||
|
>
|
||||||
{t('nav.dashboard')}
|
{t('nav.dashboard')}
|
||||||
</Link>
|
</Link>
|
||||||
{user.role === 'admin' && (
|
{user.role === 'admin' && (
|
||||||
<Link to="/admin" className="hover:text-primary" activeProps={{ className: 'text-primary' }}>
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className="hover:text-primary"
|
||||||
|
activeProps={{ className: 'text-primary' }}
|
||||||
|
>
|
||||||
{t('nav.admin')}
|
{t('nav.admin')}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
@@ -135,7 +148,11 @@ function RootLayout() {
|
|||||||
>
|
>
|
||||||
{user.userName}
|
{user.userName}
|
||||||
</Link>
|
</Link>
|
||||||
<button type="button" className="py-1 text-left" onClick={() => void handleLogout()}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="py-1 text-left"
|
||||||
|
onClick={() => void handleLogout()}
|
||||||
|
>
|
||||||
{t('nav.logout')}
|
{t('nav.logout')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { ChannelDetail } from '@/features/admin/channels/ChannelDetail'
|
import { ChannelDetail } from '@/features/admin/channels/ChannelDetail'
|
||||||
|
|
||||||
export const Route = createFileRoute('/admin/channels/$channelId')({ component: ChannelDetailRoute })
|
export const Route = createFileRoute('/admin/channels/$channelId')({
|
||||||
|
component: ChannelDetailRoute,
|
||||||
|
})
|
||||||
|
|
||||||
function ChannelDetailRoute() {
|
function ChannelDetailRoute() {
|
||||||
const { channelId } = Route.useParams()
|
const { channelId } = Route.useParams()
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ function HomePage() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h1 className="crt-glow text-4xl font-bold tracking-[0.2em]">{t('home.title')}</h1>
|
<h1 className="crt-glow text-4xl font-bold tracking-[0.2em]">{t('home.title')}</h1>
|
||||||
<p className="text-sm uppercase tracking-[0.3em] text-muted-foreground">{t('home.subtitle')}</p>
|
<p className="text-sm uppercase tracking-[0.3em] text-muted-foreground">
|
||||||
|
{t('home.subtitle')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="max-w-md text-muted-foreground">{t('home.tagline')}</p>
|
<p className="max-w-md text-muted-foreground">{t('home.tagline')}</p>
|
||||||
|
|||||||
@@ -16,26 +16,39 @@ import { toast } from '@/shared/ui/toast-store'
|
|||||||
export const Route = createFileRoute('/settings')({ component: SettingsPage })
|
export const Route = createFileRoute('/settings')({ component: SettingsPage })
|
||||||
|
|
||||||
const userNameSchema = z.object({ newUserName: z.string().min(3).max(64) })
|
const userNameSchema = z.object({ newUserName: z.string().min(3).max(64) })
|
||||||
const passwordSchema = z.object({ currentPassword: z.string().min(1), newPassword: z.string().min(8) })
|
const passwordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1),
|
||||||
|
newPassword: z.string().min(8),
|
||||||
|
})
|
||||||
|
|
||||||
function SettingsPage() {
|
function SettingsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { isReady } = useRequireAuth()
|
const { isReady } = useRequireAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const userNameForm = useForm<z.infer<typeof userNameSchema>>({ resolver: zodResolver(userNameSchema) })
|
const userNameForm = useForm<z.infer<typeof userNameSchema>>({
|
||||||
const passwordForm = useForm<z.infer<typeof passwordSchema>>({ resolver: zodResolver(passwordSchema) })
|
resolver: zodResolver(userNameSchema),
|
||||||
|
})
|
||||||
|
const passwordForm = useForm<z.infer<typeof passwordSchema>>({
|
||||||
|
resolver: zodResolver(passwordSchema),
|
||||||
|
})
|
||||||
|
|
||||||
if (!isReady) return null
|
if (!isReady) return null
|
||||||
|
|
||||||
const onSaveUserName = async (values: z.infer<typeof userNameSchema>) => {
|
const onSaveUserName = async (values: z.infer<typeof userNameSchema>) => {
|
||||||
try {
|
try {
|
||||||
await changeUserName(values.newUserName)
|
await changeUserName(values.newUserName)
|
||||||
useAuthStore.getState().setUser({ ...useAuthStore.getState().user!, userName: values.newUserName })
|
useAuthStore
|
||||||
|
.getState()
|
||||||
|
.setUser({ ...useAuthStore.getState().user!, userName: values.newUserName })
|
||||||
toast.success(t('settings.saved'))
|
toast.success(t('settings.saved'))
|
||||||
userNameForm.reset()
|
userNameForm.reset()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof HttpError && error.status === 409 ? t('auth.userNameTaken') : t('common.error'))
|
toast.error(
|
||||||
|
error instanceof HttpError && error.status === 409
|
||||||
|
? t('auth.userNameTaken')
|
||||||
|
: t('common.error'),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,12 +82,19 @@ function SettingsPage() {
|
|||||||
<CardTitle>{t('settings.changeUserName')}</CardTitle>
|
<CardTitle>{t('settings.changeUserName')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-4" onSubmit={userNameForm.handleSubmit(onSaveUserName)}>
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={userNameForm.handleSubmit(onSaveUserName)}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="newUserName">{t('settings.newUserName')}</Label>
|
<Label htmlFor="newUserName">{t('settings.newUserName')}</Label>
|
||||||
<Input id="newUserName" {...userNameForm.register('newUserName')} />
|
<Input id="newUserName" {...userNameForm.register('newUserName')} />
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="self-start" disabled={userNameForm.formState.isSubmitting}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="self-start"
|
||||||
|
disabled={userNameForm.formState.isSubmitting}
|
||||||
|
>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -86,16 +106,27 @@ function SettingsPage() {
|
|||||||
<CardTitle>{t('settings.changePassword')}</CardTitle>
|
<CardTitle>{t('settings.changePassword')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-4" onSubmit={passwordForm.handleSubmit(onSavePassword)}>
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={passwordForm.handleSubmit(onSavePassword)}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="currentPassword">{t('settings.currentPassword')}</Label>
|
<Label htmlFor="currentPassword">{t('settings.currentPassword')}</Label>
|
||||||
<Input id="currentPassword" type="password" {...passwordForm.register('currentPassword')} />
|
<Input
|
||||||
|
id="currentPassword"
|
||||||
|
type="password"
|
||||||
|
{...passwordForm.register('currentPassword')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="newPassword">{t('settings.newPassword')}</Label>
|
<Label htmlFor="newPassword">{t('settings.newPassword')}</Label>
|
||||||
<Input id="newPassword" type="password" {...passwordForm.register('newPassword')} />
|
<Input id="newPassword" type="password" {...passwordForm.register('newPassword')} />
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="self-start" disabled={passwordForm.formState.isSubmitting}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="self-start"
|
||||||
|
disabled={passwordForm.formState.isSubmitting}
|
||||||
|
>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ export async function refreshAccessToken(): Promise<boolean> {
|
|||||||
if (!refreshInFlight) {
|
if (!refreshInFlight) {
|
||||||
refreshInFlight = (async () => {
|
refreshInFlight = (async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
|
const response = await fetch('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
if (!response.ok) return false
|
if (!response.ok) return false
|
||||||
const data = (await response.json()) as { accessToken?: unknown }
|
const data = (await response.json()) as { accessToken?: unknown }
|
||||||
if (typeof data?.accessToken !== 'string') return false
|
if (typeof data?.accessToken !== 'string') return false
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react'
|
|||||||
|
|
||||||
export type KeyedRow<T> = { key: string; value: T }
|
export type KeyedRow<T> = { key: string; value: T }
|
||||||
|
|
||||||
const toRows = <T,>(values: readonly T[]): KeyedRow<T>[] =>
|
const toRows = <T>(values: readonly T[]): KeyedRow<T>[] =>
|
||||||
values.map((value) => ({ key: crypto.randomUUID(), value }))
|
values.map((value) => ({ key: crypto.randomUUID(), value }))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,7 @@ import { toast } from '@/shared/ui/toast-store'
|
|||||||
export function useApiError() {
|
export function useApiError() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(error: unknown) =>
|
(error: unknown) => toast.error(error instanceof HttpError ? error.detail : t('common.error')),
|
||||||
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
|
|
||||||
[t],
|
[t],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||||
import {
|
import { ToastContext, registerToastPush, type ToastItem, type ToastVariant } from './toast-store'
|
||||||
ToastContext,
|
|
||||||
registerToastPush,
|
|
||||||
type ToastItem,
|
|
||||||
type ToastVariant,
|
|
||||||
} from './toast-store'
|
|
||||||
|
|
||||||
let nextId = 1
|
let nextId = 1
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
|||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild, ...props }, ref) => {
|
({ className, variant, size, asChild, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : 'button'
|
const Comp = asChild ? Slot : 'button'
|
||||||
return <Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />
|
return (
|
||||||
|
<Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
Button.displayName = 'Button'
|
Button.displayName = 'Button'
|
||||||
|
|||||||
@@ -1,33 +1,46 @@
|
|||||||
import { type HTMLAttributes, forwardRef } from 'react'
|
import { type HTMLAttributes, forwardRef } from 'react'
|
||||||
import { cn } from '@/shared/lib/cn'
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
|
||||||
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
<div ref={ref} className={cn('crt-panel rounded-md', className)} {...props} />
|
({ className, ...props }, ref) => (
|
||||||
))
|
<div ref={ref} className={cn('crt-panel rounded-md', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
Card.displayName = 'Card'
|
Card.displayName = 'Card'
|
||||||
|
|
||||||
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
<div ref={ref} className={cn('flex flex-col gap-1.5 p-6', className)} {...props} />
|
({ className, ...props }, ref) => (
|
||||||
))
|
<div ref={ref} className={cn('flex flex-col gap-1.5 p-6', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
CardHeader.displayName = 'CardHeader'
|
CardHeader.displayName = 'CardHeader'
|
||||||
|
|
||||||
export const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingElement>>(
|
export const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingElement>>(
|
||||||
// children разворачиваем явно: заголовок без видимого содержимого — это дыра для скринридера,
|
// children разворачиваем явно: заголовок без видимого содержимого — это дыра для скринридера,
|
||||||
// и статический анализ такое ловит только тогда, когда содержимое видно в разметке.
|
// и статический анализ такое ловит только тогда, когда содержимое видно в разметке.
|
||||||
({ className, children, ...props }, ref) => (
|
({ className, children, ...props }, ref) => (
|
||||||
<h3 ref={ref} className={cn('crt-glow text-xl font-semibold tracking-tight', className)} {...props}>
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn('crt-glow text-xl font-semibold tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</h3>
|
</h3>
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
CardTitle.displayName = 'CardTitle'
|
CardTitle.displayName = 'CardTitle'
|
||||||
|
|
||||||
export const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
|
export const CardDescription = forwardRef<
|
||||||
({ className, ...props }, ref) => <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />,
|
HTMLParagraphElement,
|
||||||
)
|
HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
))
|
||||||
CardDescription.displayName = 'CardDescription'
|
CardDescription.displayName = 'CardDescription'
|
||||||
|
|
||||||
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
({ className, ...props }, ref) => (
|
||||||
))
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
CardContent.displayName = 'CardContent'
|
CardContent.displayName = 'CardContent'
|
||||||
|
|||||||
@@ -50,7 +50,11 @@ export const DialogTitle = forwardRef<
|
|||||||
ElementRef<typeof DialogPrimitive.Title>,
|
ElementRef<typeof DialogPrimitive.Title>,
|
||||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Title ref={ref} className={cn('crt-glow text-lg font-semibold', className)} {...props} />
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('crt-glow text-lg font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
@@ -58,7 +62,11 @@ export const DialogDescription = forwardRef<
|
|||||||
ElementRef<typeof DialogPrimitive.Description>,
|
ElementRef<typeof DialogPrimitive.Description>,
|
||||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ export function SortHeader({
|
|||||||
return (
|
return (
|
||||||
// aria-sort — атрибут заголовка столбца, а не кнопки внутри него: у роли button его нет,
|
// aria-sort — атрибут заголовка столбца, а не кнопки внутри него: у роли button его нет,
|
||||||
// и скринридер там его просто не прочтёт.
|
// и скринридер там его просто не прочтёт.
|
||||||
<th
|
<th className={cn('px-4 py-2 font-medium', className)} aria-sort={active ? direction : 'none'}>
|
||||||
className={cn('px-4 py-2 font-medium', className)}
|
|
||||||
aria-sort={active ? direction : 'none'}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggle(sortKey)}
|
onClick={() => onToggle(sortKey)}
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ export function Toaster() {
|
|||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[10000] flex flex-col gap-2">
|
<div className="pointer-events-none fixed bottom-4 right-4 z-[10000] flex flex-col gap-2">
|
||||||
{toasts.map((t) => (
|
{toasts.map((t) => (
|
||||||
<ToastItem key={t.id} id={t.id} message={t.message} variant={t.variant} onDismiss={dismiss} />
|
<ToastItem
|
||||||
|
key={t.id}
|
||||||
|
id={t.id}
|
||||||
|
message={t.message}
|
||||||
|
variant={t.variant}
|
||||||
|
onDismiss={dismiss}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user