feat: add filename and date-range filters to recordings list
Client-side filter bar with live filename search and from/to date pickers. Rendering is split into renderFiles() + applyFilters() so filters can be re-applied without re-fetching. Subtitle shows 'N of M shown' when a filter is active. Clear button resets all fields.
This commit is contained in:
@@ -564,6 +564,15 @@ button.chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|||||||
.player-row td{padding:0 10px 10px;background:var(--bg);border-bottom:1px solid var(--brd)}
|
.player-row td{padding:0 10px 10px;background:var(--bg);border-bottom:1px solid var(--brd)}
|
||||||
audio{width:100%;height:36px;border-radius:4px;display:block;
|
audio{width:100%;height:36px;border-radius:4px;display:block;
|
||||||
color-scheme:dark;accent-color:var(--accent)}
|
color-scheme:dark;accent-color:var(--accent)}
|
||||||
|
/* filter bar */
|
||||||
|
.filter-bar{display:flex;align-items:center;gap:10px;padding:8px 28px;
|
||||||
|
border-bottom:1px solid var(--brd);background:var(--surf);flex-wrap:wrap}
|
||||||
|
.filter-bar label{font-size:13px;color:var(--muted);white-space:nowrap}
|
||||||
|
.filter-bar input[type=text]{width:180px;background:var(--bg);border:1px solid var(--brd);
|
||||||
|
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px}
|
||||||
|
.filter-bar input[type=date]{background:var(--bg);border:1px solid var(--brd);
|
||||||
|
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px;color-scheme:dark}
|
||||||
|
.filter-bar input:focus{outline:2px solid var(--accent);outline-offset:1px}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -580,6 +589,15 @@ audio{width:100%;height:36px;border-radius:4px;display:block;
|
|||||||
aria-describedby="threshold-hint">
|
aria-describedby="threshold-hint">
|
||||||
<span id="threshold-hint" class="controls-hint">RMS 0–1 · sections above this value are marked loud</span>
|
<span id="threshold-hint" class="controls-hint">RMS 0–1 · sections above this value are marked loud</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filter-bar" role="search" aria-label="Filter recordings">
|
||||||
|
<label for="filter-name">Search:</label>
|
||||||
|
<input type="text" id="filter-name" placeholder="filename…" aria-label="Filter by filename">
|
||||||
|
<label for="filter-from">From:</label>
|
||||||
|
<input type="date" id="filter-from" aria-label="From date">
|
||||||
|
<label for="filter-to">To:</label>
|
||||||
|
<input type="date" id="filter-to" aria-label="To date">
|
||||||
|
<button id="filter-clear" aria-label="Clear all filters">✕ Clear</button>
|
||||||
|
</div>
|
||||||
<div class="wrap" id="main">
|
<div class="wrap" id="main">
|
||||||
<table aria-label="Recordings archive">
|
<table aria-label="Recordings archive">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -623,6 +641,8 @@ const recMap = new Map();
|
|||||||
// idx -> [{start,end}], populated after analysis
|
// idx -> [{start,end}], populated after analysis
|
||||||
const sectionMap = new Map();
|
const sectionMap = new Map();
|
||||||
let activePlayerIdx = null;
|
let activePlayerIdx = null;
|
||||||
|
// full file list from server, annotated with stable _idx
|
||||||
|
let allFiles = [];
|
||||||
|
|
||||||
function togglePlayer(idx, filename) {
|
function togglePlayer(idx, filename) {
|
||||||
const prow = document.getElementById('prow-'+idx);
|
const prow = document.getElementById('prow-'+idx);
|
||||||
@@ -780,10 +800,13 @@ async function deleteFile(idx, filename) {
|
|||||||
document.getElementById('row-'+idx)?.remove();
|
document.getElementById('row-'+idx)?.remove();
|
||||||
document.getElementById('prow-'+idx)?.remove();
|
document.getElementById('prow-'+idx)?.remove();
|
||||||
recMap.delete(idx);
|
recMap.delete(idx);
|
||||||
const remaining = document.querySelectorAll('tr.data-row').length;
|
allFiles = allFiles.filter(f => f._idx !== idx);
|
||||||
document.getElementById('subtitle').textContent =
|
const visible = document.querySelectorAll('tr.data-row').length;
|
||||||
`${remaining} recording${remaining!==1?'s':''} found`;
|
const total = allFiles.length;
|
||||||
if (!remaining) document.getElementById('empty').style.display = '';
|
document.getElementById('subtitle').textContent = total === visible
|
||||||
|
? `${total} recording${total!==1?'s':''} found`
|
||||||
|
: `${visible} of ${total} recording${total!==1?'s':''} shown`;
|
||||||
|
if (!visible) document.getElementById('empty').style.display = '';
|
||||||
updateStorage();
|
updateStorage();
|
||||||
} else {
|
} else {
|
||||||
const d = await r.json().catch(()=>({}));
|
const d = await r.json().catch(()=>({}));
|
||||||
@@ -807,32 +830,21 @@ async function updateStorage() {
|
|||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
function renderFiles(files) {
|
||||||
const refreshBtn = document.getElementById('refresh-btn');
|
|
||||||
refreshBtn.disabled = true;
|
|
||||||
document.getElementById('subtitle').textContent = 'Loading…';
|
|
||||||
recMap.clear();
|
|
||||||
|
|
||||||
let files;
|
|
||||||
try {
|
|
||||||
files = await (await fetch('/api/files')).json();
|
|
||||||
} catch(e) {
|
|
||||||
document.getElementById('subtitle').textContent = 'Error loading files';
|
|
||||||
refreshBtn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tbody = document.getElementById('tbody');
|
const tbody = document.getElementById('tbody');
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
|
recMap.clear();
|
||||||
|
sectionMap.clear();
|
||||||
|
|
||||||
const n = files.length;
|
const total = allFiles.length;
|
||||||
document.getElementById('subtitle').textContent =
|
const visible = files.length;
|
||||||
`${n} recording${n!==1?'s':''} found`;
|
document.getElementById('subtitle').textContent = total === visible
|
||||||
document.getElementById('empty').style.display = n ? 'none' : '';
|
? `${total} recording${total!==1?'s':''} found`
|
||||||
updateStorage();
|
: `${visible} of ${total} recording${total!==1?'s':''} shown`;
|
||||||
if (!n) { refreshBtn.disabled = false; return; }
|
document.getElementById('empty').style.display = visible ? 'none' : '';
|
||||||
|
|
||||||
files.forEach((f, i) => {
|
files.forEach(f => {
|
||||||
|
const i = f._idx;
|
||||||
const ext = f.ext;
|
const ext = f.ext;
|
||||||
const canAnalyse = ext === 'wav' || ext === 'flac';
|
const canAnalyse = ext === 'wav' || ext === 'flac';
|
||||||
const isRec = !!f.recording;
|
const isRec = !!f.recording;
|
||||||
@@ -909,10 +921,40 @@ async function load() {
|
|||||||
.addEventListener('click', () => deleteFile(i, f.name));
|
.addEventListener('click', () => deleteFile(i, f.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- register for live-status polling ----
|
|
||||||
recMap.set(i, f.name);
|
recMap.set(i, f.name);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const nameQ = document.getElementById('filter-name').value.toLowerCase().trim();
|
||||||
|
const fromD = document.getElementById('filter-from').value;
|
||||||
|
const toD = document.getElementById('filter-to').value;
|
||||||
|
const filtered = allFiles.filter(f => {
|
||||||
|
if (nameQ && !f.name.toLowerCase().includes(nameQ)) return false;
|
||||||
|
if (fromD && f.date < fromD + ' 00:00:00') return false;
|
||||||
|
if (toD && f.date > toD + ' 23:59:59') return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
renderFiles(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const refreshBtn = document.getElementById('refresh-btn');
|
||||||
|
refreshBtn.disabled = true;
|
||||||
|
document.getElementById('subtitle').textContent = 'Loading…';
|
||||||
|
|
||||||
|
let files;
|
||||||
|
try {
|
||||||
|
files = await (await fetch('/api/files')).json();
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('subtitle').textContent = 'Error loading files';
|
||||||
|
refreshBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
allFiles = files.map((f, i) => ({...f, _idx: i}));
|
||||||
|
updateStorage();
|
||||||
|
applyFilters();
|
||||||
refreshBtn.disabled = false;
|
refreshBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -933,6 +975,16 @@ async function pollStatus() {
|
|||||||
|
|
||||||
document.getElementById('refresh-btn').addEventListener('click', load);
|
document.getElementById('refresh-btn').addEventListener('click', load);
|
||||||
|
|
||||||
|
document.getElementById('filter-name').addEventListener('input', applyFilters);
|
||||||
|
document.getElementById('filter-from').addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('filter-to').addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('filter-clear').addEventListener('click', () => {
|
||||||
|
document.getElementById('filter-name').value = '';
|
||||||
|
document.getElementById('filter-from').value = '';
|
||||||
|
document.getElementById('filter-to').value = '';
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
// Seed threshold input from server config, then start
|
// Seed threshold input from server config, then start
|
||||||
fetch('/api/config').then(r => r.json()).then(cfg => {
|
fetch('/api/config').then(r => r.json()).then(cfg => {
|
||||||
if (cfg.threshold != null)
|
if (cfg.threshold != null)
|
||||||
|
|||||||
Reference in New Issue
Block a user