Add Prettier to the frontend and gate formatting in CI
Форматтера у фронтенда не было: стиль держался вручную и успел разъехаться в 50 файлах. Ставим Prettier с настройками под уже сложившийся стиль (без точек с запятой, одинарные кавычки, ширина 100 — подобрана замером: при 100 расходится меньше файлов, чем при 96 или 110) и прогоняем его по коду. `src/routeTree.gen.ts` исключён — его переписывает плагин роутера. Чтобы форматирование больше не расходилось незаметно, добавлены проверки в CI: `csharpier check` для бэкенда (его отсутствие и позволило накопиться 79 неотформатированным файлам) и `prettier --check` для фронтенда. Версии форматтеров прибиты точно, без кареток: минорка меняет вывод и красит CI на файлах, которых никто не трогал. `.editorconfig` задаёт редакторам те же отступы и LF ещё до форматтера; значения совпадают с настройками csharpier и Prettier намеренно — оба его читают. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0442056367
commit
0606ea3e6e
@@ -0,0 +1,22 @@
|
|||||||
|
# Базовые правила для редакторов — чтобы IDE не ставила свои отступы и концы строк до того, как
|
||||||
|
# файл дойдёт до форматтера. Итоговый стиль всё равно задают csharpier (.config/dotnet-tools.json)
|
||||||
|
# и Prettier (frontend/.prettierrc.json); значения здесь совпадают с их настройками намеренно —
|
||||||
|
# оба читают .editorconfig, и расхождение развернуло бы форматирование в другую сторону.
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
max_line_length = 100
|
||||||
|
|
||||||
|
[*.cs]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
# В markdown два пробела в конце строки — это перенос, обрезать их нельзя.
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Коммиты, которые правили только оформление: перевод концов строк в LF и первые прогоны
|
||||||
|
# csharpier/Prettier. Без этого списка `git blame` на половине файлов показывал бы их вместо
|
||||||
|
# автора реальной правки.
|
||||||
|
#
|
||||||
|
# Включить локально (в .git/config, а не в репозитории — потому команду надо выполнить у себя):
|
||||||
|
# git config blame.ignoreRevsFile .git-blame-ignore-revs
|
||||||
|
#
|
||||||
|
# Добавлять сюда только чисто механические коммиты, полным SHA, по одному на строку:
|
||||||
|
# git rev-parse HEAD >> .git-blame-ignore-revs
|
||||||
|
|
||||||
|
# Перевод дерева в LF.
|
||||||
|
0442056367cb6cd3b38964dacf75f44f123e11ac
|
||||||
@@ -21,6 +21,14 @@ jobs:
|
|||||||
- name: Restore
|
- name: Restore
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
run: dotnet restore TeleWave.slnx
|
run: dotnet restore TeleWave.slnx
|
||||||
|
# Форматирование проверяем до сборки: падает за секунду и сразу показывает, что чинить.
|
||||||
|
# Версия csharpier прибита в .config/dotnet-tools.json — глобальная копия разработчика на CI
|
||||||
|
# не влияет, иначе её минорка переформатировала бы репозиторий в свой стиль.
|
||||||
|
- name: Format check
|
||||||
|
working-directory: backend
|
||||||
|
run: |
|
||||||
|
dotnet tool restore
|
||||||
|
dotnet csharpier check .
|
||||||
# Строгая сборка: TreatWarningsAsErrors=true из Directory.Build.props не отключаем.
|
# Строгая сборка: TreatWarningsAsErrors=true из Directory.Build.props не отключаем.
|
||||||
- name: Build (Release)
|
- name: Build (Release)
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
@@ -38,6 +46,9 @@ jobs:
|
|||||||
- name: Install
|
- name: Install
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Format check
|
||||||
|
working-directory: frontend
|
||||||
|
run: pnpm format:check
|
||||||
- name: Lint
|
- name: Lint
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
dist
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
# Генерируется плагином TanStack Router на dev/build — форматировать бесполезно, перезапишется.
|
||||||
|
src/routeTree.gen.ts
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
"typecheck": "tsc -b",
|
"typecheck": "tsc -b",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
@@ -39,7 +41,8 @@
|
|||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
"oxlint": "^1.71.0",
|
"oxlint": "1.75.0",
|
||||||
|
"prettier": "3.9.6",
|
||||||
"tailwindcss": "^4.3.2",
|
"tailwindcss": "^4.3.2",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.1.1"
|
"vite": "^8.1.1"
|
||||||
|
|||||||
Generated
+4
-1
@@ -85,8 +85,11 @@ importers:
|
|||||||
specifier: ^6.0.3
|
specifier: ^6.0.3
|
||||||
version: 6.0.4(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))
|
version: 6.0.4(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))
|
||||||
oxlint:
|
oxlint:
|
||||||
specifier: ^1.71.0
|
specifier: 1.75.0
|
||||||
version: 1.75.0
|
version: 1.75.0
|
||||||
|
prettier:
|
||||||
|
specifier: 3.9.6
|
||||||
|
version: 3.9.6
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.3.2
|
specifier: ^4.3.2
|
||||||
version: 4.3.3
|
version: 4.3.3
|
||||||
|
|||||||
@@ -11,12 +11,7 @@ import { Badge } from '@/shared/ui/badge'
|
|||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Card, CardContent } from '@/shared/ui/card'
|
import { Card, CardContent } from '@/shared/ui/card'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import {
|
import { applyChannelTemplate, getChannel, getChannelTemplate, getSchedule } from './api'
|
||||||
applyChannelTemplate,
|
|
||||||
getChannel,
|
|
||||||
getChannelTemplate,
|
|
||||||
getSchedule,
|
|
||||||
} from './api'
|
|
||||||
import { ApplyDialog } from './components/ApplyDialog'
|
import { ApplyDialog } from './components/ApplyDialog'
|
||||||
import { BumperCard } from './components/BumperCard'
|
import { BumperCard } from './components/BumperCard'
|
||||||
import { EntryTraceDialog } from './components/EntryTraceDialog'
|
import { EntryTraceDialog } from './components/EntryTraceDialog'
|
||||||
|
|||||||
@@ -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,14 +240,20 @@ 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 }) => {
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -152,7 +153,9 @@ export function ShowMetadataCard({
|
|||||||
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>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.metadata.noPoster')}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
|
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
|
||||||
@@ -184,13 +187,17 @@ export function ShowMetadataCard({
|
|||||||
setSearched(false)
|
setSearched(false)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.metadata.originalNameHint')}</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.metadata.originalNameHint')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{providers && providers.length > 0 && (searched || results.length > 0) && (
|
{providers && providers.length > 0 && (searched || results.length > 0) && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{searched && results.length === 0 && (
|
{searched && results.length === 0 && (
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.metadata.nothingFound')}</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.metadata.nothingFound')}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{results.length > 0 && (
|
{results.length > 0 && (
|
||||||
@@ -198,7 +205,11 @@ export function ShowMetadataCard({
|
|||||||
{results.map((r) => (
|
{results.map((r) => (
|
||||||
<li key={r.externalId} className="flex items-start gap-3 p-2">
|
<li key={r.externalId} className="flex items-start gap-3 p-2">
|
||||||
{r.posterUrl ? (
|
{r.posterUrl ? (
|
||||||
<img src={r.posterUrl} alt="" className="h-16 w-11 shrink-0 rounded object-cover" />
|
<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="h-16 w-11 shrink-0 rounded bg-muted/40" />
|
||||||
)}
|
)}
|
||||||
@@ -217,7 +228,9 @@ export function ShowMetadataCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{r.overview && (
|
{r.overview && (
|
||||||
<p className="line-clamp-2 text-xs text-muted-foreground">{r.overview}</p>
|
<p className="line-clamp-2 text-xs text-muted-foreground">
|
||||||
|
{r.overview}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -247,11 +260,7 @@ export function ShowMetadataCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex w-24 flex-col gap-1.5">
|
<div className="flex w-24 flex-col gap-1.5">
|
||||||
<Label>{t('admin.metadata.year')}</Label>
|
<Label>{t('admin.metadata.year')}</Label>
|
||||||
<Input
|
<Input type="number" value={year} onChange={(e) => setYear(e.target.value)} />
|
||||||
type="number"
|
|
||||||
value={year}
|
|
||||||
onChange={(e) => setYear(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -279,7 +288,11 @@ export function ShowMetadataCard({
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Button size="sm" disabled={save.isPending || !name.trim()} onClick={() => save.mutate()}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={save.isPending || !name.trim()}
|
||||||
|
onClick={() => save.mutate()}
|
||||||
|
>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
{linked && (
|
{linked && (
|
||||||
|
|||||||
@@ -86,7 +86,10 @@ export function applyMetadata(showId: string, provider: string, externalId: stri
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateMetadata(showId: string, body: { description: string | null; year: number | null }) {
|
export function updateMetadata(
|
||||||
|
showId: string,
|
||||||
|
body: { description: string | null; year: number | null },
|
||||||
|
) {
|
||||||
return apiRequest<void>(`/admin/metadata/shows/${showId}`, { method: 'PUT', body })
|
return apiRequest<void>(`/admin/metadata/shows/${showId}`, { method: 'PUT', body })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,7 @@ import { useApiError } from '@/shared/lib/use-api-error'
|
|||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||||
import {
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/shared/ui/dialog'
|
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
@@ -224,7 +218,9 @@ export function UsersPanel() {
|
|||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<Select
|
<Select
|
||||||
value={roles?.find((r) => r.name === user.role)?.id}
|
value={roles?.find((r) => r.name === user.role)?.id}
|
||||||
onValueChange={(newRoleId) => changeRoleMutation.mutate({ userId: user.id, roleId: newRoleId })}
|
onValueChange={(newRoleId) =>
|
||||||
|
changeRoleMutation.mutate({ userId: user.id, roleId: newRoleId })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-32">
|
<SelectTrigger className="h-8 w-32">
|
||||||
<SelectValue>{user.role}</SelectValue>
|
<SelectValue>{user.role}</SelectValue>
|
||||||
@@ -251,18 +247,30 @@ export function UsersPanel() {
|
|||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{user.isBlocked ? (
|
{user.isBlocked ? (
|
||||||
<Button size="sm" variant="outline" onClick={() => unblockMutation.mutate(user.id)}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => unblockMutation.mutate(user.id)}
|
||||||
|
>
|
||||||
{t('admin.users.unblock')}
|
{t('admin.users.unblock')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button size="sm" variant="outline" onClick={() => blockMutation.mutate(user.id)}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => blockMutation.mutate(user.id)}
|
||||||
|
>
|
||||||
{t('admin.users.block')}
|
{t('admin.users.block')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button size="sm" variant="outline" onClick={() => setResetTarget(user)}>
|
<Button size="sm" variant="outline" onClick={() => setResetTarget(user)}>
|
||||||
{t('admin.users.resetPassword')}
|
{t('admin.users.resetPassword')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="destructive" onClick={() => deleteMutation.mutate(user.id)}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteMutation.mutate(user.id)}
|
||||||
|
>
|
||||||
{t('common.delete')}
|
{t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,13 +283,23 @@ export function UsersPanel() {
|
|||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-center gap-2 text-sm">
|
<div className="flex items-center justify-center gap-2 text-sm">
|
||||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
‹
|
‹
|
||||||
</Button>
|
</Button>
|
||||||
<span>
|
<span>
|
||||||
{page} / {totalPages}
|
{page} / {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
›
|
›
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,7 +47,12 @@ export function LoginForm({ onSuccess }: Readonly<{ onSuccess: () => void }>) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="password">{t('auth.password')}</Label>
|
<Label htmlFor="password">{t('auth.password')}</Label>
|
||||||
<Input id="password" type="password" autoComplete="current-password" {...registerField('password')} />
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
{...registerField('password')}
|
||||||
|
/>
|
||||||
{errors.password && <p className="text-xs text-red-500">{errors.password.message}</p>}
|
{errors.password && <p className="text-xs text-red-500">{errors.password.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
|||||||
@@ -47,7 +47,12 @@ export function RegisterForm({ onSuccess }: Readonly<{ onSuccess: () => void }>)
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="password">{t('auth.password')}</Label>
|
<Label htmlFor="password">{t('auth.password')}</Label>
|
||||||
<Input id="password" type="password" autoComplete="new-password" {...registerField('password')} />
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
{...registerField('password')}
|
||||||
|
/>
|
||||||
{errors.password && <p className="text-xs text-red-500">{errors.password.message}</p>}
|
{errors.password && <p className="text-xs text-red-500">{errors.password.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export function login(userName: string, password: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function register(userName: string, password: string) {
|
export function register(userName: string, password: string) {
|
||||||
return apiRequest<AuthResponse>('/auth/register', { method: 'POST', body: { userName, password } })
|
return apiRequest<AuthResponse>('/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { userName, password },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logout() {
|
export function logout() {
|
||||||
@@ -20,7 +23,10 @@ export function logout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function changePassword(currentPassword: string, newPassword: string) {
|
export function changePassword(currentPassword: string, newPassword: string) {
|
||||||
return apiRequest<void>('/auth/change-password', { method: 'POST', body: { currentPassword, newPassword } })
|
return apiRequest<void>('/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { currentPassword, newPassword },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function changeUserName(newUserName: string) {
|
export function changeUserName(newUserName: string) {
|
||||||
@@ -39,7 +45,10 @@ export function applyAuthResponse(auth: AuthResponse) {
|
|||||||
/** Тихая попытка восстановить сессию по refresh-cookie при загрузке приложения. */
|
/** Тихая попытка восстановить сессию по refresh-cookie при загрузке приложения. */
|
||||||
export async function bootstrapSession() {
|
export async function bootstrapSession() {
|
||||||
try {
|
try {
|
||||||
const auth = await apiRequest<AuthResponse>('/auth/refresh', { method: 'POST', skipRefresh: true })
|
const auth = await apiRequest<AuthResponse>('/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
skipRefresh: true,
|
||||||
|
})
|
||||||
applyAuthResponse(auth)
|
applyAuthResponse(auth)
|
||||||
} catch {
|
} catch {
|
||||||
setAccessToken(null)
|
setAccessToken(null)
|
||||||
|
|||||||
@@ -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],
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,13 @@ function RootLayout() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
<header className="border-b border-border">
|
<header className="border-b border-border">
|
||||||
<div className={cn('mx-auto flex items-center justify-between gap-4 px-4 py-3', containerMax)}>
|
<div
|
||||||
<Link to="/" className="crt-glow flex items-center gap-2 text-lg font-bold tracking-widest">
|
className={cn('mx-auto flex items-center justify-between gap-4 px-4 py-3', containerMax)}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="crt-glow flex items-center gap-2 text-lg font-bold tracking-widest"
|
||||||
|
>
|
||||||
<Radio className="h-5 w-5" />
|
<Radio className="h-5 w-5" />
|
||||||
{t('appName')}
|
{t('appName')}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -45,11 +50,19 @@ function RootLayout() {
|
|||||||
<nav className="hidden items-center gap-4 text-sm md:flex">
|
<nav className="hidden items-center gap-4 text-sm md:flex">
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
<Link to="/dashboard" className="hover:text-primary" activeProps={{ className: 'text-primary' }}>
|
<Link
|
||||||
|
to="/dashboard"
|
||||||
|
className="hover:text-primary"
|
||||||
|
activeProps={{ className: 'text-primary' }}
|
||||||
|
>
|
||||||
{t('nav.dashboard')}
|
{t('nav.dashboard')}
|
||||||
</Link>
|
</Link>
|
||||||
{user.role === 'admin' && (
|
{user.role === 'admin' && (
|
||||||
<Link to="/admin" className="hover:text-primary" activeProps={{ className: 'text-primary' }}>
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className="hover:text-primary"
|
||||||
|
activeProps={{ className: 'text-primary' }}
|
||||||
|
>
|
||||||
{t('nav.admin')}
|
{t('nav.admin')}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
@@ -135,7 +148,11 @@ function RootLayout() {
|
|||||||
>
|
>
|
||||||
{user.userName}
|
{user.userName}
|
||||||
</Link>
|
</Link>
|
||||||
<button type="button" className="py-1 text-left" onClick={() => void handleLogout()}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="py-1 text-left"
|
||||||
|
onClick={() => void handleLogout()}
|
||||||
|
>
|
||||||
{t('nav.logout')}
|
{t('nav.logout')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { ChannelDetail } from '@/features/admin/channels/ChannelDetail'
|
import { ChannelDetail } from '@/features/admin/channels/ChannelDetail'
|
||||||
|
|
||||||
export const Route = createFileRoute('/admin/channels/$channelId')({ component: ChannelDetailRoute })
|
export const Route = createFileRoute('/admin/channels/$channelId')({
|
||||||
|
component: ChannelDetailRoute,
|
||||||
|
})
|
||||||
|
|
||||||
function ChannelDetailRoute() {
|
function ChannelDetailRoute() {
|
||||||
const { channelId } = Route.useParams()
|
const { channelId } = Route.useParams()
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ function HomePage() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h1 className="crt-glow text-4xl font-bold tracking-[0.2em]">{t('home.title')}</h1>
|
<h1 className="crt-glow text-4xl font-bold tracking-[0.2em]">{t('home.title')}</h1>
|
||||||
<p className="text-sm uppercase tracking-[0.3em] text-muted-foreground">{t('home.subtitle')}</p>
|
<p className="text-sm uppercase tracking-[0.3em] text-muted-foreground">
|
||||||
|
{t('home.subtitle')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="max-w-md text-muted-foreground">{t('home.tagline')}</p>
|
<p className="max-w-md text-muted-foreground">{t('home.tagline')}</p>
|
||||||
|
|||||||
@@ -16,26 +16,39 @@ import { toast } from '@/shared/ui/toast-store'
|
|||||||
export const Route = createFileRoute('/settings')({ component: SettingsPage })
|
export const Route = createFileRoute('/settings')({ component: SettingsPage })
|
||||||
|
|
||||||
const userNameSchema = z.object({ newUserName: z.string().min(3).max(64) })
|
const userNameSchema = z.object({ newUserName: z.string().min(3).max(64) })
|
||||||
const passwordSchema = z.object({ currentPassword: z.string().min(1), newPassword: z.string().min(8) })
|
const passwordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1),
|
||||||
|
newPassword: z.string().min(8),
|
||||||
|
})
|
||||||
|
|
||||||
function SettingsPage() {
|
function SettingsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { isReady } = useRequireAuth()
|
const { isReady } = useRequireAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const userNameForm = useForm<z.infer<typeof userNameSchema>>({ resolver: zodResolver(userNameSchema) })
|
const userNameForm = useForm<z.infer<typeof userNameSchema>>({
|
||||||
const passwordForm = useForm<z.infer<typeof passwordSchema>>({ resolver: zodResolver(passwordSchema) })
|
resolver: zodResolver(userNameSchema),
|
||||||
|
})
|
||||||
|
const passwordForm = useForm<z.infer<typeof passwordSchema>>({
|
||||||
|
resolver: zodResolver(passwordSchema),
|
||||||
|
})
|
||||||
|
|
||||||
if (!isReady) return null
|
if (!isReady) return null
|
||||||
|
|
||||||
const onSaveUserName = async (values: z.infer<typeof userNameSchema>) => {
|
const onSaveUserName = async (values: z.infer<typeof userNameSchema>) => {
|
||||||
try {
|
try {
|
||||||
await changeUserName(values.newUserName)
|
await changeUserName(values.newUserName)
|
||||||
useAuthStore.getState().setUser({ ...useAuthStore.getState().user!, userName: values.newUserName })
|
useAuthStore
|
||||||
|
.getState()
|
||||||
|
.setUser({ ...useAuthStore.getState().user!, userName: values.newUserName })
|
||||||
toast.success(t('settings.saved'))
|
toast.success(t('settings.saved'))
|
||||||
userNameForm.reset()
|
userNameForm.reset()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof HttpError && error.status === 409 ? t('auth.userNameTaken') : t('common.error'))
|
toast.error(
|
||||||
|
error instanceof HttpError && error.status === 409
|
||||||
|
? t('auth.userNameTaken')
|
||||||
|
: t('common.error'),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,12 +82,19 @@ function SettingsPage() {
|
|||||||
<CardTitle>{t('settings.changeUserName')}</CardTitle>
|
<CardTitle>{t('settings.changeUserName')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-4" onSubmit={userNameForm.handleSubmit(onSaveUserName)}>
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={userNameForm.handleSubmit(onSaveUserName)}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="newUserName">{t('settings.newUserName')}</Label>
|
<Label htmlFor="newUserName">{t('settings.newUserName')}</Label>
|
||||||
<Input id="newUserName" {...userNameForm.register('newUserName')} />
|
<Input id="newUserName" {...userNameForm.register('newUserName')} />
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="self-start" disabled={userNameForm.formState.isSubmitting}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="self-start"
|
||||||
|
disabled={userNameForm.formState.isSubmitting}
|
||||||
|
>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -86,16 +106,27 @@ function SettingsPage() {
|
|||||||
<CardTitle>{t('settings.changePassword')}</CardTitle>
|
<CardTitle>{t('settings.changePassword')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-4" onSubmit={passwordForm.handleSubmit(onSavePassword)}>
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={passwordForm.handleSubmit(onSavePassword)}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="currentPassword">{t('settings.currentPassword')}</Label>
|
<Label htmlFor="currentPassword">{t('settings.currentPassword')}</Label>
|
||||||
<Input id="currentPassword" type="password" {...passwordForm.register('currentPassword')} />
|
<Input
|
||||||
|
id="currentPassword"
|
||||||
|
type="password"
|
||||||
|
{...passwordForm.register('currentPassword')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="newPassword">{t('settings.newPassword')}</Label>
|
<Label htmlFor="newPassword">{t('settings.newPassword')}</Label>
|
||||||
<Input id="newPassword" type="password" {...passwordForm.register('newPassword')} />
|
<Input id="newPassword" type="password" {...passwordForm.register('newPassword')} />
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="self-start" disabled={passwordForm.formState.isSubmitting}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="self-start"
|
||||||
|
disabled={passwordForm.formState.isSubmitting}
|
||||||
|
>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ export async function refreshAccessToken(): Promise<boolean> {
|
|||||||
if (!refreshInFlight) {
|
if (!refreshInFlight) {
|
||||||
refreshInFlight = (async () => {
|
refreshInFlight = (async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
|
const response = await fetch('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
if (!response.ok) return false
|
if (!response.ok) return false
|
||||||
const data = (await response.json()) as { accessToken?: unknown }
|
const data = (await response.json()) as { accessToken?: unknown }
|
||||||
if (typeof data?.accessToken !== 'string') return false
|
if (typeof data?.accessToken !== 'string') return false
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react'
|
|||||||
|
|
||||||
export type KeyedRow<T> = { key: string; value: T }
|
export type KeyedRow<T> = { key: string; value: T }
|
||||||
|
|
||||||
const toRows = <T,>(values: readonly T[]): KeyedRow<T>[] =>
|
const toRows = <T>(values: readonly T[]): KeyedRow<T>[] =>
|
||||||
values.map((value) => ({ key: crypto.randomUUID(), value }))
|
values.map((value) => ({ key: crypto.randomUUID(), value }))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ export const ru = {
|
|||||||
noAccount: 'Нет аккаунта?',
|
noAccount: 'Нет аккаунта?',
|
||||||
haveAccount: 'Уже есть аккаунт?',
|
haveAccount: 'Уже есть аккаунт?',
|
||||||
registrationClosed: 'Регистрация закрыта',
|
registrationClosed: 'Регистрация закрыта',
|
||||||
registrationClosedHint: 'Открытая регистрация отключена. Учётную запись может завести администратор.',
|
registrationClosedHint:
|
||||||
|
'Открытая регистрация отключена. Учётную запись может завести администратор.',
|
||||||
invalidCredentials: 'Неверное имя пользователя или пароль',
|
invalidCredentials: 'Неверное имя пользователя или пароль',
|
||||||
userNameTaken: 'Это имя пользователя уже занято',
|
userNameTaken: 'Это имя пользователя уже занято',
|
||||||
blocked: 'Аккаунт заблокирован администратором',
|
blocked: 'Аккаунт заблокирован администратором',
|
||||||
@@ -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: 'Выбрать из галереи',
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import { toast } from '@/shared/ui/toast-store'
|
|||||||
export function useApiError() {
|
export function useApiError() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(error: unknown) =>
|
(error: unknown) => toast.error(error instanceof HttpError ? error.detail : t('common.error')),
|
||||||
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
|
|
||||||
[t],
|
[t],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||||
import {
|
import { ToastContext, registerToastPush, type ToastItem, type ToastVariant } from './toast-store'
|
||||||
ToastContext,
|
|
||||||
registerToastPush,
|
|
||||||
type ToastItem,
|
|
||||||
type ToastVariant,
|
|
||||||
} from './toast-store'
|
|
||||||
|
|
||||||
let nextId = 1
|
let nextId = 1
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
|||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild, ...props }, ref) => {
|
({ className, variant, size, asChild, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : 'button'
|
const Comp = asChild ? Slot : 'button'
|
||||||
return <Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />
|
return (
|
||||||
|
<Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
Button.displayName = 'Button'
|
Button.displayName = 'Button'
|
||||||
|
|||||||
@@ -1,33 +1,46 @@
|
|||||||
import { type HTMLAttributes, forwardRef } from 'react'
|
import { type HTMLAttributes, forwardRef } from 'react'
|
||||||
import { cn } from '@/shared/lib/cn'
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
|
||||||
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn('crt-panel rounded-md', className)} {...props} />
|
<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>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn('flex flex-col gap-1.5 p-6', className)} {...props} />
|
<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>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
))
|
),
|
||||||
|
)
|
||||||
CardContent.displayName = 'CardContent'
|
CardContent.displayName = 'CardContent'
|
||||||
|
|||||||
@@ -50,7 +50,11 @@ export const DialogTitle = forwardRef<
|
|||||||
ElementRef<typeof DialogPrimitive.Title>,
|
ElementRef<typeof DialogPrimitive.Title>,
|
||||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Title ref={ref} className={cn('crt-glow text-lg font-semibold', className)} {...props} />
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('crt-glow text-lg font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
@@ -58,7 +62,11 @@ export const DialogDescription = forwardRef<
|
|||||||
ElementRef<typeof DialogPrimitive.Description>,
|
ElementRef<typeof DialogPrimitive.Description>,
|
||||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ export function SortHeader({
|
|||||||
return (
|
return (
|
||||||
// aria-sort — атрибут заголовка столбца, а не кнопки внутри него: у роли button его нет,
|
// aria-sort — атрибут заголовка столбца, а не кнопки внутри него: у роли button его нет,
|
||||||
// и скринридер там его просто не прочтёт.
|
// и скринридер там его просто не прочтёт.
|
||||||
<th
|
<th className={cn('px-4 py-2 font-medium', className)} aria-sort={active ? direction : 'none'}>
|
||||||
className={cn('px-4 py-2 font-medium', className)}
|
|
||||||
aria-sort={active ? direction : 'none'}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggle(sortKey)}
|
onClick={() => onToggle(sortKey)}
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ export function Toaster() {
|
|||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[10000] flex flex-col gap-2">
|
<div className="pointer-events-none fixed bottom-4 right-4 z-[10000] flex flex-col gap-2">
|
||||||
{toasts.map((t) => (
|
{toasts.map((t) => (
|
||||||
<ToastItem key={t.id} id={t.id} message={t.message} variant={t.variant} onDismiss={dismiss} />
|
<ToastItem
|
||||||
|
key={t.id}
|
||||||
|
id={t.id}
|
||||||
|
message={t.message}
|
||||||
|
variant={t.variant}
|
||||||
|
onDismiss={dismiss}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user