diff --git a/docker-compose.yml b/docker-compose.yml index 23c480c..5168b6c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,7 @@ services: - "3000:3000" volumes: - /path/to/your/media:/media:ro + - /path/to/your/data:/app/data environment: - MEDIA_ROOT=/media - PORT=3000 diff --git a/public/app.js b/public/app.js index 7a84e7c..14fc5a8 100644 --- a/public/app.js +++ b/public/app.js @@ -1166,10 +1166,12 @@ const FileBrowser = (() => { const selectAddQueue = document.getElementById('select-add-queue'); const selectCancel = document.getElementById('select-cancel'); - let currentPath = ''; - let currentItems = []; - let playingPath = ''; - let searchQuery = ''; + let currentPath = ''; + let currentItems = []; + let playingPath = ''; + let searchQuery = ''; + let searchResults = null; // non-null when a full-library search is active + let searchDebounce = null; let sortKey = localStorage.getItem('snap_sort_key') || 'name'; let sortDir = localStorage.getItem('snap_sort_dir') || 'asc'; @@ -1300,9 +1302,10 @@ const FileBrowser = (() => { function navigate(p, push = true) { if (selectMode) exitSelectMode(); if (push) history.pushState({ type: 'browse', path: p }, ''); - currentPath = p; + currentPath = p; searchEl.value = ''; - searchQuery = ''; + searchQuery = ''; + searchResults = null; load(); } @@ -1328,12 +1331,28 @@ const FileBrowser = (() => { function refresh() { render(); } + function renderSearchResults() { + if (searchResults.length === 0) { + browserEl.innerHTML = '
No results found
'; + return; + } + const isGrid = ViewToggle.get() === 'grid'; + const container = document.createElement('div'); + container.className = isGrid ? 'grid-view' : 'list-view'; + for (const item of searchResults) { + const dir = item.path.includes('/') ? item.path.split('/').slice(0, -1).join('/') : ''; + container.appendChild(isGrid ? makeGridItem(item, dir) : makeListItem(item, dir)); + } + browserEl.innerHTML = ''; + browserEl.appendChild(container); + } + function render() { - let items = currentItems; - if (searchQuery) { - const q = searchQuery.toLowerCase(); - items = items.filter(i => i.name.toLowerCase().includes(q)); + if (searchResults !== null) { + renderSearchResults(); + return; } + let items = currentItems; if (items.length === 0) { browserEl.innerHTML = '
No files found
'; return; @@ -1429,7 +1448,7 @@ const FileBrowser = (() => { } } - function makeListItem(item) { + function makeListItem(item, pathSubtitle) { const el = document.createElement('div'); el.className = 'list-item' + (item.name.startsWith('.') ? ' hidden-entry' : '') + @@ -1465,7 +1484,14 @@ const FileBrowser = (() => { meta.append(dur, sz); } - el.append(name, meta); + if (pathSubtitle !== undefined) { + const sub = document.createElement('span'); + sub.className = 'list-path'; + sub.textContent = pathSubtitle || '/'; + el.append(name, sub, meta); + } else { + el.append(name, meta); + } if (item.type === 'dir') { el.addEventListener('click', () => navigate(item.path)); @@ -1483,7 +1509,7 @@ const FileBrowser = (() => { return el; } - function makeGridItem(item) { + function makeGridItem(item, pathSubtitle) { const el = document.createElement('div'); el.className = 'grid-item' + (item.name.startsWith('.') ? ' hidden-entry' : '') + @@ -1508,7 +1534,14 @@ const FileBrowser = (() => { name.className = 'grid-name'; name.textContent = item.name; - el.append(thumb, name); + if (pathSubtitle !== undefined) { + const sub = document.createElement('span'); + sub.className = 'grid-path'; + sub.textContent = pathSubtitle || '/'; + el.append(thumb, name, sub); + } else { + el.append(thumb, name); + } if (item.type === 'dir') { el.addEventListener('click', () => navigate(item.path)); @@ -1573,8 +1606,26 @@ const FileBrowser = (() => { } searchEl.addEventListener('input', () => { - searchQuery = searchEl.value.trim(); - render(); + const q = searchEl.value.trim(); + searchQuery = q; + clearTimeout(searchDebounce); + if (q.length < 2) { + searchResults = null; + render(); + return; + } + browserEl.innerHTML = '
Searching…
'; + searchDebounce = setTimeout(async () => { + try { + const res = await fetch(`/api/search?q=${enc(q)}`); + if (!res.ok) return; + const data = await res.json(); + if (searchEl.value.trim() === q) { // ignore stale responses + searchResults = data; + render(); + } + } catch {} + }, 300); }); history.replaceState({ type: 'browse', path: '' }, ''); diff --git a/public/style.css b/public/style.css index c4c7f3c..f94954a 100644 --- a/public/style.css +++ b/public/style.css @@ -163,6 +163,7 @@ html, body { .list-item { display: flex; align-items: center; + flex-wrap: wrap; gap: 10px; padding: 8px 10px; border-radius: 6px; @@ -177,6 +178,7 @@ html, body { .list-icon { font-size: 18px; flex-shrink: 0; width: 24px; text-align: center; } .list-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .list-meta { color: var(--text-muted); font-size: 12px; flex-shrink: 0; display: flex; gap: 12px; } +.list-path { font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-basis: 100%; order: 3; padding-left: 28px; margin-top: -2px; } /* Grid view */ .grid-view { @@ -228,6 +230,16 @@ html, body { width: 100%; max-width: 130px; } +.grid-path { + font-size: 10px; + color: var(--text-muted); + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; + max-width: 130px; +} /* Empty / loading states */ .browser-empty { diff --git a/server.js b/server.js index f7d8d03..1613c98 100644 --- a/server.js +++ b/server.js @@ -282,6 +282,40 @@ app.delete('/api/playlists/:id', requireAuth, (req, res) => { res.json({ ok: true }); }); +// ── Search ──────────────────────────────────────────────────────────────────── +app.get('/api/search', requireAuth, (req, res) => { + const q = (req.query.q || '').trim().toLowerCase(); + if (q.length < 2) return res.json([]); + + const results = []; + const LIMIT = 200; + + function walk(relPath) { + if (results.length >= LIMIT) return; + let entries; + try { entries = fs.readdirSync(path.join(MEDIA_ROOT, relPath), { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (results.length >= LIMIT) return; + const rel = relPath ? path.join(relPath, entry.name) : entry.name; + if (entry.isDirectory()) { + if (!canAccess(req.user, rel)) continue; + walk(rel); + } else { + const ext = path.extname(entry.name).toLowerCase(); + if (!AUDIO_EXTS.has(ext) && !VIDEO_EXTS.has(ext)) continue; + if (!canAccess(req.user, relPath || '')) continue; + if (!entry.name.toLowerCase().includes(q)) continue; + let size = 0, mtime = 0; + try { const s = fs.statSync(path.join(MEDIA_ROOT, rel)); size = s.size; mtime = s.mtimeMs; } catch {} + results.push({ name: entry.name, type: 'file', path: rel, size, ext, mtime }); + } + } + } + + walk(''); + res.json(results); +}); + // ── Browse directory ────────────────────────────────────────────────────────── app.get('/api/browse', requireAuth, (req, res) => { const relPath = req.query.path || '';