Normalize line endings to LF via .gitattributes
Репозиторий хранил фронтенд в CRLF, а часть бэкенда — вперемешку, хотя CI и Docker-сборка работают под Linux. Прибиваем LF атрибутом `* text=auto eol=lf` и разово нормализуем дерево, чтобы форматтеры не переписывали файлы целиком на каждом прогоне. Коммит чисто механический: изменений содержимого нет, только концы строк. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9d1c6d2fc3
commit
0442056367
@@ -1,85 +1,85 @@
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ScheduleEntryDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { formatTime } from '../lib/format'
|
||||
|
||||
/** Что стоит в строке расписания: реклама, заставка-переход или программа с номером серии. */
|
||||
function EntryLabel({ entry }: Readonly<{ entry: ScheduleEntryDto }>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (entry.kind === 'Ad') return <Badge variant="muted">{t('air.ad')}</Badge>
|
||||
|
||||
if (entry.kind === 'Bumper')
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="muted" className="shrink-0">
|
||||
{t('air.bumper')}
|
||||
</Badge>
|
||||
{(entry.bumperName || entry.bumperText) && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">
|
||||
{entry.bumperName}
|
||||
{entry.bumperName && entry.bumperText ? ' · ' : ''}
|
||||
{entry.bumperText}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<span>
|
||||
{entry.showName ?? '—'}
|
||||
<EpisodeSuffix entry={entry} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** «· S02E05» либо «· серия N» — что удалось распознать; ничего, если ни того ни другого нет. */
|
||||
function EpisodeSuffix({ entry }: Readonly<{ entry: ScheduleEntryDto }>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (entry.seasonEpisode)
|
||||
return <span className="text-muted-foreground"> · {entry.seasonEpisode}</span>
|
||||
|
||||
if (entry.episodeIndex == null) return null
|
||||
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
· {t('air.episode')} {entry.episodeIndex + 1}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function SchedulePreview({
|
||||
entries,
|
||||
onShowTrace,
|
||||
}: Readonly<{
|
||||
entries: ScheduleEntryDto[]
|
||||
onShowTrace: (entryId: string) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
if (entries.length === 0)
|
||||
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col divide-y divide-border text-sm">
|
||||
{entries.slice(0, 40).map((e) => (
|
||||
<li key={e.id} className="group flex items-center gap-3 py-1.5">
|
||||
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
||||
{formatTime(e.startsAtUtc)}
|
||||
</span>
|
||||
<EntryLabel entry={e} />
|
||||
<button
|
||||
type="button"
|
||||
title={t('admin.channels.whyHere')}
|
||||
className="ml-auto shrink-0 text-muted-foreground opacity-0 hover:text-foreground group-hover:opacity-100"
|
||||
onClick={() => onShowTrace(e.id)}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ScheduleEntryDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { formatTime } from '../lib/format'
|
||||
|
||||
/** Что стоит в строке расписания: реклама, заставка-переход или программа с номером серии. */
|
||||
function EntryLabel({ entry }: Readonly<{ entry: ScheduleEntryDto }>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (entry.kind === 'Ad') return <Badge variant="muted">{t('air.ad')}</Badge>
|
||||
|
||||
if (entry.kind === 'Bumper')
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="muted" className="shrink-0">
|
||||
{t('air.bumper')}
|
||||
</Badge>
|
||||
{(entry.bumperName || entry.bumperText) && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">
|
||||
{entry.bumperName}
|
||||
{entry.bumperName && entry.bumperText ? ' · ' : ''}
|
||||
{entry.bumperText}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<span>
|
||||
{entry.showName ?? '—'}
|
||||
<EpisodeSuffix entry={entry} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** «· S02E05» либо «· серия N» — что удалось распознать; ничего, если ни того ни другого нет. */
|
||||
function EpisodeSuffix({ entry }: Readonly<{ entry: ScheduleEntryDto }>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (entry.seasonEpisode)
|
||||
return <span className="text-muted-foreground"> · {entry.seasonEpisode}</span>
|
||||
|
||||
if (entry.episodeIndex == null) return null
|
||||
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
· {t('air.episode')} {entry.episodeIndex + 1}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function SchedulePreview({
|
||||
entries,
|
||||
onShowTrace,
|
||||
}: Readonly<{
|
||||
entries: ScheduleEntryDto[]
|
||||
onShowTrace: (entryId: string) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
if (entries.length === 0)
|
||||
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col divide-y divide-border text-sm">
|
||||
{entries.slice(0, 40).map((e) => (
|
||||
<li key={e.id} className="group flex items-center gap-3 py-1.5">
|
||||
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
||||
{formatTime(e.startsAtUtc)}
|
||||
</span>
|
||||
<EntryLabel entry={e} />
|
||||
<button
|
||||
type="button"
|
||||
title={t('admin.channels.whyHere')}
|
||||
className="ml-auto shrink-0 text-muted-foreground opacity-0 hover:text-foreground group-hover:opacity-100"
|
||||
onClick={() => onShowTrace(e.id)}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,451 +1,451 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listGroups } from '@/features/admin/groups/api'
|
||||
import type {
|
||||
Daypart,
|
||||
OverflowPolicy,
|
||||
SlotBlockMode,
|
||||
SlotDto,
|
||||
SlotKind,
|
||||
SlotStrategyType,
|
||||
} from '@/shared/api/types'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import {
|
||||
createSlot,
|
||||
deleteSlot,
|
||||
listJunctions,
|
||||
toSlotBody,
|
||||
updateSlot,
|
||||
type SlotBody,
|
||||
} from '../api'
|
||||
|
||||
const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night']
|
||||
const SLOT_KINDS: SlotKind[] = ['Content', 'Repeat', 'SignOff']
|
||||
const BLOCK_MODES: SlotBlockMode[] = ['Count', 'Duration', 'FillSlot']
|
||||
const OVERFLOW: OverflowPolicy[] = ['ContinueNext', 'ExtendSlot', 'SkipIfNotFits']
|
||||
const STRATEGIES: SlotStrategyType[] = ['Sequential', 'RandomWithCooldown', 'Fixed']
|
||||
const SNAP_OPTIONS = [0, 5, 10, 15, 30]
|
||||
|
||||
/** Черновик слота: новый (layerId + предзаполненные время/день) либо существующий. */
|
||||
export type SlotDraft = { layerId: string; slot: SlotDto | null; defaults?: Partial<SlotBody> }
|
||||
|
||||
function emptyBody(defaults?: Partial<SlotBody>): SlotBody {
|
||||
return {
|
||||
title: '',
|
||||
weekday: null,
|
||||
targetStart: '20:00:00',
|
||||
targetDurationMinutes: 60,
|
||||
daypart: 'Day',
|
||||
slotKind: 'Content',
|
||||
groupId: null,
|
||||
strategy: {
|
||||
type: 'Sequential',
|
||||
restartOnEnd: true,
|
||||
cooldownDays: 0,
|
||||
fallback: 'OldestFirst',
|
||||
},
|
||||
repeatSource: null,
|
||||
blockMode: 'FillSlot',
|
||||
blockValue: 1,
|
||||
overflowPolicy: 'ContinueNext',
|
||||
isAnchor: false,
|
||||
maxDriftMinutes: 5,
|
||||
snapToMinutes: null,
|
||||
junctionBetweenId: null,
|
||||
junctionAfterId: null,
|
||||
...defaults,
|
||||
}
|
||||
}
|
||||
|
||||
export function SlotInspector({
|
||||
channelId,
|
||||
draft,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: Readonly<{
|
||||
channelId: string
|
||||
draft: SlotDraft
|
||||
onClose: () => void
|
||||
onChanged: () => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [body, setBody] = useState<SlotBody>(() =>
|
||||
draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setBody(draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults))
|
||||
}, [draft])
|
||||
|
||||
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
|
||||
const { data: junctions } = useQuery({
|
||||
queryKey: qk.channels.junctions(channelId),
|
||||
queryFn: () => listJunctions(channelId),
|
||||
})
|
||||
|
||||
const onError = useApiError()
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (draft.slot) await updateSlot(draft.slot.id, body)
|
||||
else await createSlot(draft.layerId, body)
|
||||
},
|
||||
onSuccess: () => {
|
||||
onChanged()
|
||||
onClose()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => deleteSlot(draft.slot!.id),
|
||||
onSuccess: () => {
|
||||
onChanged()
|
||||
onClose()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const patch = (part: Partial<SlotBody>) => setBody((prev) => ({ ...prev, ...part }))
|
||||
const isContent = body.slotKind === 'Content'
|
||||
|
||||
return (
|
||||
<div className="crt-panel flex flex-col gap-3 rounded-md p-4 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide">
|
||||
{draft.slot ? t('admin.channels.editSlot') : t('admin.channels.newSlot')}
|
||||
</h3>
|
||||
<Button size="sm" variant="ghost" onClick={onClose}>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotTitle')}</Label>
|
||||
<Input value={body.title} onChange={(e) => patch({ title: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotStart')}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={body.targetStart.slice(0, 5)}
|
||||
onChange={(e) => patch({ targetStart: `${e.target.value}:00` })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotDuration')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.targetDurationMinutes}
|
||||
onChange={(e) => patch({ targetDurationMinutes: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.weekday')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.weekday ?? 'any'}
|
||||
onChange={(e) =>
|
||||
patch({ weekday: e.target.value === 'any' ? null : Number(e.target.value) })
|
||||
}
|
||||
>
|
||||
<option value="any">{t('admin.channels.everyDay')}</option>
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((day) => (
|
||||
<option key={day} value={day}>
|
||||
{t(`admin.channels.weekdays.${day}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.daypart')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.daypart}
|
||||
onChange={(e) => patch({ daypart: e.target.value as Daypart })}
|
||||
>
|
||||
{DAYPARTS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.dayparts.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotKind')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.slotKind}
|
||||
onChange={(e) => {
|
||||
const slotKind = e.target.value as SlotKind
|
||||
patch({
|
||||
slotKind,
|
||||
// Повтору нужен источник, конец вещания не берёт контент вовсе.
|
||||
repeatSource:
|
||||
slotKind === 'Repeat'
|
||||
? (body.repeatSource ?? { daysAgo: 1, time: '20:00:00', durationMinutes: 90 })
|
||||
: null,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{SLOT_KINDS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.slotKinds.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isContent && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.group')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.groupId ?? ''}
|
||||
onChange={(e) => patch({ groupId: e.target.value || null })}
|
||||
>
|
||||
<option value="">{t('admin.channels.pickGroup')}</option>
|
||||
{(groups ?? []).map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name} · {group.itemCount}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.strategy')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.strategy?.type ?? 'Sequential'}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
strategy: {
|
||||
...(body.strategy ?? {
|
||||
restartOnEnd: true,
|
||||
cooldownDays: 0,
|
||||
fallback: 'OldestFirst',
|
||||
type: 'Sequential',
|
||||
}),
|
||||
type: e.target.value as SlotStrategyType,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{STRATEGIES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.strategies.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{body.strategy?.type === 'RandomWithCooldown' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.cooldownDays')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={body.strategy.cooldownDays}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
strategy: { ...body.strategy!, cooldownDays: Number(e.target.value) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.cooldownHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.blockMode')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.blockMode}
|
||||
onChange={(e) => patch({ blockMode: e.target.value as SlotBlockMode })}
|
||||
>
|
||||
{BLOCK_MODES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.blockModes.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{body.blockMode !== 'FillSlot' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.blockValue')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.blockValue}
|
||||
onChange={(e) => patch({ blockValue: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.overflow')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.overflowPolicy}
|
||||
onChange={(e) => patch({ overflowPolicy: e.target.value as OverflowPolicy })}
|
||||
>
|
||||
{OVERFLOW.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.overflows.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.overflowHint')}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{body.slotKind === 'Repeat' && body.repeatSource && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatDaysAgo')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.repeatSource.daysAgo}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
repeatSource: { ...body.repeatSource!, daysAgo: Number(e.target.value) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatTime')}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={body.repeatSource.time.slice(0, 5)}
|
||||
onChange={(e) =>
|
||||
patch({ repeatSource: { ...body.repeatSource!, time: `${e.target.value}:00` } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatDuration')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.repeatSource.durationMinutes}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
repeatSource: {
|
||||
...body.repeatSource!,
|
||||
durationMinutes: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={body.isAnchor}
|
||||
onChange={(e) => patch({ isAnchor: e.target.checked })}
|
||||
/>
|
||||
{t('admin.channels.anchor')}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.maxDrift')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-24"
|
||||
value={body.maxDriftMinutes}
|
||||
onChange={(e) => patch({ maxDriftMinutes: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.snap')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.snapToMinutes ?? 0}
|
||||
onChange={(e) =>
|
||||
patch({ snapToMinutes: Number(e.target.value) === 0 ? null : Number(e.target.value) })
|
||||
}
|
||||
>
|
||||
{SNAP_OPTIONS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value === 0 ? t('admin.channels.snapOff') : `${value}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.anchorHint')}</p>
|
||||
|
||||
{/* Стыки: внутри слота — между единицами, после слота — на переходе к следующему. */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.junctionBetween')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.junctionBetweenId ?? ''}
|
||||
onChange={(e) => patch({ junctionBetweenId: e.target.value || null })}
|
||||
>
|
||||
<option value="">{t('admin.channels.junctionDefault')}</option>
|
||||
{(junctions ?? []).map((junction) => (
|
||||
<option key={junction.id} value={junction.id}>
|
||||
{junction.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.junctionAfter')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.junctionAfterId ?? ''}
|
||||
onChange={(e) => patch({ junctionAfterId: e.target.value || null })}
|
||||
>
|
||||
<option value="">{t('admin.channels.junctionDefault')}</option>
|
||||
{(junctions ?? []).map((junction) => (
|
||||
<option key={junction.id} value={junction.id}>
|
||||
{junction.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{draft.slot ? (
|
||||
<Button size="sm" variant="destructive" onClick={() => remove.mutate()}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listGroups } from '@/features/admin/groups/api'
|
||||
import type {
|
||||
Daypart,
|
||||
OverflowPolicy,
|
||||
SlotBlockMode,
|
||||
SlotDto,
|
||||
SlotKind,
|
||||
SlotStrategyType,
|
||||
} from '@/shared/api/types'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import {
|
||||
createSlot,
|
||||
deleteSlot,
|
||||
listJunctions,
|
||||
toSlotBody,
|
||||
updateSlot,
|
||||
type SlotBody,
|
||||
} from '../api'
|
||||
|
||||
const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night']
|
||||
const SLOT_KINDS: SlotKind[] = ['Content', 'Repeat', 'SignOff']
|
||||
const BLOCK_MODES: SlotBlockMode[] = ['Count', 'Duration', 'FillSlot']
|
||||
const OVERFLOW: OverflowPolicy[] = ['ContinueNext', 'ExtendSlot', 'SkipIfNotFits']
|
||||
const STRATEGIES: SlotStrategyType[] = ['Sequential', 'RandomWithCooldown', 'Fixed']
|
||||
const SNAP_OPTIONS = [0, 5, 10, 15, 30]
|
||||
|
||||
/** Черновик слота: новый (layerId + предзаполненные время/день) либо существующий. */
|
||||
export type SlotDraft = { layerId: string; slot: SlotDto | null; defaults?: Partial<SlotBody> }
|
||||
|
||||
function emptyBody(defaults?: Partial<SlotBody>): SlotBody {
|
||||
return {
|
||||
title: '',
|
||||
weekday: null,
|
||||
targetStart: '20:00:00',
|
||||
targetDurationMinutes: 60,
|
||||
daypart: 'Day',
|
||||
slotKind: 'Content',
|
||||
groupId: null,
|
||||
strategy: {
|
||||
type: 'Sequential',
|
||||
restartOnEnd: true,
|
||||
cooldownDays: 0,
|
||||
fallback: 'OldestFirst',
|
||||
},
|
||||
repeatSource: null,
|
||||
blockMode: 'FillSlot',
|
||||
blockValue: 1,
|
||||
overflowPolicy: 'ContinueNext',
|
||||
isAnchor: false,
|
||||
maxDriftMinutes: 5,
|
||||
snapToMinutes: null,
|
||||
junctionBetweenId: null,
|
||||
junctionAfterId: null,
|
||||
...defaults,
|
||||
}
|
||||
}
|
||||
|
||||
export function SlotInspector({
|
||||
channelId,
|
||||
draft,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: Readonly<{
|
||||
channelId: string
|
||||
draft: SlotDraft
|
||||
onClose: () => void
|
||||
onChanged: () => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [body, setBody] = useState<SlotBody>(() =>
|
||||
draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setBody(draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults))
|
||||
}, [draft])
|
||||
|
||||
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
|
||||
const { data: junctions } = useQuery({
|
||||
queryKey: qk.channels.junctions(channelId),
|
||||
queryFn: () => listJunctions(channelId),
|
||||
})
|
||||
|
||||
const onError = useApiError()
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (draft.slot) await updateSlot(draft.slot.id, body)
|
||||
else await createSlot(draft.layerId, body)
|
||||
},
|
||||
onSuccess: () => {
|
||||
onChanged()
|
||||
onClose()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => deleteSlot(draft.slot!.id),
|
||||
onSuccess: () => {
|
||||
onChanged()
|
||||
onClose()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const patch = (part: Partial<SlotBody>) => setBody((prev) => ({ ...prev, ...part }))
|
||||
const isContent = body.slotKind === 'Content'
|
||||
|
||||
return (
|
||||
<div className="crt-panel flex flex-col gap-3 rounded-md p-4 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide">
|
||||
{draft.slot ? t('admin.channels.editSlot') : t('admin.channels.newSlot')}
|
||||
</h3>
|
||||
<Button size="sm" variant="ghost" onClick={onClose}>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotTitle')}</Label>
|
||||
<Input value={body.title} onChange={(e) => patch({ title: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotStart')}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={body.targetStart.slice(0, 5)}
|
||||
onChange={(e) => patch({ targetStart: `${e.target.value}:00` })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotDuration')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.targetDurationMinutes}
|
||||
onChange={(e) => patch({ targetDurationMinutes: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.weekday')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.weekday ?? 'any'}
|
||||
onChange={(e) =>
|
||||
patch({ weekday: e.target.value === 'any' ? null : Number(e.target.value) })
|
||||
}
|
||||
>
|
||||
<option value="any">{t('admin.channels.everyDay')}</option>
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((day) => (
|
||||
<option key={day} value={day}>
|
||||
{t(`admin.channels.weekdays.${day}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.daypart')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.daypart}
|
||||
onChange={(e) => patch({ daypart: e.target.value as Daypart })}
|
||||
>
|
||||
{DAYPARTS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.dayparts.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotKind')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.slotKind}
|
||||
onChange={(e) => {
|
||||
const slotKind = e.target.value as SlotKind
|
||||
patch({
|
||||
slotKind,
|
||||
// Повтору нужен источник, конец вещания не берёт контент вовсе.
|
||||
repeatSource:
|
||||
slotKind === 'Repeat'
|
||||
? (body.repeatSource ?? { daysAgo: 1, time: '20:00:00', durationMinutes: 90 })
|
||||
: null,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{SLOT_KINDS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.slotKinds.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isContent && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.group')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.groupId ?? ''}
|
||||
onChange={(e) => patch({ groupId: e.target.value || null })}
|
||||
>
|
||||
<option value="">{t('admin.channels.pickGroup')}</option>
|
||||
{(groups ?? []).map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name} · {group.itemCount}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.strategy')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.strategy?.type ?? 'Sequential'}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
strategy: {
|
||||
...(body.strategy ?? {
|
||||
restartOnEnd: true,
|
||||
cooldownDays: 0,
|
||||
fallback: 'OldestFirst',
|
||||
type: 'Sequential',
|
||||
}),
|
||||
type: e.target.value as SlotStrategyType,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{STRATEGIES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.strategies.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{body.strategy?.type === 'RandomWithCooldown' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.cooldownDays')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={body.strategy.cooldownDays}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
strategy: { ...body.strategy!, cooldownDays: Number(e.target.value) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.cooldownHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.blockMode')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.blockMode}
|
||||
onChange={(e) => patch({ blockMode: e.target.value as SlotBlockMode })}
|
||||
>
|
||||
{BLOCK_MODES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.blockModes.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{body.blockMode !== 'FillSlot' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.blockValue')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.blockValue}
|
||||
onChange={(e) => patch({ blockValue: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.overflow')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.overflowPolicy}
|
||||
onChange={(e) => patch({ overflowPolicy: e.target.value as OverflowPolicy })}
|
||||
>
|
||||
{OVERFLOW.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.overflows.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.overflowHint')}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{body.slotKind === 'Repeat' && body.repeatSource && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatDaysAgo')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.repeatSource.daysAgo}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
repeatSource: { ...body.repeatSource!, daysAgo: Number(e.target.value) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatTime')}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={body.repeatSource.time.slice(0, 5)}
|
||||
onChange={(e) =>
|
||||
patch({ repeatSource: { ...body.repeatSource!, time: `${e.target.value}:00` } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatDuration')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.repeatSource.durationMinutes}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
repeatSource: {
|
||||
...body.repeatSource!,
|
||||
durationMinutes: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={body.isAnchor}
|
||||
onChange={(e) => patch({ isAnchor: e.target.checked })}
|
||||
/>
|
||||
{t('admin.channels.anchor')}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.maxDrift')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-24"
|
||||
value={body.maxDriftMinutes}
|
||||
onChange={(e) => patch({ maxDriftMinutes: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.snap')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.snapToMinutes ?? 0}
|
||||
onChange={(e) =>
|
||||
patch({ snapToMinutes: Number(e.target.value) === 0 ? null : Number(e.target.value) })
|
||||
}
|
||||
>
|
||||
{SNAP_OPTIONS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value === 0 ? t('admin.channels.snapOff') : `${value}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.anchorHint')}</p>
|
||||
|
||||
{/* Стыки: внутри слота — между единицами, после слота — на переходе к следующему. */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.junctionBetween')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.junctionBetweenId ?? ''}
|
||||
onChange={(e) => patch({ junctionBetweenId: e.target.value || null })}
|
||||
>
|
||||
<option value="">{t('admin.channels.junctionDefault')}</option>
|
||||
{(junctions ?? []).map((junction) => (
|
||||
<option key={junction.id} value={junction.id}>
|
||||
{junction.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.junctionAfter')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.junctionAfterId ?? ''}
|
||||
onChange={(e) => patch({ junctionAfterId: e.target.value || null })}
|
||||
>
|
||||
<option value="">{t('admin.channels.junctionDefault')}</option>
|
||||
{(junctions ?? []).map((junction) => (
|
||||
<option key={junction.id} value={junction.id}>
|
||||
{junction.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{draft.slot ? (
|
||||
<Button size="sm" variant="destructive" onClick={() => remove.mutate()}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,148 +1,148 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { imageUrl } from '@/features/admin/images/api'
|
||||
import { ImageGallery } from '@/features/admin/images/ImageGallery'
|
||||
import type { ChannelDto, LogoCorner, ViewerSettings } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { updateViewerSettings } from '../api'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
const CORNERS: LogoCorner[] = ['TopLeft', 'TopRight', 'BottomLeft', 'BottomRight']
|
||||
|
||||
/**
|
||||
* Как канал выглядит у зрителя (см. 6.8): логотип, часы, аналоговый фильтр. Всё рисуется на клиенте
|
||||
* поверх видео и по умолчанию выключено — канал без логотипа и без шума остаётся нормальным каналом.
|
||||
*/
|
||||
export function ViewerCard({
|
||||
channel,
|
||||
bare,
|
||||
onSaved,
|
||||
onError,
|
||||
}: Readonly<{
|
||||
channel: ChannelDto
|
||||
bare?: boolean
|
||||
onSaved: () => void
|
||||
onError: (error: unknown) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [viewer, setViewer] = useState<ViewerSettings>(channel.viewer)
|
||||
const [galleryOpen, setGalleryOpen] = useState(false)
|
||||
|
||||
useEffect(() => setViewer(channel.viewer), [channel])
|
||||
|
||||
const patch = (part: Partial<ViewerSettings>) => setViewer((prev) => ({ ...prev, ...part }))
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => updateViewerSettings(channel.id, viewer),
|
||||
onSuccess: onSaved,
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<CollapsibleCard title={t('admin.channels.viewer')} bare={bare}>
|
||||
<div className="flex flex-col gap-4 text-sm">
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.viewerHint')}</p>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.logo')}</Label>
|
||||
<div className="flex h-16 w-24 items-center justify-center rounded-md border border-border bg-muted/30">
|
||||
{viewer.logoImageId ? (
|
||||
<img
|
||||
src={imageUrl(viewer.logoImageId)}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{t('admin.channels.noLogo')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
|
||||
{t('admin.channels.pickLogo')}
|
||||
</Button>
|
||||
{viewer.logoImageId && (
|
||||
<Button size="sm" variant="ghost" onClick={() => patch({ logoImageId: null })}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
<ImageGallery
|
||||
open={galleryOpen}
|
||||
onOpenChange={setGalleryOpen}
|
||||
category="Library"
|
||||
onSelect={(image) => patch({ logoImageId: image.id })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{viewer.logoImageId && (
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.logoCorner')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={viewer.logoCorner}
|
||||
onChange={(e) => patch({ logoCorner: e.target.value as LogoCorner })}
|
||||
>
|
||||
{CORNERS.map((corner) => (
|
||||
<option key={corner} value={corner}>
|
||||
{t(`admin.channels.corners.${corner}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.logoOpacity')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
className="w-28"
|
||||
value={viewer.logoOpacity}
|
||||
onChange={(e) => patch({ logoOpacity: clamp01(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={viewer.showClock}
|
||||
onChange={(e) => patch({ showClock: e.target.checked })}
|
||||
/>
|
||||
{t('admin.channels.showClock')}
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.analogFilter')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
className="w-28"
|
||||
value={viewer.analogFilterStrength}
|
||||
onChange={(e) => patch({ analogFilterStrength: clamp01(e.target.value) })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.analogFilterHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
|
||||
/** Сила и прозрачность живут в 0..1: пустой ввод трактуем как ноль, а не как NaN. */
|
||||
function clamp01(value: string): number {
|
||||
const n = Number(value)
|
||||
return Number.isNaN(n) ? 0 : Math.min(1, Math.max(0, n))
|
||||
}
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { imageUrl } from '@/features/admin/images/api'
|
||||
import { ImageGallery } from '@/features/admin/images/ImageGallery'
|
||||
import type { ChannelDto, LogoCorner, ViewerSettings } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { updateViewerSettings } from '../api'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
const CORNERS: LogoCorner[] = ['TopLeft', 'TopRight', 'BottomLeft', 'BottomRight']
|
||||
|
||||
/**
|
||||
* Как канал выглядит у зрителя (см. 6.8): логотип, часы, аналоговый фильтр. Всё рисуется на клиенте
|
||||
* поверх видео и по умолчанию выключено — канал без логотипа и без шума остаётся нормальным каналом.
|
||||
*/
|
||||
export function ViewerCard({
|
||||
channel,
|
||||
bare,
|
||||
onSaved,
|
||||
onError,
|
||||
}: Readonly<{
|
||||
channel: ChannelDto
|
||||
bare?: boolean
|
||||
onSaved: () => void
|
||||
onError: (error: unknown) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [viewer, setViewer] = useState<ViewerSettings>(channel.viewer)
|
||||
const [galleryOpen, setGalleryOpen] = useState(false)
|
||||
|
||||
useEffect(() => setViewer(channel.viewer), [channel])
|
||||
|
||||
const patch = (part: Partial<ViewerSettings>) => setViewer((prev) => ({ ...prev, ...part }))
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => updateViewerSettings(channel.id, viewer),
|
||||
onSuccess: onSaved,
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<CollapsibleCard title={t('admin.channels.viewer')} bare={bare}>
|
||||
<div className="flex flex-col gap-4 text-sm">
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.viewerHint')}</p>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.logo')}</Label>
|
||||
<div className="flex h-16 w-24 items-center justify-center rounded-md border border-border bg-muted/30">
|
||||
{viewer.logoImageId ? (
|
||||
<img
|
||||
src={imageUrl(viewer.logoImageId)}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{t('admin.channels.noLogo')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
|
||||
{t('admin.channels.pickLogo')}
|
||||
</Button>
|
||||
{viewer.logoImageId && (
|
||||
<Button size="sm" variant="ghost" onClick={() => patch({ logoImageId: null })}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
<ImageGallery
|
||||
open={galleryOpen}
|
||||
onOpenChange={setGalleryOpen}
|
||||
category="Library"
|
||||
onSelect={(image) => patch({ logoImageId: image.id })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{viewer.logoImageId && (
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.logoCorner')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={viewer.logoCorner}
|
||||
onChange={(e) => patch({ logoCorner: e.target.value as LogoCorner })}
|
||||
>
|
||||
{CORNERS.map((corner) => (
|
||||
<option key={corner} value={corner}>
|
||||
{t(`admin.channels.corners.${corner}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.logoOpacity')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
className="w-28"
|
||||
value={viewer.logoOpacity}
|
||||
onChange={(e) => patch({ logoOpacity: clamp01(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={viewer.showClock}
|
||||
onChange={(e) => patch({ showClock: e.target.checked })}
|
||||
/>
|
||||
{t('admin.channels.showClock')}
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.analogFilter')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
className="w-28"
|
||||
value={viewer.analogFilterStrength}
|
||||
onChange={(e) => patch({ analogFilterStrength: clamp01(e.target.value) })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.analogFilterHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
|
||||
/** Сила и прозрачность живут в 0..1: пустой ввод трактуем как ноль, а не как NaN. */
|
||||
function clamp01(value: string): number {
|
||||
const n = Number(value)
|
||||
return Number.isNaN(n) ? 0 : Math.min(1, Math.max(0, n))
|
||||
}
|
||||
|
||||
@@ -1,288 +1,288 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FolderInput, ListPlus, Upload } from 'lucide-react'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Badge, type BadgeProps } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Pager } from '@/shared/ui/pager'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { useTableSort } from '@/shared/lib/table-sort'
|
||||
import { SortHeader } from '@/shared/ui/sortable'
|
||||
import { deleteMedia, getMediaStats, listMedia } from './api'
|
||||
import { formatDuration } from './format'
|
||||
import { ManualInboxDialog } from './ManualInboxDialog'
|
||||
import { UploadToShowDialog } from './UploadToShowDialog'
|
||||
import { useUploadStore } from './upload-store'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
type MediaFilter = 'active' | 'Pending' | 'Processing' | 'all' | 'Ready' | 'Failed'
|
||||
|
||||
const filterStatuses: Record<MediaFilter, MediaAssetStatus[]> = {
|
||||
active: ['Pending', 'Processing'],
|
||||
Pending: ['Pending'],
|
||||
Processing: ['Processing'],
|
||||
all: [],
|
||||
Ready: ['Ready'],
|
||||
Failed: ['Failed'],
|
||||
}
|
||||
|
||||
const statusVariant: Record<MediaAssetStatus, BadgeProps['variant']> = {
|
||||
Ready: 'default',
|
||||
Processing: 'muted',
|
||||
Pending: 'muted',
|
||||
Failed: 'destructive',
|
||||
}
|
||||
|
||||
export function MediaPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const fileInputShow = useRef<HTMLInputElement>(null)
|
||||
const [filter, setFilter] = useState<MediaFilter>('active')
|
||||
const [page, setPage] = useState(1)
|
||||
const { sort, toggle } = useTableSort('created', true)
|
||||
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
|
||||
const [manualOpen, setManualOpen] = useState(false)
|
||||
const enqueue = useUploadStore((s) => s.enqueue)
|
||||
|
||||
const sortColumn = (key: string) => {
|
||||
setPage(1)
|
||||
toggle(key)
|
||||
}
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: qk.media.list(filter, page, sort.key, sort.desc),
|
||||
queryFn: () =>
|
||||
listMedia({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
statuses: filterStatuses[filter],
|
||||
sort: sort.key,
|
||||
desc: sort.desc,
|
||||
}),
|
||||
// Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI.
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending')
|
||||
? 4000
|
||||
: false,
|
||||
})
|
||||
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: qk.media.stats,
|
||||
queryFn: getMediaStats,
|
||||
// Пока есть незавершённая работа — освежаем чипы очереди/обработки.
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data?.queued ?? 0) + (query.state.data?.processing ?? 0) > 0 ? 4000 : 15000,
|
||||
})
|
||||
|
||||
/**
|
||||
* Сводка очереди опрашивается всегда, и она же служит датчиком: изменилась пара
|
||||
* «в очереди / в обработке» — значит какая-то запись сменила статус, и список пора перечитать.
|
||||
* Без этого список обновлялся бы, только пока активные записи видны на текущей странице: при
|
||||
* фильтре «Готовые» или пустом списке опрос выключался, и новый файл из inbox не появлялся.
|
||||
*/
|
||||
const activity = `${stats?.queued ?? 0}:${stats?.processing ?? 0}`
|
||||
const lastActivity = useRef(activity)
|
||||
useEffect(() => {
|
||||
if (lastActivity.current === activity) return
|
||||
lastActivity.current = activity
|
||||
void refetch()
|
||||
}, [activity, refetch])
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.media.all })
|
||||
const onError = useApiError()
|
||||
|
||||
const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError })
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.media.title')}</h2>
|
||||
<Select
|
||||
value={filter}
|
||||
onValueChange={(v) => {
|
||||
setPage(1)
|
||||
setFilter(v as MediaFilter)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">{t('admin.media.filterActive')}</SelectItem>
|
||||
<SelectItem value="Pending">{t('admin.media.statuses.Pending')}</SelectItem>
|
||||
<SelectItem value="Processing">{t('admin.media.statuses.Processing')}</SelectItem>
|
||||
<SelectItem value="all">{t('admin.media.filterAll')}</SelectItem>
|
||||
<SelectItem value="Ready">{t('admin.media.statuses.Ready')}</SelectItem>
|
||||
<SelectItem value="Failed">{t('admin.media.statuses.Failed')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{stats && (
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span title={t('admin.media.stats.queued')}>
|
||||
{t('admin.media.stats.queuedShort')}:{' '}
|
||||
<span className="text-foreground tabular-nums">{stats.queued}</span>
|
||||
</span>
|
||||
<span title={t('admin.media.stats.processing')}>
|
||||
{t('admin.media.stats.processingShort')}:{' '}
|
||||
<span className="text-foreground tabular-nums">{stats.processing}</span>
|
||||
</span>
|
||||
<span title={t('admin.media.stats.average')}>
|
||||
{t('admin.media.stats.averageShort')}:{' '}
|
||||
<span className="text-foreground tabular-nums">
|
||||
{formatDuration(stats.averageProcessingSeconds)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="video/*,.mkv,.avi,.ts"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files
|
||||
if (files && files.length > 0) void enqueue(Array.from(files))
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputShow}
|
||||
type="file"
|
||||
accept="video/*,.mkv,.avi,.ts"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files
|
||||
if (files && files.length > 0) setFilesForShow(Array.from(files))
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={() => setManualOpen(true)}>
|
||||
<FolderInput className="h-4 w-4" />
|
||||
{t('admin.media.manualButton')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => fileInputShow.current?.click()}>
|
||||
<ListPlus className="h-4 w-4" />
|
||||
{t('admin.media.uploadToShow')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => fileInput.current?.click()}>
|
||||
<Upload className="h-4 w-4" />
|
||||
{t('admin.media.upload')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filesForShow && (
|
||||
<UploadToShowDialog files={filesForShow} onClose={() => setFilesForShow(null)} />
|
||||
)}
|
||||
|
||||
{manualOpen && <ManualInboxDialog onClose={() => setManualOpen(false)} />}
|
||||
|
||||
<div className="crt-panel overflow-x-auto rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border text-left text-muted-foreground">
|
||||
<tr>
|
||||
<SortHeader
|
||||
label={t('admin.media.name')}
|
||||
sortKey="name"
|
||||
sort={sort}
|
||||
onToggle={sortColumn}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.media.status')}
|
||||
sortKey="status"
|
||||
sort={sort}
|
||||
onToggle={sortColumn}
|
||||
className="whitespace-nowrap"
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.media.duration')}
|
||||
sortKey="duration"
|
||||
sort={sort}
|
||||
onToggle={sortColumn}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.media.resolution')}
|
||||
sortKey="resolution"
|
||||
sort={sort}
|
||||
onToggle={sortColumn}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.media.processingTime')}
|
||||
sortKey="processing"
|
||||
sort={sort}
|
||||
onToggle={sortColumn}
|
||||
/>
|
||||
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
|
||||
{t('common.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{data?.items.map((asset) => (
|
||||
<MediaRow
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
onDelete={() => deleteMutation.mutate(asset.id)}
|
||||
/>
|
||||
))}
|
||||
{data && data.items.length === 0 && !isLoading && (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
|
||||
{t('admin.media.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pager
|
||||
page={page}
|
||||
totalPages={data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MediaRow({ asset, onDelete }: Readonly<{ asset: MediaAssetDto; onDelete: () => void }>) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<tr className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-2">{asset.originalFileName}</td>
|
||||
<td className="whitespace-nowrap px-4 py-2">
|
||||
<Badge variant={statusVariant[asset.status]} title={asset.errorMessage ?? undefined}>
|
||||
{t(`admin.media.statuses.${asset.status}`)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{formatDuration(asset.durationSeconds)}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">
|
||||
{asset.width && asset.height ? `${asset.width}×${asset.height}` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground tabular-nums">
|
||||
{asset.status === 'Ready' ? formatDuration(asset.processingSeconds) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Button size="sm" variant="destructive" onClick={onDelete}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FolderInput, ListPlus, Upload } from 'lucide-react'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Badge, type BadgeProps } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Pager } from '@/shared/ui/pager'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { useTableSort } from '@/shared/lib/table-sort'
|
||||
import { SortHeader } from '@/shared/ui/sortable'
|
||||
import { deleteMedia, getMediaStats, listMedia } from './api'
|
||||
import { formatDuration } from './format'
|
||||
import { ManualInboxDialog } from './ManualInboxDialog'
|
||||
import { UploadToShowDialog } from './UploadToShowDialog'
|
||||
import { useUploadStore } from './upload-store'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
type MediaFilter = 'active' | 'Pending' | 'Processing' | 'all' | 'Ready' | 'Failed'
|
||||
|
||||
const filterStatuses: Record<MediaFilter, MediaAssetStatus[]> = {
|
||||
active: ['Pending', 'Processing'],
|
||||
Pending: ['Pending'],
|
||||
Processing: ['Processing'],
|
||||
all: [],
|
||||
Ready: ['Ready'],
|
||||
Failed: ['Failed'],
|
||||
}
|
||||
|
||||
const statusVariant: Record<MediaAssetStatus, BadgeProps['variant']> = {
|
||||
Ready: 'default',
|
||||
Processing: 'muted',
|
||||
Pending: 'muted',
|
||||
Failed: 'destructive',
|
||||
}
|
||||
|
||||
export function MediaPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const fileInputShow = useRef<HTMLInputElement>(null)
|
||||
const [filter, setFilter] = useState<MediaFilter>('active')
|
||||
const [page, setPage] = useState(1)
|
||||
const { sort, toggle } = useTableSort('created', true)
|
||||
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
|
||||
const [manualOpen, setManualOpen] = useState(false)
|
||||
const enqueue = useUploadStore((s) => s.enqueue)
|
||||
|
||||
const sortColumn = (key: string) => {
|
||||
setPage(1)
|
||||
toggle(key)
|
||||
}
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: qk.media.list(filter, page, sort.key, sort.desc),
|
||||
queryFn: () =>
|
||||
listMedia({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
statuses: filterStatuses[filter],
|
||||
sort: sort.key,
|
||||
desc: sort.desc,
|
||||
}),
|
||||
// Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI.
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending')
|
||||
? 4000
|
||||
: false,
|
||||
})
|
||||
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: qk.media.stats,
|
||||
queryFn: getMediaStats,
|
||||
// Пока есть незавершённая работа — освежаем чипы очереди/обработки.
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data?.queued ?? 0) + (query.state.data?.processing ?? 0) > 0 ? 4000 : 15000,
|
||||
})
|
||||
|
||||
/**
|
||||
* Сводка очереди опрашивается всегда, и она же служит датчиком: изменилась пара
|
||||
* «в очереди / в обработке» — значит какая-то запись сменила статус, и список пора перечитать.
|
||||
* Без этого список обновлялся бы, только пока активные записи видны на текущей странице: при
|
||||
* фильтре «Готовые» или пустом списке опрос выключался, и новый файл из inbox не появлялся.
|
||||
*/
|
||||
const activity = `${stats?.queued ?? 0}:${stats?.processing ?? 0}`
|
||||
const lastActivity = useRef(activity)
|
||||
useEffect(() => {
|
||||
if (lastActivity.current === activity) return
|
||||
lastActivity.current = activity
|
||||
void refetch()
|
||||
}, [activity, refetch])
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.media.all })
|
||||
const onError = useApiError()
|
||||
|
||||
const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError })
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.media.title')}</h2>
|
||||
<Select
|
||||
value={filter}
|
||||
onValueChange={(v) => {
|
||||
setPage(1)
|
||||
setFilter(v as MediaFilter)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">{t('admin.media.filterActive')}</SelectItem>
|
||||
<SelectItem value="Pending">{t('admin.media.statuses.Pending')}</SelectItem>
|
||||
<SelectItem value="Processing">{t('admin.media.statuses.Processing')}</SelectItem>
|
||||
<SelectItem value="all">{t('admin.media.filterAll')}</SelectItem>
|
||||
<SelectItem value="Ready">{t('admin.media.statuses.Ready')}</SelectItem>
|
||||
<SelectItem value="Failed">{t('admin.media.statuses.Failed')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{stats && (
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span title={t('admin.media.stats.queued')}>
|
||||
{t('admin.media.stats.queuedShort')}:{' '}
|
||||
<span className="text-foreground tabular-nums">{stats.queued}</span>
|
||||
</span>
|
||||
<span title={t('admin.media.stats.processing')}>
|
||||
{t('admin.media.stats.processingShort')}:{' '}
|
||||
<span className="text-foreground tabular-nums">{stats.processing}</span>
|
||||
</span>
|
||||
<span title={t('admin.media.stats.average')}>
|
||||
{t('admin.media.stats.averageShort')}:{' '}
|
||||
<span className="text-foreground tabular-nums">
|
||||
{formatDuration(stats.averageProcessingSeconds)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="video/*,.mkv,.avi,.ts"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files
|
||||
if (files && files.length > 0) void enqueue(Array.from(files))
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputShow}
|
||||
type="file"
|
||||
accept="video/*,.mkv,.avi,.ts"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files
|
||||
if (files && files.length > 0) setFilesForShow(Array.from(files))
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={() => setManualOpen(true)}>
|
||||
<FolderInput className="h-4 w-4" />
|
||||
{t('admin.media.manualButton')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => fileInputShow.current?.click()}>
|
||||
<ListPlus className="h-4 w-4" />
|
||||
{t('admin.media.uploadToShow')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => fileInput.current?.click()}>
|
||||
<Upload className="h-4 w-4" />
|
||||
{t('admin.media.upload')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filesForShow && (
|
||||
<UploadToShowDialog files={filesForShow} onClose={() => setFilesForShow(null)} />
|
||||
)}
|
||||
|
||||
{manualOpen && <ManualInboxDialog onClose={() => setManualOpen(false)} />}
|
||||
|
||||
<div className="crt-panel overflow-x-auto rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border text-left text-muted-foreground">
|
||||
<tr>
|
||||
<SortHeader
|
||||
label={t('admin.media.name')}
|
||||
sortKey="name"
|
||||
sort={sort}
|
||||
onToggle={sortColumn}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.media.status')}
|
||||
sortKey="status"
|
||||
sort={sort}
|
||||
onToggle={sortColumn}
|
||||
className="whitespace-nowrap"
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.media.duration')}
|
||||
sortKey="duration"
|
||||
sort={sort}
|
||||
onToggle={sortColumn}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.media.resolution')}
|
||||
sortKey="resolution"
|
||||
sort={sort}
|
||||
onToggle={sortColumn}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.media.processingTime')}
|
||||
sortKey="processing"
|
||||
sort={sort}
|
||||
onToggle={sortColumn}
|
||||
/>
|
||||
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
|
||||
{t('common.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{data?.items.map((asset) => (
|
||||
<MediaRow
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
onDelete={() => deleteMutation.mutate(asset.id)}
|
||||
/>
|
||||
))}
|
||||
{data && data.items.length === 0 && !isLoading && (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
|
||||
{t('admin.media.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pager
|
||||
page={page}
|
||||
totalPages={data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MediaRow({ asset, onDelete }: Readonly<{ asset: MediaAssetDto; onDelete: () => void }>) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<tr className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-2">{asset.originalFileName}</td>
|
||||
<td className="whitespace-nowrap px-4 py-2">
|
||||
<Badge variant={statusVariant[asset.status]} title={asset.errorMessage ?? undefined}>
|
||||
{t(`admin.media.statuses.${asset.status}`)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{formatDuration(asset.durationSeconds)}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">
|
||||
{asset.width && asset.height ? `${asset.width}×${asset.height}` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground tabular-nums">
|
||||
{asset.status === 'Ready' ? formatDuration(asset.processingSeconds) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Button size="sm" variant="destructive" onClick={onDelete}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,130 +1,130 @@
|
||||
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
CreatedIdResponse,
|
||||
ImportManualInboxResultDto,
|
||||
ManualImportItem,
|
||||
ManualInboxListDto,
|
||||
MediaAssetDto,
|
||||
MediaAssetStatus,
|
||||
MediaStatsDto,
|
||||
PagedList,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
type ListMediaParams = {
|
||||
page: number
|
||||
pageSize: number
|
||||
statuses?: MediaAssetStatus[]
|
||||
search?: string
|
||||
sort?: string
|
||||
desc?: boolean
|
||||
}
|
||||
|
||||
export function listMedia(params: ListMediaParams) {
|
||||
const query = new URLSearchParams({
|
||||
page: String(params.page),
|
||||
pageSize: String(params.pageSize),
|
||||
})
|
||||
for (const status of params.statuses ?? []) query.append('status', status)
|
||||
if (params.search) query.set('search', params.search)
|
||||
if (params.sort) query.set('sort', params.sort)
|
||||
if (params.desc) query.set('desc', 'true')
|
||||
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`)
|
||||
}
|
||||
|
||||
export function getMediaStats() {
|
||||
return apiRequest<MediaStatsDto>('/admin/media/stats')
|
||||
}
|
||||
|
||||
/**
|
||||
* Дозагружает ВСЕ страницы медиа (для пикеров с клиентской фильтрацией/сортировкой — кандидаты серий,
|
||||
* пул рекламы), чтобы ничего не терялось молча за фиксированным pageSize. Есть предохранитель `cap`:
|
||||
* если элементов больше — возвращаем `truncated: true`, и UI показывает предупреждение (а не делает вид,
|
||||
* что список полон).
|
||||
*/
|
||||
export async function listAllMedia(
|
||||
params: Omit<ListMediaParams, 'page' | 'pageSize'> & { cap?: number },
|
||||
): Promise<{ items: MediaAssetDto[]; total: number; truncated: boolean }> {
|
||||
const pageSize = 200
|
||||
const cap = params.cap ?? 5000
|
||||
const items: MediaAssetDto[] = []
|
||||
let total = 0
|
||||
for (let page = 1; ; page++) {
|
||||
const res = await listMedia({
|
||||
page,
|
||||
pageSize,
|
||||
statuses: params.statuses,
|
||||
search: params.search,
|
||||
})
|
||||
total = res.total
|
||||
items.push(...res.items)
|
||||
if (res.items.length === 0 || items.length >= total || items.length >= cap) break
|
||||
}
|
||||
return { items, total, truncated: items.length < total }
|
||||
}
|
||||
|
||||
/** Что лежит в ручном inbox (manual/) и ждёт разбора. */
|
||||
export function listManualInbox() {
|
||||
return apiRequest<ManualInboxListDto>('/admin/media/manual')
|
||||
}
|
||||
|
||||
/**
|
||||
* Забирает файлы из manual/ в шоу: файлы уходят из каталога, как и из обычного inbox, а спутники
|
||||
* (субтитры, nfo) удаляются. Номера серий передаются явно — сохранится ровно то, что было показано.
|
||||
*/
|
||||
export function importManualInbox(items: ManualImportItem[], showId: string) {
|
||||
return apiRequest<ImportManualInboxResultDto>('/admin/media/manual/import', {
|
||||
method: 'POST',
|
||||
body: { items, showId },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteMedia(id: string) {
|
||||
return apiRequest<void>(`/admin/media/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Потоковая загрузка файла (сырое тело + fileName в query). Через XHR ради индикатора прогресса.
|
||||
*/
|
||||
export function uploadMedia(
|
||||
file: File,
|
||||
onProgress?: (percent: number) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CreatedIdResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
return
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
const query = new URLSearchParams({ fileName: file.name })
|
||||
xhr.open('POST', `/api/admin/media?${query.toString()}`)
|
||||
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
|
||||
signal?.addEventListener('abort', () => xhr.abort())
|
||||
xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError'))
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable && onProgress)
|
||||
onProgress(Math.round((event.loaded / event.total) * 100))
|
||||
}
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(JSON.parse(xhr.responseText) as CreatedIdResponse)
|
||||
} else {
|
||||
let detail = `HTTP ${xhr.status}`
|
||||
try {
|
||||
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
|
||||
detail = problem.detail ?? problem.title ?? detail
|
||||
} catch {
|
||||
/* пусто */
|
||||
}
|
||||
reject(new HttpError({ detail }, xhr.status))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
CreatedIdResponse,
|
||||
ImportManualInboxResultDto,
|
||||
ManualImportItem,
|
||||
ManualInboxListDto,
|
||||
MediaAssetDto,
|
||||
MediaAssetStatus,
|
||||
MediaStatsDto,
|
||||
PagedList,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
type ListMediaParams = {
|
||||
page: number
|
||||
pageSize: number
|
||||
statuses?: MediaAssetStatus[]
|
||||
search?: string
|
||||
sort?: string
|
||||
desc?: boolean
|
||||
}
|
||||
|
||||
export function listMedia(params: ListMediaParams) {
|
||||
const query = new URLSearchParams({
|
||||
page: String(params.page),
|
||||
pageSize: String(params.pageSize),
|
||||
})
|
||||
for (const status of params.statuses ?? []) query.append('status', status)
|
||||
if (params.search) query.set('search', params.search)
|
||||
if (params.sort) query.set('sort', params.sort)
|
||||
if (params.desc) query.set('desc', 'true')
|
||||
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`)
|
||||
}
|
||||
|
||||
export function getMediaStats() {
|
||||
return apiRequest<MediaStatsDto>('/admin/media/stats')
|
||||
}
|
||||
|
||||
/**
|
||||
* Дозагружает ВСЕ страницы медиа (для пикеров с клиентской фильтрацией/сортировкой — кандидаты серий,
|
||||
* пул рекламы), чтобы ничего не терялось молча за фиксированным pageSize. Есть предохранитель `cap`:
|
||||
* если элементов больше — возвращаем `truncated: true`, и UI показывает предупреждение (а не делает вид,
|
||||
* что список полон).
|
||||
*/
|
||||
export async function listAllMedia(
|
||||
params: Omit<ListMediaParams, 'page' | 'pageSize'> & { cap?: number },
|
||||
): Promise<{ items: MediaAssetDto[]; total: number; truncated: boolean }> {
|
||||
const pageSize = 200
|
||||
const cap = params.cap ?? 5000
|
||||
const items: MediaAssetDto[] = []
|
||||
let total = 0
|
||||
for (let page = 1; ; page++) {
|
||||
const res = await listMedia({
|
||||
page,
|
||||
pageSize,
|
||||
statuses: params.statuses,
|
||||
search: params.search,
|
||||
})
|
||||
total = res.total
|
||||
items.push(...res.items)
|
||||
if (res.items.length === 0 || items.length >= total || items.length >= cap) break
|
||||
}
|
||||
return { items, total, truncated: items.length < total }
|
||||
}
|
||||
|
||||
/** Что лежит в ручном inbox (manual/) и ждёт разбора. */
|
||||
export function listManualInbox() {
|
||||
return apiRequest<ManualInboxListDto>('/admin/media/manual')
|
||||
}
|
||||
|
||||
/**
|
||||
* Забирает файлы из manual/ в шоу: файлы уходят из каталога, как и из обычного inbox, а спутники
|
||||
* (субтитры, nfo) удаляются. Номера серий передаются явно — сохранится ровно то, что было показано.
|
||||
*/
|
||||
export function importManualInbox(items: ManualImportItem[], showId: string) {
|
||||
return apiRequest<ImportManualInboxResultDto>('/admin/media/manual/import', {
|
||||
method: 'POST',
|
||||
body: { items, showId },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteMedia(id: string) {
|
||||
return apiRequest<void>(`/admin/media/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Потоковая загрузка файла (сырое тело + fileName в query). Через XHR ради индикатора прогресса.
|
||||
*/
|
||||
export function uploadMedia(
|
||||
file: File,
|
||||
onProgress?: (percent: number) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CreatedIdResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
return
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
const query = new URLSearchParams({ fileName: file.name })
|
||||
xhr.open('POST', `/api/admin/media?${query.toString()}`)
|
||||
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
|
||||
signal?.addEventListener('abort', () => xhr.abort())
|
||||
xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError'))
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable && onProgress)
|
||||
onProgress(Math.round((event.loaded / event.total) * 100))
|
||||
}
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(JSON.parse(xhr.responseText) as CreatedIdResponse)
|
||||
} else {
|
||||
let detail = `HTTP ${xhr.status}`
|
||||
try {
|
||||
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
|
||||
detail = problem.detail ?? problem.title ?? detail
|
||||
} catch {
|
||||
/* пусто */
|
||||
}
|
||||
reject(new HttpError({ detail }, xhr.status))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,64 +1,64 @@
|
||||
/** Готовые шаблоны для частых раскладок имён. Подпись переводится в UI по ключу. */
|
||||
export const REGEX_PRESETS: { key: string; pattern: string }[] = [
|
||||
{ key: 'seriesWord', pattern: '[Сс]ерия\\s*(\\d{1,3})' },
|
||||
{ key: 'episodeWord', pattern: '[Ээ]пизод\\s*(\\d{1,3})' },
|
||||
{ key: 'seasonEpisode', pattern: '[Ss](\\d{1,2})[Ee](\\d{1,3})' },
|
||||
{ key: 'afterDash', pattern: '[-–—]\\s*(\\d{1,3})' },
|
||||
{ key: 'firstNumber', pattern: '(?:^|\\D)(\\d{1,3})(?:\\D|$)' },
|
||||
]
|
||||
|
||||
/** Числа в имени файла: позиция и текст — по ним строится кликабельный образец. */
|
||||
export function findNumbers(fileName: string): { index: number; start: number; text: string }[] {
|
||||
return [...fileName.matchAll(/\d+/g)].map((match, index) => ({
|
||||
index,
|
||||
start: match.index ?? 0,
|
||||
text: match[0],
|
||||
}))
|
||||
}
|
||||
|
||||
const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&')
|
||||
|
||||
const isLetter = (char: string) => /\p{L}/u.test(char)
|
||||
const isLetterOrDigit = (char: string) => /[\p{L}\p{N}]/u.test(char)
|
||||
|
||||
/**
|
||||
* Хвост строки из символов, удовлетворяющих условию. Обходим с конца вручную, а не шаблоном вида
|
||||
* `X*$`: такой шаблон движок примеряет с каждой позиции строки и получает квадратичное время
|
||||
* (см. предупреждение анализатора о backtracking), тогда как здесь один линейный проход.
|
||||
*/
|
||||
function trailingRun(value: string, matches: (char: string) => boolean): string {
|
||||
let start = value.length
|
||||
while (start > 0 && matches(value[start - 1])) start--
|
||||
return value.slice(start)
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит regex по указанному пользователем числу в имени файла. Якорем берётся слово перед числом
|
||||
* («Серия 01» → `Серия\s*(\d{1,3})`): позиция числа в разных файлах гуляет, а слово рядом — нет.
|
||||
* Если перед числом только разделители — якорем становятся они, а число в начале имени крепится к `^`.
|
||||
*/
|
||||
export function buildEpisodeRegex(fileName: string, occurrenceIndex: number): string {
|
||||
const numbers = findNumbers(fileName)
|
||||
const target = numbers[occurrenceIndex]
|
||||
if (!target) return ''
|
||||
|
||||
// Всегда до трёх цифр — как во встроенных шаблонах: правило строится по одному файлу,
|
||||
// а применяется ко всей папке, где рядом может лежать и «Серия 100».
|
||||
const digits = '(\\d{1,3})'
|
||||
const before = fileName.slice(0, target.start)
|
||||
if (!before.trim()) return `^\\s*${digits}`
|
||||
|
||||
// Разделители между якорем и числом описываем классом, а не буквально: в соседних файлах
|
||||
// там встречается то пробел, то точка, то подчёркивание.
|
||||
const gap = trailingRun(before, (char) => !isLetterOrDigit(char))
|
||||
const anchorSource = before.slice(0, before.length - gap.length)
|
||||
// Якорь — только буквы: захвати он цифры, «S01E07» дало бы правило `S01E(\d)`, прибитое
|
||||
// к первому сезону, и на «S02E05» оно бы уже не сработало.
|
||||
const anchor = trailingRun(anchorSource, isLetter)
|
||||
|
||||
if (anchor) return `${escapeRegex(anchor)}${gap ? '[\\s._-]*' : ''}${digits}`
|
||||
|
||||
// Слова перед числом нет — цепляемся за последний разделитель («- 05», «(05)»).
|
||||
const punctuation = gap.trim().slice(-1)
|
||||
return punctuation ? `${escapeRegex(punctuation)}\\s*${digits}` : `\\s${digits}`
|
||||
}
|
||||
/** Готовые шаблоны для частых раскладок имён. Подпись переводится в UI по ключу. */
|
||||
export const REGEX_PRESETS: { key: string; pattern: string }[] = [
|
||||
{ key: 'seriesWord', pattern: '[Сс]ерия\\s*(\\d{1,3})' },
|
||||
{ key: 'episodeWord', pattern: '[Ээ]пизод\\s*(\\d{1,3})' },
|
||||
{ key: 'seasonEpisode', pattern: '[Ss](\\d{1,2})[Ee](\\d{1,3})' },
|
||||
{ key: 'afterDash', pattern: '[-–—]\\s*(\\d{1,3})' },
|
||||
{ key: 'firstNumber', pattern: '(?:^|\\D)(\\d{1,3})(?:\\D|$)' },
|
||||
]
|
||||
|
||||
/** Числа в имени файла: позиция и текст — по ним строится кликабельный образец. */
|
||||
export function findNumbers(fileName: string): { index: number; start: number; text: string }[] {
|
||||
return [...fileName.matchAll(/\d+/g)].map((match, index) => ({
|
||||
index,
|
||||
start: match.index ?? 0,
|
||||
text: match[0],
|
||||
}))
|
||||
}
|
||||
|
||||
const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&')
|
||||
|
||||
const isLetter = (char: string) => /\p{L}/u.test(char)
|
||||
const isLetterOrDigit = (char: string) => /[\p{L}\p{N}]/u.test(char)
|
||||
|
||||
/**
|
||||
* Хвост строки из символов, удовлетворяющих условию. Обходим с конца вручную, а не шаблоном вида
|
||||
* `X*$`: такой шаблон движок примеряет с каждой позиции строки и получает квадратичное время
|
||||
* (см. предупреждение анализатора о backtracking), тогда как здесь один линейный проход.
|
||||
*/
|
||||
function trailingRun(value: string, matches: (char: string) => boolean): string {
|
||||
let start = value.length
|
||||
while (start > 0 && matches(value[start - 1])) start--
|
||||
return value.slice(start)
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит regex по указанному пользователем числу в имени файла. Якорем берётся слово перед числом
|
||||
* («Серия 01» → `Серия\s*(\d{1,3})`): позиция числа в разных файлах гуляет, а слово рядом — нет.
|
||||
* Если перед числом только разделители — якорем становятся они, а число в начале имени крепится к `^`.
|
||||
*/
|
||||
export function buildEpisodeRegex(fileName: string, occurrenceIndex: number): string {
|
||||
const numbers = findNumbers(fileName)
|
||||
const target = numbers[occurrenceIndex]
|
||||
if (!target) return ''
|
||||
|
||||
// Всегда до трёх цифр — как во встроенных шаблонах: правило строится по одному файлу,
|
||||
// а применяется ко всей папке, где рядом может лежать и «Серия 100».
|
||||
const digits = '(\\d{1,3})'
|
||||
const before = fileName.slice(0, target.start)
|
||||
if (!before.trim()) return `^\\s*${digits}`
|
||||
|
||||
// Разделители между якорем и числом описываем классом, а не буквально: в соседних файлах
|
||||
// там встречается то пробел, то точка, то подчёркивание.
|
||||
const gap = trailingRun(before, (char) => !isLetterOrDigit(char))
|
||||
const anchorSource = before.slice(0, before.length - gap.length)
|
||||
// Якорь — только буквы: захвати он цифры, «S01E07» дало бы правило `S01E(\d)`, прибитое
|
||||
// к первому сезону, и на «S02E05» оно бы уже не сработало.
|
||||
const anchor = trailingRun(anchorSource, isLetter)
|
||||
|
||||
if (anchor) return `${escapeRegex(anchor)}${gap ? '[\\s._-]*' : ''}${digits}`
|
||||
|
||||
// Слова перед числом нет — цепляемся за последний разделитель («- 05», «(05)»).
|
||||
const punctuation = gap.trim().slice(-1)
|
||||
return punctuation ? `${escapeRegex(punctuation)}\\s*${digits}` : `\\s${digits}`
|
||||
}
|
||||
|
||||
@@ -1,110 +1,110 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { getSiteSettings, updateSiteSettings } from './api'
|
||||
|
||||
export function SettingsPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [registrationEnabled, setRegistrationEnabled] = useState(false)
|
||||
const [preferredAudioLanguages, setPreferredAudioLanguages] = useState('')
|
||||
const [channelNumbersEnabled, setChannelNumbersEnabled] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: qk.settings.all,
|
||||
queryFn: getSiteSettings,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setRegistrationEnabled(data.registrationEnabled)
|
||||
setPreferredAudioLanguages(data.preferredAudioLanguages)
|
||||
setChannelNumbersEnabled(data.channelNumbersEnabled)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const onError = useApiError()
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateSiteSettings({
|
||||
registrationEnabled,
|
||||
preferredAudioLanguages,
|
||||
channelNumbersEnabled,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
void queryClient.invalidateQueries({ queryKey: qk.settings.all })
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.settings.title')}</h2>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.settings.registration')}</CardTitle>
|
||||
<CardDescription>{t('admin.settings.registrationHint')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={registrationEnabled}
|
||||
disabled={isLoading}
|
||||
onChange={(e) => setRegistrationEnabled(e.target.checked)}
|
||||
/>
|
||||
{t('admin.settings.registrationLabel')}
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="preferred-audio">
|
||||
{t('admin.settings.preferredAudio')}
|
||||
</label>
|
||||
<Input
|
||||
id="preferred-audio"
|
||||
className="max-w-xs"
|
||||
placeholder="rus, eng"
|
||||
value={preferredAudioLanguages}
|
||||
disabled={isLoading}
|
||||
onChange={(e) => setPreferredAudioLanguages(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.settings.preferredAudioHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={channelNumbersEnabled}
|
||||
disabled={isLoading}
|
||||
onChange={(e) => setChannelNumbersEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t('admin.settings.channelNumbers')}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('admin.settings.channelNumbersHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<Button size="sm" disabled={isLoading || save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { getSiteSettings, updateSiteSettings } from './api'
|
||||
|
||||
export function SettingsPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [registrationEnabled, setRegistrationEnabled] = useState(false)
|
||||
const [preferredAudioLanguages, setPreferredAudioLanguages] = useState('')
|
||||
const [channelNumbersEnabled, setChannelNumbersEnabled] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: qk.settings.all,
|
||||
queryFn: getSiteSettings,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setRegistrationEnabled(data.registrationEnabled)
|
||||
setPreferredAudioLanguages(data.preferredAudioLanguages)
|
||||
setChannelNumbersEnabled(data.channelNumbersEnabled)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const onError = useApiError()
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateSiteSettings({
|
||||
registrationEnabled,
|
||||
preferredAudioLanguages,
|
||||
channelNumbersEnabled,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
void queryClient.invalidateQueries({ queryKey: qk.settings.all })
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.settings.title')}</h2>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.settings.registration')}</CardTitle>
|
||||
<CardDescription>{t('admin.settings.registrationHint')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={registrationEnabled}
|
||||
disabled={isLoading}
|
||||
onChange={(e) => setRegistrationEnabled(e.target.checked)}
|
||||
/>
|
||||
{t('admin.settings.registrationLabel')}
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="preferred-audio">
|
||||
{t('admin.settings.preferredAudio')}
|
||||
</label>
|
||||
<Input
|
||||
id="preferred-audio"
|
||||
className="max-w-xs"
|
||||
placeholder="rus, eng"
|
||||
value={preferredAudioLanguages}
|
||||
disabled={isLoading}
|
||||
onChange={(e) => setPreferredAudioLanguages(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.settings.preferredAudioHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={channelNumbersEnabled}
|
||||
disabled={isLoading}
|
||||
onChange={(e) => setChannelNumbersEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t('admin.settings.channelNumbers')}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('admin.settings.channelNumbersHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<Button size="sm" disabled={isLoading || save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user