289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
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>
|
||
)
|
||
}
|