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.
build / backend (push) Successful in 1m35s
build / frontend (push) Successful in 31s
tests / backend-tests (push) Successful in 1m48s

This commit is contained in:
Leonid Pershin
2026-07-26 00:28:22 +03:00
parent d6ad8e11a4
commit 64a2dade74
27 changed files with 1634 additions and 39 deletions
@@ -1,16 +1,19 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Trash2, Upload } from 'lucide-react'
import { useRef, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import type { ImageCategory } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { deleteImage, imageUrl, listImages, uploadImage } from './api'
const CATEGORIES: ImageCategory[] = ['Library', 'ShowPoster', 'EpisodeStill', 'BumperBackground']
type ImageOrder = 'new' | 'old' | 'az' | 'za'
export type ImagePick = { id: string; url: string }
/**
@@ -35,11 +38,30 @@ export function GalleryBrowser({
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const [order, setOrder] = useState<ImageOrder>('new')
const { data: images, isLoading } = useQuery({
queryKey: ['admin', 'images', active],
queryFn: () => listImages(active),
})
const sorted = useMemo(() => {
const arr = [...(images ?? [])]
arr.sort((a, b) => {
switch (order) {
case 'old':
return a.createdAt.localeCompare(b.createdAt)
case 'az':
return (a.originalFileName ?? '').localeCompare(b.originalFileName ?? '')
case 'za':
return (b.originalFileName ?? '').localeCompare(a.originalFileName ?? '')
default:
return b.createdAt.localeCompare(a.createdAt)
}
})
return arr
}, [images, order])
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'images', active] })
const pick = (id: string) => {
@@ -87,6 +109,17 @@ export function GalleryBrowser({
<span className="text-xs text-muted-foreground">
{onSelect ? t('admin.gallery.pickHint') : t('admin.gallery.browseHint')}
</span>
<Select value={order} onValueChange={(v) => setOrder(v as ImageOrder)}>
<SelectTrigger className="ml-auto h-8 w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="new">{t('admin.gallery.sort.newest')}</SelectItem>
<SelectItem value="old">{t('admin.gallery.sort.oldest')}</SelectItem>
<SelectItem value="az">{t('admin.gallery.sort.nameAsc')}</SelectItem>
<SelectItem value="za">{t('admin.gallery.sort.nameDesc')}</SelectItem>
</SelectContent>
</Select>
<input
ref={fileInput}
type="file"
@@ -114,7 +147,7 @@ export function GalleryBrowser({
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
) : images && images.length > 0 ? (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5">
{images.map((img) => (
{sorted.map((img) => (
<div key={img.id} className="group relative">
<button
type="button"
@@ -8,17 +8,20 @@ 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 { SortHeader, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { deleteMedia, listMedia } from './api'
import { deleteMedia, getMediaStats, listMedia } from './api'
import { UploadToShowDialog } from './UploadToShowDialog'
import { useUploadStore } from './upload-store'
const PAGE_SIZE = 20
type MediaFilter = 'active' | 'all' | 'Ready' | 'Failed'
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'],
@@ -48,12 +51,25 @@ export function MediaPanel() {
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 enqueue = useUploadStore((s) => s.enqueue)
const sortColumn = (key: string) => {
setPage(1)
toggle(key)
}
const { data, isLoading } = useQuery({
queryKey: ['admin', 'media', filter, page],
queryFn: () => listMedia({ page, pageSize: PAGE_SIZE, statuses: filterStatuses[filter] }),
queryKey: ['admin', 'media', 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')
@@ -61,6 +77,14 @@ export function MediaPanel() {
: false,
})
const { data: stats } = useQuery({
queryKey: ['admin', 'media', 'stats'],
queryFn: getMediaStats,
// Пока есть незавершённая работа — освежаем чипы очереди/обработки.
refetchInterval: (query) =>
(query.state.data?.queued ?? 0) + (query.state.data?.processing ?? 0) > 0 ? 4000 : 15000,
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
@@ -84,11 +108,32 @@ export function MediaPanel() {
</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
@@ -134,17 +179,43 @@ export function MediaPanel() {
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">{t('admin.media.name')}</th>
<th className="px-4 py-2 font-medium">{t('admin.media.status')}</th>
<th className="px-4 py-2 font-medium">{t('admin.media.duration')}</th>
<th className="px-4 py-2 font-medium">{t('admin.media.resolution')}</th>
<SortHeader
label={t('admin.media.name')}
sortKey="name"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.media.status')}
sortKey="status"
sort={sort}
onToggle={sortColumn}
/>
<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={5}>
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
{t('common.loading')}
</td>
</tr>
@@ -158,7 +229,7 @@ export function MediaPanel() {
))}
{data && data.items.length === 0 && !isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
{t('admin.media.empty')}
</td>
</tr>
@@ -190,6 +261,9 @@ function MediaRow({ asset, onDelete }: { asset: MediaAssetDto; onDelete: () => v
<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')}
+9
View File
@@ -3,6 +3,7 @@ import type {
CreatedIdResponse,
MediaAssetDto,
MediaAssetStatus,
MediaStatsDto,
PagedList,
} from '@/shared/api/types'
@@ -11,6 +12,8 @@ export type ListMediaParams = {
pageSize: number
statuses?: MediaAssetStatus[]
search?: string
sort?: string
desc?: boolean
}
export function listMedia(params: ListMediaParams) {
@@ -20,9 +23,15 @@ export function listMedia(params: ListMediaParams) {
})
for (const status of params.statuses ?? []) query.append('status', status)
if (params.search) query.set('search', params.search)
if (params.sort) query.set('sort', params.sort)
if (params.desc) query.set('desc', 'true')
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`)
}
export function getMediaStats() {
return apiRequest<MediaStatsDto>('/admin/media/stats')
}
/**
* Дозагружает ВСЕ страницы медиа (для пикеров с клиентской фильтрацией/сортировкой — кандидаты серий,
* пул рекламы), чтобы ничего не терялось молча за фиксированным pageSize. Есть предохранитель `cap`:
@@ -18,6 +18,7 @@ import {
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { createRole, deleteRole, listRoles, updateRole } from './api'
@@ -27,6 +28,11 @@ export function RolesPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: roles, isLoading } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles })
const { sort, toggle } = useTableSort('name', false)
const sortedRoles = sortRows(roles ?? [], sort, {
name: (r) => r.name.toLowerCase(),
system: (r) => r.isSystem,
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'roles'] })
@@ -97,8 +103,18 @@ export function RolesPanel() {
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">{t('admin.roles.name')}</th>
<th className="px-4 py-2 font-medium">{t('admin.roles.system')}</th>
<SortHeader
label={t('admin.roles.name')}
sortKey="name"
sort={sort}
onToggle={toggle}
/>
<SortHeader
label={t('admin.roles.system')}
sortKey="system"
sort={sort}
onToggle={toggle}
/>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
@@ -110,7 +126,7 @@ export function RolesPanel() {
</td>
</tr>
)}
{roles?.map((role) => (
{sortedRoles.map((role) => (
<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">
@@ -9,6 +9,7 @@ import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { createShow, deleteShow, listShows } from './api'
@@ -22,19 +23,31 @@ export function ShowsPanel() {
const [kind, setKind] = useState<ShowKind>('Series')
const [query, setQuery] = useState('')
const [page, setPage] = useState(1)
const { sort, toggle } = useTableSort('name', false)
const sortColumn = (key: string) => {
setPage(1)
toggle(key)
}
const { data, isLoading } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
// Список шоу обычно умещается в одну загрузку — фильтруем и листаем на клиенте (пикеры берут всё).
// Список шоу обычно умещается в одну загрузку — фильтруем, сортируем и листаем на клиенте.
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
const all = data ?? []
if (!q) return all
return all.filter(
(s) =>
s.name.toLowerCase().includes(q) || (s.originalName ?? '').toLowerCase().includes(q),
)
}, [data, query])
const matched = q
? all.filter(
(s) =>
s.name.toLowerCase().includes(q) || (s.originalName ?? '').toLowerCase().includes(q),
)
: all
return sortRows(matched, sort, {
name: (s) => s.name.toLowerCase(),
kind: (s) => s.kind,
seasons: (s) => s.seasonCount,
episodes: (s) => s.episodeCount,
})
}, [data, query, sort])
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const pageItems = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
@@ -102,10 +115,30 @@ export function ShowsPanel() {
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">{t('admin.shows.name')}</th>
<th className="px-4 py-2 font-medium">{t('admin.shows.kind')}</th>
<th className="px-4 py-2 font-medium">{t('admin.shows.seasons')}</th>
<th className="px-4 py-2 font-medium">{t('admin.shows.episodes')}</th>
<SortHeader
label={t('admin.shows.name')}
sortKey="name"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.shows.kind')}
sortKey="kind"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.shows.seasons')}
sortKey="seasons"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.shows.episodes')}
sortKey="episodes"
sort={sort}
onToggle={sortColumn}
/>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
@@ -15,6 +15,7 @@ import {
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { SortHeader, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import type { UserSummaryDto } from '@/shared/api/types'
import { changeUserRole } from '@/features/admin/roles/api'
@@ -29,6 +30,11 @@ export function UsersPanel() {
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [roleId, setRoleId] = useState<string>('')
const { sort, toggle } = useTableSort('created', true)
const sortColumn = (key: string) => {
setPage(1)
toggle(key)
}
const [newUserName, setNewUserName] = useState('')
const [newPassword, setNewPassword] = useState('')
const [newRoleId, setNewRoleId] = useState('')
@@ -36,8 +42,16 @@ export function UsersPanel() {
const { data: roles } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles })
const { data, isLoading } = useQuery({
queryKey: ['admin', 'users', page, search, roleId],
queryFn: () => listUsers({ page, pageSize: PAGE_SIZE, search: search || undefined, roleId: roleId || undefined }),
queryKey: ['admin', 'users', page, search, roleId, sort.key, sort.desc],
queryFn: () =>
listUsers({
page,
pageSize: PAGE_SIZE,
search: search || undefined,
roleId: roleId || undefined,
sort: sort.key,
desc: sort.desc,
}),
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
@@ -167,10 +181,30 @@ export function UsersPanel() {
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">{t('admin.users.userName')}</th>
<th className="px-4 py-2 font-medium">{t('admin.users.role')}</th>
<th className="px-4 py-2 font-medium">{t('admin.users.status')}</th>
<th className="px-4 py-2 font-medium">{t('admin.users.createdAt')}</th>
<SortHeader
label={t('admin.users.userName')}
sortKey="username"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.users.role')}
sortKey="role"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.users.status')}
sortKey="blocked"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.users.createdAt')}
sortKey="created"
sort={sort}
onToggle={sortColumn}
/>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
+4
View File
@@ -7,6 +7,8 @@ export type ListUsersParams = {
search?: string
roleId?: string
isBlocked?: boolean
sort?: string
desc?: boolean
}
export function listUsers(params: ListUsersParams) {
@@ -17,6 +19,8 @@ export function listUsers(params: ListUsersParams) {
if (params.search) query.set('search', params.search)
if (params.roleId) query.set('roleId', params.roleId)
if (params.isBlocked !== undefined) query.set('isBlocked', String(params.isBlocked))
if (params.sort) query.set('sort', params.sort)
if (params.desc) query.set('desc', 'true')
return apiRequest<PagedList<UserSummaryDto>>(`/admin/users?${query.toString()}`)
}
+7
View File
@@ -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'
+30
View File
@@ -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: AZ',
nameDesc: 'Name: ZA',
},
categories: {
Library: 'Library',
ShowPoster: 'Show posters',
+73
View File
@@ -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
})
}