Enhance user and media management with sorting and statistics: add sorting options to user and media listing endpoints, implement media statistics retrieval, and update frontend components for sorting and displaying media processing times. Refactor related query handlers and API types to support new features.
This commit is contained in:
@@ -59,9 +59,16 @@ export type MediaAssetDto = {
|
||||
videoCodec: string | null
|
||||
audioCodec: string | null
|
||||
errorMessage: string | null
|
||||
processingSeconds: number | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type MediaStatsDto = {
|
||||
queued: number
|
||||
processing: number
|
||||
averageProcessingSeconds: number | null
|
||||
}
|
||||
|
||||
// ── Библиотека (шоу) ───────────────────────────────────────────────────────
|
||||
export type ShowKind = 'Series' | 'Single'
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ const resources = {
|
||||
status: 'Статус',
|
||||
duration: 'Длительность',
|
||||
resolution: 'Разрешение',
|
||||
processingTime: 'Время обработки',
|
||||
empty: 'Пока нет загруженных файлов',
|
||||
statuses: {
|
||||
Pending: 'В очереди',
|
||||
@@ -161,6 +162,14 @@ const resources = {
|
||||
Ready: 'Готов',
|
||||
Failed: 'Ошибка',
|
||||
},
|
||||
stats: {
|
||||
queued: 'Сейчас в очереди',
|
||||
queuedShort: 'В очереди',
|
||||
processing: 'Сейчас в обработке',
|
||||
processingShort: 'В обработке',
|
||||
average: 'Среднее время обработки (по недавним)',
|
||||
averageShort: 'Ср. время',
|
||||
},
|
||||
},
|
||||
gallery: {
|
||||
title: 'Галерея',
|
||||
@@ -168,6 +177,12 @@ const resources = {
|
||||
empty: 'В этой категории пока нет изображений',
|
||||
pickHint: 'Выберите изображение или загрузите новое',
|
||||
browseHint: 'Все изображения приложения по категориям',
|
||||
sort: {
|
||||
newest: 'Сначала новые',
|
||||
oldest: 'Сначала старые',
|
||||
nameAsc: 'Имя: А–Я',
|
||||
nameDesc: 'Имя: Я–А',
|
||||
},
|
||||
categories: {
|
||||
Library: 'Библиотека',
|
||||
ShowPoster: 'Постеры шоу',
|
||||
@@ -516,6 +531,7 @@ const resources = {
|
||||
status: 'Status',
|
||||
duration: 'Duration',
|
||||
resolution: 'Resolution',
|
||||
processingTime: 'Processing time',
|
||||
empty: 'No uploaded files yet',
|
||||
statuses: {
|
||||
Pending: 'Queued',
|
||||
@@ -523,6 +539,14 @@ const resources = {
|
||||
Ready: 'Ready',
|
||||
Failed: 'Failed',
|
||||
},
|
||||
stats: {
|
||||
queued: 'Currently queued',
|
||||
queuedShort: 'Queued',
|
||||
processing: 'Currently processing',
|
||||
processingShort: 'Processing',
|
||||
average: 'Average processing time (recent)',
|
||||
averageShort: 'Avg time',
|
||||
},
|
||||
},
|
||||
gallery: {
|
||||
title: 'Gallery',
|
||||
@@ -530,6 +554,12 @@ const resources = {
|
||||
empty: 'No images in this category yet',
|
||||
pickHint: 'Pick an image or upload a new one',
|
||||
browseHint: 'All app images by category',
|
||||
sort: {
|
||||
newest: 'Newest first',
|
||||
oldest: 'Oldest first',
|
||||
nameAsc: 'Name: A–Z',
|
||||
nameDesc: 'Name: Z–A',
|
||||
},
|
||||
categories: {
|
||||
Library: 'Library',
|
||||
ShowPoster: 'Show posters',
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from 'react'
|
||||
import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
export type SortState = { key: string; desc: boolean }
|
||||
|
||||
/**
|
||||
* Состояние сортировки таблицы. Клик по новому столбцу — по возрастанию; повторный клик по тому же —
|
||||
* переключает направление. Для серверных списков `sort`/`desc` передаются в API (и в queryKey), для
|
||||
* клиентских — в {@link sortRows}.
|
||||
*/
|
||||
export function useTableSort(defaultKey: string, defaultDesc = false) {
|
||||
const [sort, setSort] = useState<SortState>({ key: defaultKey, desc: defaultDesc })
|
||||
const toggle = (key: string) =>
|
||||
setSort((s) => (s.key === key ? { key, desc: !s.desc } : { key, desc: false }))
|
||||
return { sort, toggle }
|
||||
}
|
||||
|
||||
/** Заголовок-кнопка столбца со стрелкой сортировки. */
|
||||
export function SortHeader({
|
||||
label,
|
||||
sortKey,
|
||||
sort,
|
||||
onToggle,
|
||||
className,
|
||||
}: {
|
||||
label: string
|
||||
sortKey: string
|
||||
sort: SortState
|
||||
onToggle: (key: string) => void
|
||||
className?: string
|
||||
}) {
|
||||
const active = sort.key === sortKey
|
||||
const Icon = !active ? ChevronsUpDown : sort.desc ? ArrowDown : ArrowUp
|
||||
return (
|
||||
<th className={cn('px-4 py-2 font-medium', className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(sortKey)}
|
||||
aria-sort={active ? (sort.desc ? 'descending' : 'ascending') : 'none'}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 hover:text-foreground',
|
||||
active && 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<Icon className={cn('h-3.5 w-3.5', !active && 'opacity-40')} />
|
||||
</button>
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
type Comparable = string | number | boolean | null | undefined
|
||||
|
||||
/** Клиентская сортировка строк по выбранному ключу (для непагинированных списков). nulls — в конец. */
|
||||
export function sortRows<T>(
|
||||
rows: T[],
|
||||
sort: SortState,
|
||||
accessors: Record<string, (row: T) => Comparable>,
|
||||
): T[] {
|
||||
const accessor = accessors[sort.key]
|
||||
if (!accessor) return rows
|
||||
const dir = sort.desc ? -1 : 1
|
||||
return [...rows].sort((a, b) => {
|
||||
const av = accessor(a)
|
||||
const bv = accessor(b)
|
||||
if (av == null && bv == null) return 0
|
||||
if (av == null) return 1
|
||||
if (bv == null) return -1
|
||||
if (typeof av === 'string' && typeof bv === 'string') return av.localeCompare(bv) * dir
|
||||
return (av < bv ? -1 : av > bv ? 1 : 0) * dir
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user