Search filters for size (MB) and file extensions
Test and deploy / test (push) Successful in 1m27s
Test and deploy / deploy (push) Successful in 2m9s

- API: min_size and max_size in MB, ext as a comma-separated list. An
  extension matches as a whole word in search_text (file and entry names,
  dots as spaces), so "iso" finds .iso files but not "ISOLATION", which a
  plain search word matched as a substring. The trigram index serves it.
- Web UI: a size range and an extension field in the filter row, kept in
  the URL like the other filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-04 19:43:55 +02:00
1 parent 95f1d427df
commit e1392608d8
6 files changed
+110 -2

No files matched your search

+37
View File
@@ -238,6 +238,43 @@ kbd {
cursor: pointer;
}
/* A chip holding inputs: size bounds and file extensions. */
.field-chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 6px 0 13px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
font-size: 13px;
color: var(--muted);
}
.field-chip:focus-within {
border-color: var(--accent);
}
.field-chip input {
width: 64px;
height: 24px;
padding: 0 6px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-2);
color: var(--text);
font: inherit;
}
.field-chip input.ext {
width: 96px;
}
.field-chip .unit {
padding-right: 7px;
}
.chip[aria-pressed='true'],
.chip-active {
border-color: var(--accent);
+16 -1
View File
@@ -3,6 +3,8 @@
const $ = (selector) => document.querySelector(selector)
const FILTERS = ['readable', 'complete', 'hide_encrypted', 'par2_only']
/** Text inputs that narrow a search: size bounds in MB and file extensions. */
const FIELDS = ['min_size', 'max_size', 'ext']
const SOURCE_LABELS = {
stem: 'Filename',
subject: 'Subject',
@@ -19,6 +21,9 @@ const state = {
sort: 'newest',
source: '',
poster: '',
min_size: '',
max_size: '',
ext: '',
readable: false,
complete: false,
hide_encrypted: false,
@@ -90,6 +95,7 @@ function readUrl() {
state.sort = params.get('sort') ?? 'newest'
state.source = params.get('source') ?? ''
state.poster = params.get('poster') ?? ''
for (const k of FIELDS) state[k] = params.get(k) ?? ''
for (const f of FILTERS) state[f] = params.get(f) === '1'
state.release = params.get('release') ? Number(params.get('release')) : null
}
@@ -100,6 +106,7 @@ function writeUrl(push = false) {
if (state.sort !== 'newest') params.set('sort', state.sort)
if (state.source) params.set('source', state.source)
if (state.poster) params.set('poster', state.poster)
for (const k of FIELDS) if (state[k]) params.set(k, state[k])
for (const f of FILTERS) if (state[f]) params.set(f, '1')
if (state.release) params.set('release', String(state.release))
const url = `${location.pathname}${params.size ? `?${params}` : ''}`
@@ -110,6 +117,7 @@ function syncControls() {
$('#q').value = state.q
$('#sort').value = state.sort
$('#source').value = state.source
for (const k of FIELDS) $(`#${k}`).value = state[k]
for (const f of FILTERS) {
document.querySelector(`[data-filter="${f}"]`).setAttribute('aria-pressed', String(state[f]))
}
@@ -130,6 +138,7 @@ function searchParams(page) {
if (state.q) params.set('q', state.q)
if (state.source) params.set('source', state.source)
if (state.poster) params.set('poster', state.poster)
for (const k of FIELDS) if (state[k]) params.set(k, state[k])
for (const f of FILTERS) if (state[f]) params.set(f, '1')
return params
}
@@ -483,6 +492,12 @@ $('#q').addEventListener('input', (event) => {
state.q = event.target.value.trim()
scheduleSearch()
})
for (const k of FIELDS) {
$(`#${k}`).addEventListener('input', (event) => {
state[k] = event.target.value.trim()
scheduleSearch()
})
}
$('#sort').addEventListener('change', (event) => {
state.sort = event.target.value
writeUrl()
@@ -528,7 +543,7 @@ window.addEventListener('popstate', () => {
const previous = { ...state }
readUrl()
syncControls()
const searchChanged = ['q', 'sort', 'source', 'poster', ...FILTERS].some((k) => previous[k] !== state[k])
const searchChanged = ['q', 'sort', 'source', 'poster', ...FIELDS, ...FILTERS].some((k) => previous[k] !== state[k])
if (searchChanged) loadResults()
if (state.release && state.release !== previous.release) openRelease(state.release)
if (!state.release && previous.release) {
+11
View File
@@ -55,6 +55,17 @@
<option value="spot">Spotnet</option>
</select>
</label>
<span class="field-chip" title="Release size in MB">
<label for="min_size">Size</label>
<input type="number" id="min_size" min="0" step="any" inputmode="decimal" placeholder="min" aria-label="Minimum size in MB" />
<span aria-hidden="true">–</span>
<input type="number" id="max_size" min="0" step="any" inputmode="decimal" placeholder="max" aria-label="Maximum size in MB" />
<span class="unit">MB</span>
</span>
<span class="field-chip" title="Releases with a file or archive entry of these types, e.g. mkv, flac">
<label for="ext">Ext</label>
<input type="text" id="ext" class="ext" placeholder="mkv, flac" autocomplete="off" spellcheck="false" />
</span>
<span class="chip chip-active" id="poster-filter" hidden>
<span id="poster-filter-label"></span>
<button type="button" id="poster-clear" aria-label="Remove poster filter">×</button>