Skip to content

Commit a6af59c

Browse files
authored
feat(web): implement sorting functionality in Torznab search and search dialog (#634)
- add sorting options for search results based on size, seeds, and peers in both TorznabSearch and SearchDialog components - introduced toggle for sorting direction (ascending/descending) - update translations for sorting options in multiple languages Signed-off-by: Pavel Pikta <devops@pavelpikta.com>
1 parent fa8ad54 commit a6af59c

File tree

11 files changed

+403
-60
lines changed

11 files changed

+403
-60
lines changed

web/src/components/Add/TorznabSearch.jsx

Lines changed: 176 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,36 @@
1-
import React, { useState } from 'react'
2-
import { TextField, Button, List, ListItem, ListItemText, CircularProgress, Typography, Divider, ListItemSecondaryAction, IconButton } from '@material-ui/core'
1+
import React, { useState, useMemo } from 'react'
2+
import {
3+
TextField,
4+
Button,
5+
List,
6+
ListItem,
7+
ListItemText,
8+
CircularProgress,
9+
Typography,
10+
Divider,
11+
ListItemSecondaryAction,
12+
IconButton,
13+
Select,
14+
MenuItem,
15+
FormControl,
16+
InputLabel,
17+
useMediaQuery,
18+
} from '@material-ui/core'
319
import { useTranslation } from 'react-i18next'
420
import axios from 'axios'
521
import { torznabSearchHost } from 'utils/Hosts'
6-
import { AddCircleOutline as AddIcon } from '@material-ui/icons'
22+
import { AddCircleOutline as AddIcon, ArrowUpward, ArrowDownward } from '@material-ui/icons'
23+
import { parseSizeToBytes, formatSizeToClassicUnits } from 'utils/Utils'
724

825
export default function TorznabSearch({ onSelect }) {
926
const { t } = useTranslation()
1027
const [query, setQuery] = useState('')
1128
const [results, setResults] = useState([])
1229
const [loading, setLoading] = useState(false)
1330
const [searched, setSearched] = useState(false)
31+
const [sortField, setSortField] = useState('') // '', 'size', 'seeds', 'peers'
32+
const [sortDirection, setSortDirection] = useState('desc') // 'asc' or 'desc'
33+
const isMobile = useMediaQuery('(max-width:600px)')
1434

1535
const handleSearch = async () => {
1636
if (!query) return
@@ -27,56 +47,178 @@ export default function TorznabSearch({ onSelect }) {
2747
}
2848
}
2949

30-
const handleKeyDown = (e) => {
50+
const handleKeyDown = e => {
3151
if (e.key === 'Enter') {
3252
handleSearch()
3353
}
3454
}
3555

56+
const toggleSortDirection = () => {
57+
setSortDirection(prev => (prev === 'asc' ? 'desc' : 'asc'))
58+
}
59+
60+
const sortedResults = useMemo(() => {
61+
if (!sortField || results.length === 0) return results
62+
63+
const sorted = [...results].sort((a, b) => {
64+
let aVal
65+
let bVal
66+
67+
switch (sortField) {
68+
case 'size':
69+
aVal = parseSizeToBytes(a.Size || '0')
70+
bVal = parseSizeToBytes(b.Size || '0')
71+
break
72+
case 'seeds':
73+
aVal = a.Seed || 0
74+
bVal = b.Seed || 0
75+
break
76+
case 'peers':
77+
aVal = a.Peer || 0
78+
bVal = b.Peer || 0
79+
break
80+
default:
81+
return 0
82+
}
83+
84+
if (aVal === bVal) return 0
85+
return sortDirection === 'asc' ? (aVal < bVal ? -1 : 1) : aVal > bVal ? -1 : 1
86+
})
87+
88+
return sorted
89+
}, [results, sortField, sortDirection])
90+
3691
return (
3792
<div style={{ marginTop: '1.5em' }}>
38-
<div style={{ display: 'flex', gap: '8px' }}>
93+
<div style={{ display: 'flex', gap: '8px', flexWrap: isMobile ? 'wrap' : 'nowrap' }}>
3994
<TextField
4095
label={t('Torznab.SearchTorznab')}
4196
value={query}
42-
onChange={(e) => setQuery(e.target.value)}
97+
onChange={e => setQuery(e.target.value)}
4398
onKeyDown={handleKeyDown}
44-
variant="outlined"
45-
size="small"
99+
variant='outlined'
100+
size='small'
46101
fullWidth
47102
placeholder={t('Torznab.SearchMoviesShows')}
103+
style={{ flex: isMobile ? '1 1 100%' : '1' }}
48104
/>
49-
<Button variant="contained" color="primary" onClick={handleSearch} disabled={loading} style={{ minWidth: '80px' }}>
50-
{loading ? <CircularProgress size={24} color="inherit" /> : t('Torznab.SearchTorrents')}
105+
<Button
106+
variant='contained'
107+
color='primary'
108+
onClick={handleSearch}
109+
disabled={loading}
110+
style={{
111+
minWidth: isMobile ? '100%' : '80px',
112+
flex: isMobile ? '1 1 100%' : '0 0 auto',
113+
}}
114+
>
115+
{loading ? <CircularProgress size={24} color='inherit' /> : t('Torznab.SearchTorrents')}
51116
</Button>
52117
</div>
53118
{searched && (
54-
<div style={{ maxHeight: '200px', overflowY: 'auto', marginTop: '8px', border: '1px solid rgba(0,0,0,0.12)', borderRadius: '4px' }}>
55-
{results.length === 0 ? (
56-
<div style={{ padding: '8px', textAlign: 'center' }}>
57-
<Typography variant="body2">{loading ? t('Torznab.SearchTorrents') : t('Torznab.NoResultsFound')}</Typography>
119+
<div style={{ marginTop: '8px' }}>
120+
{results.length > 0 && (
121+
<div
122+
style={{
123+
display: 'flex',
124+
gap: isMobile ? '8px' : '4px',
125+
marginBottom: '12px',
126+
alignItems: 'center',
127+
padding: isMobile ? '12px 8px' : '8px 12px',
128+
backgroundColor: 'rgba(0, 0, 0, 0.02)',
129+
borderRadius: '4px',
130+
border: '1px solid rgba(0, 0, 0, 0.08)',
131+
flexWrap: isMobile ? 'wrap' : 'nowrap',
132+
}}
133+
>
134+
<FormControl
135+
variant='outlined'
136+
size='small'
137+
style={{
138+
minWidth: isMobile ? '100%' : 140,
139+
flexShrink: 0,
140+
flex: isMobile ? '1 1 100%' : '0 0 auto',
141+
}}
142+
>
143+
<InputLabel>{t('Torznab.SortBy')}</InputLabel>
144+
<Select value={sortField} onChange={e => setSortField(e.target.value)} label={t('Torznab.SortBy')}>
145+
<MenuItem value=''>{t('Torznab.SortByNone')}</MenuItem>
146+
<MenuItem value='size'>{t('Torznab.SortBySize')}</MenuItem>
147+
<MenuItem value='seeds'>{t('Torznab.SortBySeeds')}</MenuItem>
148+
<MenuItem value='peers'>{t('Torznab.SortByPeers')}</MenuItem>
149+
</Select>
150+
</FormControl>
151+
{sortField && (
152+
<IconButton
153+
size='small'
154+
onClick={toggleSortDirection}
155+
title={sortDirection === 'asc' ? t('Torznab.SortAscending') : t('Torznab.SortDescending')}
156+
style={{
157+
marginLeft: isMobile ? 'auto' : '4px',
158+
padding: '8px',
159+
}}
160+
>
161+
{sortDirection === 'asc' ? <ArrowUpward /> : <ArrowDownward />}
162+
</IconButton>
163+
)}
58164
</div>
59-
) : (
60-
<List dense>
61-
{results.map((item, index) => (
62-
<React.Fragment key={item.Hash || item.Link || index}>
63-
<ListItem button onClick={() => onSelect(item.Magnet || item.Link)}>
64-
<ListItemText
65-
primary={item.Title}
66-
secondary={`${item.Size} • S:${item.Seed} P:${item.Peer}`}
67-
primaryTypographyProps={{ noWrap: true, style: { fontSize: '0.9rem' } }}
68-
/>
69-
<ListItemSecondaryAction>
70-
<IconButton edge="end" aria-label="add" onClick={() => onSelect(item.Magnet || item.Link)}>
71-
<AddIcon />
72-
</IconButton>
73-
</ListItemSecondaryAction>
74-
</ListItem>
75-
<Divider />
76-
</React.Fragment>
77-
))}
78-
</List>
79165
)}
166+
<div
167+
style={{
168+
maxHeight: isMobile ? '300px' : '200px',
169+
overflowY: 'auto',
170+
border: '1px solid rgba(0,0,0,0.12)',
171+
borderRadius: '4px',
172+
}}
173+
>
174+
{results.length === 0 ? (
175+
<div style={{ padding: '8px', textAlign: 'center' }}>
176+
<Typography variant='body2'>
177+
{loading ? t('Torznab.SearchTorrents') : t('Torznab.NoResultsFound')}
178+
</Typography>
179+
</div>
180+
) : (
181+
<List dense>
182+
{sortedResults.map((item, index) => {
183+
const sizeBytes = parseSizeToBytes(item.Size || '0')
184+
const formattedSize = formatSizeToClassicUnits(sizeBytes)
185+
return (
186+
<React.Fragment key={item.Hash || item.Link || index}>
187+
<ListItem button onClick={() => onSelect(item.Magnet || item.Link)}>
188+
<ListItemText
189+
primary={item.Title}
190+
secondary={`${formattedSize} • S:${item.Seed || 0} P:${item.Peer || 0}`}
191+
primaryTypographyProps={{
192+
noWrap: !isMobile,
193+
style: {
194+
fontSize: isMobile ? '0.85rem' : '0.9rem',
195+
whiteSpace: isMobile ? 'normal' : 'nowrap',
196+
},
197+
}}
198+
secondaryTypographyProps={{
199+
style: {
200+
fontSize: isMobile ? '0.75rem' : '0.8rem',
201+
},
202+
}}
203+
/>
204+
<ListItemSecondaryAction>
205+
<IconButton
206+
edge='end'
207+
aria-label='add'
208+
onClick={() => onSelect(item.Magnet || item.Link)}
209+
size={isMobile ? 'small' : 'medium'}
210+
>
211+
<AddIcon />
212+
</IconButton>
213+
</ListItemSecondaryAction>
214+
</ListItem>
215+
<Divider />
216+
</React.Fragment>
217+
)
218+
})}
219+
</List>
220+
)}
221+
</div>
80222
</div>
81223
)}
82224
</div>

web/src/components/Add/helpers.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export const checkTorrentSource = source =>
3636
source.match(hashRegex) !== null ||
3737
source.match(magnetRegex) !== null ||
3838
source.match(torrentRegex) !== null ||
39-
source.match(linkRegex) !== null||
39+
source.match(linkRegex) !== null ||
4040
source.match(torrsRegex) !== null
4141

4242
export const parseTorrentTitle = (parsingSource, callback) => {

0 commit comments

Comments
 (0)