Normalize line endings to LF via .gitattributes

Репозиторий хранил фронтенд в CRLF, а часть бэкенда — вперемешку, хотя CI и Docker-сборка
работают под Linux. Прибиваем LF атрибутом `* text=auto eol=lf` и разово нормализуем дерево,
чтобы форматтеры не переписывали файлы целиком на каждом прогоне.

Коммит чисто механический: изменений содержимого нет, только концы строк.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-07-27 01:37:33 +03:00
co-authored by Claude Opus 5
parent 9d1c6d2fc3
commit 0442056367
109 changed files with 13469 additions and 13459 deletions
+288 -288
View File
@@ -1,288 +1,288 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FolderInput, ListPlus, Upload } from 'lucide-react'
import { qk } from '@/shared/api/query-keys'
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge, type BadgeProps } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { useTableSort } from '@/shared/lib/table-sort'
import { SortHeader } from '@/shared/ui/sortable'
import { deleteMedia, getMediaStats, listMedia } from './api'
import { formatDuration } from './format'
import { ManualInboxDialog } from './ManualInboxDialog'
import { UploadToShowDialog } from './UploadToShowDialog'
import { useUploadStore } from './upload-store'
const PAGE_SIZE = 20
type MediaFilter = 'active' | 'Pending' | 'Processing' | 'all' | 'Ready' | 'Failed'
const filterStatuses: Record<MediaFilter, MediaAssetStatus[]> = {
active: ['Pending', 'Processing'],
Pending: ['Pending'],
Processing: ['Processing'],
all: [],
Ready: ['Ready'],
Failed: ['Failed'],
}
const statusVariant: Record<MediaAssetStatus, BadgeProps['variant']> = {
Ready: 'default',
Processing: 'muted',
Pending: 'muted',
Failed: 'destructive',
}
export function MediaPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const fileInput = useRef<HTMLInputElement>(null)
const fileInputShow = useRef<HTMLInputElement>(null)
const [filter, setFilter] = useState<MediaFilter>('active')
const [page, setPage] = useState(1)
const { sort, toggle } = useTableSort('created', true)
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
const [manualOpen, setManualOpen] = useState(false)
const enqueue = useUploadStore((s) => s.enqueue)
const sortColumn = (key: string) => {
setPage(1)
toggle(key)
}
const { data, isLoading, refetch } = useQuery({
queryKey: qk.media.list(filter, page, sort.key, sort.desc),
queryFn: () =>
listMedia({
page,
pageSize: PAGE_SIZE,
statuses: filterStatuses[filter],
sort: sort.key,
desc: sort.desc,
}),
// Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI.
refetchInterval: (query) =>
query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending')
? 4000
: false,
})
const { data: stats } = useQuery({
queryKey: qk.media.stats,
queryFn: getMediaStats,
// Пока есть незавершённая работа — освежаем чипы очереди/обработки.
refetchInterval: (query) =>
(query.state.data?.queued ?? 0) + (query.state.data?.processing ?? 0) > 0 ? 4000 : 15000,
})
/**
* Сводка очереди опрашивается всегда, и она же служит датчиком: изменилась пара
* «в очереди / в обработке» — значит какая-то запись сменила статус, и список пора перечитать.
* Без этого список обновлялся бы, только пока активные записи видны на текущей странице: при
* фильтре «Готовые» или пустом списке опрос выключался, и новый файл из inbox не появлялся.
*/
const activity = `${stats?.queued ?? 0}:${stats?.processing ?? 0}`
const lastActivity = useRef(activity)
useEffect(() => {
if (lastActivity.current === activity) return
lastActivity.current = activity
void refetch()
}, [activity, refetch])
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.media.all })
const onError = useApiError()
const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError })
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<h2 className="crt-glow text-xl font-semibold">{t('admin.media.title')}</h2>
<Select
value={filter}
onValueChange={(v) => {
setPage(1)
setFilter(v as MediaFilter)
}}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">{t('admin.media.filterActive')}</SelectItem>
<SelectItem value="Pending">{t('admin.media.statuses.Pending')}</SelectItem>
<SelectItem value="Processing">{t('admin.media.statuses.Processing')}</SelectItem>
<SelectItem value="all">{t('admin.media.filterAll')}</SelectItem>
<SelectItem value="Ready">{t('admin.media.statuses.Ready')}</SelectItem>
<SelectItem value="Failed">{t('admin.media.statuses.Failed')}</SelectItem>
</SelectContent>
</Select>
{stats && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span title={t('admin.media.stats.queued')}>
{t('admin.media.stats.queuedShort')}:{' '}
<span className="text-foreground tabular-nums">{stats.queued}</span>
</span>
<span title={t('admin.media.stats.processing')}>
{t('admin.media.stats.processingShort')}:{' '}
<span className="text-foreground tabular-nums">{stats.processing}</span>
</span>
<span title={t('admin.media.stats.average')}>
{t('admin.media.stats.averageShort')}:{' '}
<span className="text-foreground tabular-nums">
{formatDuration(stats.averageProcessingSeconds)}
</span>
</span>
</div>
)}
</div>
<div className="flex items-center gap-3">
<input
ref={fileInput}
type="file"
accept="video/*,.mkv,.avi,.ts"
multiple
className="hidden"
onChange={(e) => {
const files = e.target.files
if (files && files.length > 0) void enqueue(Array.from(files))
e.target.value = ''
}}
/>
<input
ref={fileInputShow}
type="file"
accept="video/*,.mkv,.avi,.ts"
multiple
className="hidden"
onChange={(e) => {
const files = e.target.files
if (files && files.length > 0) setFilesForShow(Array.from(files))
e.target.value = ''
}}
/>
<Button size="sm" variant="outline" onClick={() => setManualOpen(true)}>
<FolderInput className="h-4 w-4" />
{t('admin.media.manualButton')}
</Button>
<Button size="sm" variant="outline" onClick={() => fileInputShow.current?.click()}>
<ListPlus className="h-4 w-4" />
{t('admin.media.uploadToShow')}
</Button>
<Button size="sm" onClick={() => fileInput.current?.click()}>
<Upload className="h-4 w-4" />
{t('admin.media.upload')}
</Button>
</div>
</div>
{filesForShow && (
<UploadToShowDialog files={filesForShow} onClose={() => setFilesForShow(null)} />
)}
{manualOpen && <ManualInboxDialog onClose={() => setManualOpen(false)} />}
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<SortHeader
label={t('admin.media.name')}
sortKey="name"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.media.status')}
sortKey="status"
sort={sort}
onToggle={sortColumn}
className="whitespace-nowrap"
/>
<SortHeader
label={t('admin.media.duration')}
sortKey="duration"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.media.resolution')}
sortKey="resolution"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.media.processingTime')}
sortKey="processing"
sort={sort}
onToggle={sortColumn}
/>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
{t('common.loading')}
</td>
</tr>
)}
{data?.items.map((asset) => (
<MediaRow
key={asset.id}
asset={asset}
onDelete={() => deleteMutation.mutate(asset.id)}
/>
))}
{data && data.items.length === 0 && !isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
{t('admin.media.empty')}
</td>
</tr>
)}
</tbody>
</table>
</div>
<Pager
page={page}
totalPages={data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1}
onChange={setPage}
/>
</div>
)
}
function MediaRow({ asset, onDelete }: Readonly<{ asset: MediaAssetDto; onDelete: () => void }>) {
const { t } = useTranslation()
return (
<tr className="border-b border-border last:border-0">
<td className="px-4 py-2">{asset.originalFileName}</td>
<td className="whitespace-nowrap px-4 py-2">
<Badge variant={statusVariant[asset.status]} title={asset.errorMessage ?? undefined}>
{t(`admin.media.statuses.${asset.status}`)}
</Badge>
</td>
<td className="px-4 py-2 text-muted-foreground">{formatDuration(asset.durationSeconds)}</td>
<td className="px-4 py-2 text-muted-foreground">
{asset.width && asset.height ? `${asset.width}×${asset.height}` : '—'}
</td>
<td className="px-4 py-2 text-muted-foreground tabular-nums">
{asset.status === 'Ready' ? formatDuration(asset.processingSeconds) : '—'}
</td>
<td className="px-4 py-2">
<Button size="sm" variant="destructive" onClick={onDelete}>
{t('common.delete')}
</Button>
</td>
</tr>
)
}
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FolderInput, ListPlus, Upload } from 'lucide-react'
import { qk } from '@/shared/api/query-keys'
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge, type BadgeProps } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { useTableSort } from '@/shared/lib/table-sort'
import { SortHeader } from '@/shared/ui/sortable'
import { deleteMedia, getMediaStats, listMedia } from './api'
import { formatDuration } from './format'
import { ManualInboxDialog } from './ManualInboxDialog'
import { UploadToShowDialog } from './UploadToShowDialog'
import { useUploadStore } from './upload-store'
const PAGE_SIZE = 20
type MediaFilter = 'active' | 'Pending' | 'Processing' | 'all' | 'Ready' | 'Failed'
const filterStatuses: Record<MediaFilter, MediaAssetStatus[]> = {
active: ['Pending', 'Processing'],
Pending: ['Pending'],
Processing: ['Processing'],
all: [],
Ready: ['Ready'],
Failed: ['Failed'],
}
const statusVariant: Record<MediaAssetStatus, BadgeProps['variant']> = {
Ready: 'default',
Processing: 'muted',
Pending: 'muted',
Failed: 'destructive',
}
export function MediaPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const fileInput = useRef<HTMLInputElement>(null)
const fileInputShow = useRef<HTMLInputElement>(null)
const [filter, setFilter] = useState<MediaFilter>('active')
const [page, setPage] = useState(1)
const { sort, toggle } = useTableSort('created', true)
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
const [manualOpen, setManualOpen] = useState(false)
const enqueue = useUploadStore((s) => s.enqueue)
const sortColumn = (key: string) => {
setPage(1)
toggle(key)
}
const { data, isLoading, refetch } = useQuery({
queryKey: qk.media.list(filter, page, sort.key, sort.desc),
queryFn: () =>
listMedia({
page,
pageSize: PAGE_SIZE,
statuses: filterStatuses[filter],
sort: sort.key,
desc: sort.desc,
}),
// Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI.
refetchInterval: (query) =>
query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending')
? 4000
: false,
})
const { data: stats } = useQuery({
queryKey: qk.media.stats,
queryFn: getMediaStats,
// Пока есть незавершённая работа — освежаем чипы очереди/обработки.
refetchInterval: (query) =>
(query.state.data?.queued ?? 0) + (query.state.data?.processing ?? 0) > 0 ? 4000 : 15000,
})
/**
* Сводка очереди опрашивается всегда, и она же служит датчиком: изменилась пара
* «в очереди / в обработке» — значит какая-то запись сменила статус, и список пора перечитать.
* Без этого список обновлялся бы, только пока активные записи видны на текущей странице: при
* фильтре «Готовые» или пустом списке опрос выключался, и новый файл из inbox не появлялся.
*/
const activity = `${stats?.queued ?? 0}:${stats?.processing ?? 0}`
const lastActivity = useRef(activity)
useEffect(() => {
if (lastActivity.current === activity) return
lastActivity.current = activity
void refetch()
}, [activity, refetch])
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.media.all })
const onError = useApiError()
const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError })
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<h2 className="crt-glow text-xl font-semibold">{t('admin.media.title')}</h2>
<Select
value={filter}
onValueChange={(v) => {
setPage(1)
setFilter(v as MediaFilter)
}}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">{t('admin.media.filterActive')}</SelectItem>
<SelectItem value="Pending">{t('admin.media.statuses.Pending')}</SelectItem>
<SelectItem value="Processing">{t('admin.media.statuses.Processing')}</SelectItem>
<SelectItem value="all">{t('admin.media.filterAll')}</SelectItem>
<SelectItem value="Ready">{t('admin.media.statuses.Ready')}</SelectItem>
<SelectItem value="Failed">{t('admin.media.statuses.Failed')}</SelectItem>
</SelectContent>
</Select>
{stats && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span title={t('admin.media.stats.queued')}>
{t('admin.media.stats.queuedShort')}:{' '}
<span className="text-foreground tabular-nums">{stats.queued}</span>
</span>
<span title={t('admin.media.stats.processing')}>
{t('admin.media.stats.processingShort')}:{' '}
<span className="text-foreground tabular-nums">{stats.processing}</span>
</span>
<span title={t('admin.media.stats.average')}>
{t('admin.media.stats.averageShort')}:{' '}
<span className="text-foreground tabular-nums">
{formatDuration(stats.averageProcessingSeconds)}
</span>
</span>
</div>
)}
</div>
<div className="flex items-center gap-3">
<input
ref={fileInput}
type="file"
accept="video/*,.mkv,.avi,.ts"
multiple
className="hidden"
onChange={(e) => {
const files = e.target.files
if (files && files.length > 0) void enqueue(Array.from(files))
e.target.value = ''
}}
/>
<input
ref={fileInputShow}
type="file"
accept="video/*,.mkv,.avi,.ts"
multiple
className="hidden"
onChange={(e) => {
const files = e.target.files
if (files && files.length > 0) setFilesForShow(Array.from(files))
e.target.value = ''
}}
/>
<Button size="sm" variant="outline" onClick={() => setManualOpen(true)}>
<FolderInput className="h-4 w-4" />
{t('admin.media.manualButton')}
</Button>
<Button size="sm" variant="outline" onClick={() => fileInputShow.current?.click()}>
<ListPlus className="h-4 w-4" />
{t('admin.media.uploadToShow')}
</Button>
<Button size="sm" onClick={() => fileInput.current?.click()}>
<Upload className="h-4 w-4" />
{t('admin.media.upload')}
</Button>
</div>
</div>
{filesForShow && (
<UploadToShowDialog files={filesForShow} onClose={() => setFilesForShow(null)} />
)}
{manualOpen && <ManualInboxDialog onClose={() => setManualOpen(false)} />}
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<SortHeader
label={t('admin.media.name')}
sortKey="name"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.media.status')}
sortKey="status"
sort={sort}
onToggle={sortColumn}
className="whitespace-nowrap"
/>
<SortHeader
label={t('admin.media.duration')}
sortKey="duration"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.media.resolution')}
sortKey="resolution"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.media.processingTime')}
sortKey="processing"
sort={sort}
onToggle={sortColumn}
/>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
{t('common.loading')}
</td>
</tr>
)}
{data?.items.map((asset) => (
<MediaRow
key={asset.id}
asset={asset}
onDelete={() => deleteMutation.mutate(asset.id)}
/>
))}
{data && data.items.length === 0 && !isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
{t('admin.media.empty')}
</td>
</tr>
)}
</tbody>
</table>
</div>
<Pager
page={page}
totalPages={data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1}
onChange={setPage}
/>
</div>
)
}
function MediaRow({ asset, onDelete }: Readonly<{ asset: MediaAssetDto; onDelete: () => void }>) {
const { t } = useTranslation()
return (
<tr className="border-b border-border last:border-0">
<td className="px-4 py-2">{asset.originalFileName}</td>
<td className="whitespace-nowrap px-4 py-2">
<Badge variant={statusVariant[asset.status]} title={asset.errorMessage ?? undefined}>
{t(`admin.media.statuses.${asset.status}`)}
</Badge>
</td>
<td className="px-4 py-2 text-muted-foreground">{formatDuration(asset.durationSeconds)}</td>
<td className="px-4 py-2 text-muted-foreground">
{asset.width && asset.height ? `${asset.width}×${asset.height}` : '—'}
</td>
<td className="px-4 py-2 text-muted-foreground tabular-nums">
{asset.status === 'Ready' ? formatDuration(asset.processingSeconds) : '—'}
</td>
<td className="px-4 py-2">
<Button size="sm" variant="destructive" onClick={onDelete}>
{t('common.delete')}
</Button>
</td>
</tr>
)
}