Add Prettier to the frontend and gate formatting in CI

Форматтера у фронтенда не было: стиль держался вручную и успел разъехаться в 50 файлах.
Ставим Prettier с настройками под уже сложившийся стиль (без точек с запятой, одинарные кавычки,
ширина 100 — подобрана замером: при 100 расходится меньше файлов, чем при 96 или 110) и
прогоняем его по коду. `src/routeTree.gen.ts` исключён — его переписывает плагин роутера.

Чтобы форматирование больше не расходилось незаметно, добавлены проверки в CI: `csharpier check`
для бэкенда (его отсутствие и позволило накопиться 79 неотформатированным файлам) и
`prettier --check` для фронтенда. Версии форматтеров прибиты точно, без кареток: минорка меняет
вывод и красит CI на файлах, которых никто не трогал.

`.editorconfig` задаёт редакторам те же отступы и LF ещё до форматтера; значения совпадают с
настройками csharpier и Prettier намеренно — оба его читают.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-07-27 01:38:12 +03:00
co-authored by Claude Opus 5
parent 0442056367
commit 0606ea3e6e
58 changed files with 5158 additions and 4903 deletions
+22
View File
@@ -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
+12
View File
@@ -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
+11
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
dist
pnpm-lock.yaml
# Генерируется плагином TanStack Router на dev/build — форматировать бесполезно, перезапишется.
src/routeTree.gen.ts
+5
View File
@@ -0,0 +1,5 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 100
}
+4 -1
View File
@@ -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"
+4 -1
View File
@@ -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'
+5 -1
View File
@@ -245,7 +245,11 @@ export function addBumperTemplate(id: string, name: string) {
}) })
} }
export function updateBumperTemplate(id: string, templateId: string, body: BumperTemplateStyleBody) { export function updateBumperTemplate(
id: string,
templateId: string,
body: BumperTemplateStyleBody,
) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, { return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
method: 'PUT', method: 'PUT',
body, body,
@@ -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>
)} )}
@@ -84,7 +84,10 @@ export function BumperCard({
<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
value={bumper.selection}
onValueChange={(v) => setField('selection', v as BumperSelection)}
>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -136,7 +139,12 @@ export function BumperCard({
))} ))}
</div> </div>
<div className="flex justify-center"> <div className="flex justify-center">
<Button size="sm" variant="outline" disabled={addTemplate.isPending} onClick={() => addTemplate.mutate()}> <Button
size="sm"
variant="outline"
disabled={addTemplate.isPending}
onClick={() => addTemplate.mutate()}
>
{t('admin.channels.bumperAddTemplate')} {t('admin.channels.bumperAddTemplate')}
</Button> </Button>
</div> </div>
@@ -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>
)} )}
@@ -2,12 +2,7 @@ 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,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { getEntryTrace } from '../api' import { getEntryTrace } from '../api'
import { formatChannelTime } from '../lib/format' import { formatChannelTime } from '../lib/format'
@@ -59,7 +54,8 @@ export function EntryTraceDialog({
type Translate = ReturnType<typeof useTranslation>['t'] type Translate = ReturnType<typeof useTranslation>['t']
/** Склейка непустых частей строки трейса; пусто — значит строка не заполнена (покажем «—»). */ /** Склейка непустых частей строки трейса; пусто — значит строка не заполнена (покажем «—»). */
const joinParts = (parts: (string | null | undefined)[]) => parts.filter(Boolean).join(' · ') || null const joinParts = (parts: (string | null | undefined)[]) =>
parts.filter(Boolean).join(' · ') || null
function layerSummary(data: EntryTraceDto, t: Translate) { function layerSummary(data: EntryTraceDto, t: Translate) {
if (!data.layerName) return null if (!data.layerName) return null
@@ -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'
@@ -252,7 +252,9 @@ function JunctionChain({
<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> <option value="">{t('admin.channels.addJunctionElement')}</option>
{ADDABLE.map((kind) => ( {ADDABLE.map((kind) => (
@@ -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"
@@ -156,11 +156,7 @@ export function RulesCard({
))} ))}
</select> </select>
</div> </div>
<Button <Button size="sm" variant="ghost" onClick={() => removeWindow(key)}>
size="sm"
variant="ghost"
onClick={() => removeWindow(key)}
>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</li> </li>
@@ -231,7 +231,8 @@ export function ScheduleGrid({
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={{ style={{
top: (from / 60) * HOUR_HEIGHT, top: (from / 60) * HOUR_HEIGHT,
@@ -123,7 +123,11 @@ export function SettingsCard({
</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
type="checkbox"
checked={isEnabled}
onChange={(e) => setIsEnabled(e.target.checked)}
/>
{t('admin.channels.enabledLabel')} {t('admin.channels.enabledLabel')}
</label> </label>
<div className="flex items-end justify-end sm:col-span-2"> <div className="flex items-end justify-end sm:col-span-2">
@@ -135,7 +135,8 @@ function loadByHour(preview: SchedulePreviewDto): { hour: Date; minutes: number
start.getUTCDate(), start.getUTCDate(),
start.getUTCHours(), start.getUTCHours(),
) )
const minutes = (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
buckets.set(hour, (buckets.get(hour) ?? 0) + minutes) buckets.set(hour, (buckets.get(hour) ?? 0) + minutes)
} }
return [...buckets.entries()] return [...buckets.entries()]
@@ -193,7 +194,10 @@ function TapeRow({
<span className="w-10 shrink-0 tabular-nums text-muted-foreground"> <span className="w-10 shrink-0 tabular-nums text-muted-foreground">
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)} {formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
</span> </span>
<span className={cn('h-2 shrink-0 rounded-sm', KIND_COLORS[item.kind])} style={{ width: `${Math.max(4, minutes * 2)}px` }} /> <span
className={cn('h-2 shrink-0 rounded-sm', KIND_COLORS[item.kind])}
style={{ width: `${Math.max(4, minutes * 2)}px` }}
/>
<Badge variant="muted" className="shrink-0"> <Badge variant="muted" className="shrink-0">
{t(`admin.channels.previewKinds.${item.kind}`)} {t(`admin.channels.previewKinds.${item.kind}`)}
</Badge> </Badge>
@@ -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>
@@ -74,7 +74,8 @@ export function ManualInboxDialog({ onClose }: Readonly<{ onClose: () => void }>
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 ?? [])
map.set(file.relativePath, parseEpisodeName(file.name, options))
return map return map
}, [data, seasonOverride, regexStr, regexOk]) }, [data, seasonOverride, regexStr, regexOk])
@@ -98,8 +99,14 @@ export function ManualInboxDialog({ onClose }: Readonly<{ onClose: () => void }>
folder, folder,
files: [...files].sort((a, b) => files: [...files].sort((a, b) =>
compareParsed( compareParsed(
{ name: a.name, parsed: parsedByPath.get(a.relativePath) ?? { season: null, episode: null } }, {
{ name: b.name, parsed: parsedByPath.get(b.relativePath) ?? { season: null, episode: null } }, name: a.name,
parsed: parsedByPath.get(a.relativePath) ?? { season: null, episode: null },
},
{
name: b.name,
parsed: parsedByPath.get(b.relativePath) ?? { season: null, episode: null },
},
), ),
), ),
})) }))
@@ -172,8 +179,7 @@ export function ManualInboxDialog({ onClose }: Readonly<{ onClose: () => void }>
if (result.imported > 0) if (result.imported > 0)
toast.success(t('admin.media.manualImported', { count: result.imported })) toast.success(t('admin.media.manualImported', { count: result.imported }))
// Отказы показываем по одному: у каждого своя причина, и файл остаётся в каталоге. // Отказы показываем по одному: у каждого своя причина, и файл остаётся в каталоге.
for (const failure of result.failed) for (const failure of result.failed) toast.error(`${failure.relativePath}: ${failure.reason}`)
toast.error(`${failure.relativePath}: ${failure.reason}`)
setSelected([]) setSelected([])
void queryClient.invalidateQueries({ queryKey: qk.media.all }) void queryClient.invalidateQueries({ queryKey: qk.media.all })
@@ -234,9 +240,7 @@ export function ManualInboxDialog({ onClose }: Readonly<{ onClose: () => void }>
</SelectContent> </SelectContent>
</Select> </Select>
{autoDetected && ( {autoDetected && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{t('admin.media.manualDetected')}</p>
{t('admin.media.manualDetected')}
</p>
)} )}
</div> </div>
@@ -266,7 +270,9 @@ export function ManualInboxDialog({ onClose }: Readonly<{ onClose: () => void }>
/> />
</div> </div>
</div> </div>
{!regexOk && <p className="text-xs text-red-500">{t('admin.media.toShowRegexInvalid')}</p>} {!regexOk && (
<p className="text-xs text-red-500">{t('admin.media.toShowRegexInvalid')}</p>
)}
{/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */} {/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */}
{sample && ( {sample && (
@@ -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>
</> </>
) )
} }
+4 -1
View File
@@ -86,7 +86,10 @@ export function applyMetadata(showId: string, provider: string, externalId: stri
}) })
} }
export function updateMetadata(showId: string, body: { description: string | null; year: number | null }) { export function updateMetadata(
showId: string,
body: { description: string | null; year: number | null },
) {
return apiRequest<void>(`/admin/metadata/shows/${showId}`, { method: 'PUT', body }) 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>
+6 -1
View File
@@ -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}>
+6 -1
View File
@@ -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 -3
View File
@@ -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)
+4 -11
View File
@@ -130,23 +130,16 @@ export function AirPage() {
// иначе плейлист/сегменты начнут отдавать 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)
},
20 * 60_000,
)
return () => window.clearInterval(id) return () => window.clearInterval(id)
}, [selected, playerError]) }, [selected, playerError])
const { data: epg } = useQuery({ const { data: epg } = useQuery({
queryKey: qk.air.epg(selected), queryKey: qk.air.epg(selected),
queryFn: () => queryFn: () =>
getEpg( getEpg(selected!, new Date(Date.now() - 30 * 60_000), new Date(Date.now() + 3 * 60 * 60_000)),
selected!,
new Date(Date.now() - 30 * 60_000),
new Date(Date.now() + 3 * 60 * 60_000),
),
enabled: !!selected, enabled: !!selected,
refetchInterval: 60_000, refetchInterval: 60_000,
}) })
@@ -19,8 +19,7 @@ function readStoredAudio(): { volume: number; muted: boolean } {
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 }
} }
@@ -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],
)}
/> />
) )
} }
+22 -5
View File
@@ -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()
+3 -1
View File
@@ -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>
+41 -10
View File
@@ -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>
+4 -1
View File
@@ -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
+1 -1
View File
@@ -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 }))
/** /**
+8 -6
View File
@@ -46,7 +46,8 @@ export const en = {
noAccount: "Don't have an account?", noAccount: "Don't have an account?",
haveAccount: 'Already have an account?', haveAccount: 'Already have an account?',
registrationClosed: 'Registration is closed', registrationClosed: 'Registration is closed',
registrationClosedHint: 'Public registration is disabled. An administrator can create an account for you.', registrationClosedHint:
'Public registration is disabled. An administrator can create an account for you.',
invalidCredentials: 'Invalid username or password', invalidCredentials: 'Invalid username or password',
userNameTaken: 'This username is already taken', userNameTaken: 'This username is already taken',
blocked: 'Account blocked by an administrator', blocked: 'Account blocked by an administrator',
@@ -110,7 +111,7 @@ export const en = {
genresHint: 'Any of the checked ones.', genresHint: 'Any of the checked ones.',
maxAudience: 'No stricter than', maxAudience: 'No stricter than',
audienceHint: audienceHint:
'Ratings are ordered by strictness: G → PG → PG-13 → R → NC-17. Unrated shows stay in the selection.', 'Ratings are ordered by strictness: G → PG → PG-13 → R → NC-17. Unrated shows stay in the selection.',
year: 'Year', year: 'Year',
unitMinutes: 'Unit runtime, min', unitMinutes: 'Unit runtime, min',
unitMinutesHint: unitMinutesHint:
@@ -160,8 +161,7 @@ export const en = {
slug: 'Key', slug: 'Key',
slugHint: 'Latin letters, no spaces — the genre is matched by it when the reference updates.', slugHint: 'Latin letters, no spaces — the genre is matched by it when the reference updates.',
aliases: 'Spellings', aliases: 'Spellings',
aliasesHint: aliasesHint: 'Comma-separated. Maps provider genres onto yours: tmdb:28, action, боевик.',
'Comma-separated. Maps provider genres onto yours: tmdb:28, action, боевик.',
order: 'Order', order: 'Order',
usage: 'Shows', usage: 'Shows',
system: 'System', system: 'System',
@@ -600,7 +600,8 @@ export const en = {
bumperTriggerBetweenEpisodes: 'Between episodes', bumperTriggerBetweenEpisodes: 'Between episodes',
bumperTriggerBoth: 'Both', bumperTriggerBoth: 'Both',
bumperVariantWeight: 'Weight', bumperVariantWeight: 'Weight',
bumperVariantWeightHint: 'For the “weighted random” strategy: higher = more often (0 — never picked)', bumperVariantWeightHint:
'For the “weighted random” strategy: higher = more often (0 — never picked)',
bumperDefault: 'default', bumperDefault: 'default',
bumperSeconds: 's', bumperSeconds: 's',
bumperDefaultDuration: '≈8 s (jingle)', bumperDefaultDuration: '≈8 s (jingle)',
@@ -608,7 +609,8 @@ export const en = {
bumperAudioHint: 'Bumper sound; otherwise a synthesized jingle', bumperAudioHint: 'Bumper sound; otherwise a synthesized jingle',
bumperPreview: 'Render samples', bumperPreview: 'Render samples',
bumperPreviewRendering: 'Rendering…', bumperPreviewRendering: 'Rendering…',
bumperPreviewHint: 'Samples of all sub-blocks with sound and animation (example show names). Uses saved settings.', bumperPreviewHint:
'Samples of all sub-blocks with sound and animation (example show names). Uses saved settings.',
bumperBackground: 'Background image', bumperBackground: 'Background image',
bumperBackgroundHint: 'Background image; otherwise the show poster or a gradient', bumperBackgroundHint: 'Background image; otherwise the show poster or a gradient',
bumperBackgroundPick: 'Pick from gallery', bumperBackgroundPick: 'Pick from gallery',
+8 -6
View File
@@ -46,7 +46,8 @@ export const ru = {
noAccount: 'Нет аккаунта?', noAccount: 'Нет аккаунта?',
haveAccount: 'Уже есть аккаунт?', haveAccount: 'Уже есть аккаунт?',
registrationClosed: 'Регистрация закрыта', registrationClosed: 'Регистрация закрыта',
registrationClosedHint: 'Открытая регистрация отключена. Учётную запись может завести администратор.', registrationClosedHint:
'Открытая регистрация отключена. Учётную запись может завести администратор.',
invalidCredentials: 'Неверное имя пользователя или пароль', invalidCredentials: 'Неверное имя пользователя или пароль',
userNameTaken: 'Это имя пользователя уже занято', userNameTaken: 'Это имя пользователя уже занято',
blocked: 'Аккаунт заблокирован администратором', blocked: 'Аккаунт заблокирован администратором',
@@ -110,7 +111,7 @@ export const ru = {
genresHint: 'Любой из отмеченных.', genresHint: 'Любой из отмеченных.',
maxAudience: 'Возраст не строже', maxAudience: 'Возраст не строже',
audienceHint: audienceHint:
'Рейтинги упорядочены по строгости: G → PG → PG-13 → R → NC-17. Шоу без рейтинга остаются в выборке.', 'Рейтинги упорядочены по строгости: G → PG → PG-13 → R → NC-17. Шоу без рейтинга остаются в выборке.',
year: 'Год', year: 'Год',
unitMinutes: 'Длительность единицы, мин', unitMinutes: 'Длительность единицы, мин',
unitMinutesHint: unitMinutesHint:
@@ -397,8 +398,7 @@ export const ru = {
}, },
noTemplate: 'Сетка канала не загрузилась', noTemplate: 'Сетка канала не загрузилась',
createTemplate: 'Создать сетку', createTemplate: 'Создать сетку',
createTemplateHint: createTemplateHint: 'Появится пустая сетка с фоновым слоем — дальше добавляйте слои и слоты.',
'Появится пустая сетка с фоновым слоем — дальше добавляйте слои и слоты.',
rules: 'Правила отбора', rules: 'Правила отбора',
rulesHint: rulesHint:
'Жёсткие фильтры: отсекают неподходящее до жребия. Как и правка сетки, эфир не двигают — нужно применить.', 'Жёсткие фильтры: отсекают неподходящее до жребия. Как и правка сетки, эфир не двигают — нужно применить.',
@@ -606,7 +606,8 @@ export const ru = {
bumperTriggerBetweenEpisodes: 'Между сериями', bumperTriggerBetweenEpisodes: 'Между сериями',
bumperTriggerBoth: 'Оба', bumperTriggerBoth: 'Оба',
bumperVariantWeight: 'Вес', bumperVariantWeight: 'Вес',
bumperVariantWeightHint: 'Для стратегии «случайно взвешенный»: чем больше — тем чаще (0 — не выбирается)', bumperVariantWeightHint:
'Для стратегии «случайно взвешенный»: чем больше — тем чаще (0 — не выбирается)',
bumperDefault: 'по умолчанию', bumperDefault: 'по умолчанию',
bumperSeconds: 'с', bumperSeconds: 'с',
bumperDefaultDuration: '≈8 с (джингл)', bumperDefaultDuration: '≈8 с (джингл)',
@@ -614,7 +615,8 @@ export const ru = {
bumperAudioHint: 'Звук заставки; иначе — синтезированный джингл', bumperAudioHint: 'Звук заставки; иначе — синтезированный джингл',
bumperPreview: 'Отрендерить примеры', bumperPreview: 'Отрендерить примеры',
bumperPreviewRendering: 'Рендерим…', bumperPreviewRendering: 'Рендерим…',
bumperPreviewHint: 'Примеры всех подблоков со звуком и анимацией (примерные названия шоу). Использует сохранённые настройки.', bumperPreviewHint:
'Примеры всех подблоков со звуком и анимацией (примерные названия шоу). Использует сохранённые настройки.',
bumperBackground: 'Фон-картинка', bumperBackground: 'Фон-картинка',
bumperBackgroundHint: 'Картинка фона; иначе — постер шоу или градиент', bumperBackgroundHint: 'Картинка фона; иначе — постер шоу или градиент',
bumperBackgroundPick: 'Выбрать из галереи', bumperBackgroundPick: 'Выбрать из галереи',
+1 -2
View File
@@ -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 -6
View File
@@ -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
+3 -1
View File
@@ -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'
+26 -13
View File
@@ -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'
+10 -2
View File
@@ -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
+1 -4
View File
@@ -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)}
+7 -1
View File
@@ -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 -4
View File
@@ -1,7 +1,4 @@
{ {
"files": [], "files": [],
"references": [ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
} }