Add container filesystem folder browser to empty voice state

- /api/browse-dirs endpoint lists subdirectories at any container path
- '📁 Browse' button next to the path input toggles an inline dir browser
- Breadcrumb navigation lets users click up/down through the filesystem
- 'Use this folder' confirms the selection back into the path input
- Existing 'Set & reload' flow unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-26 13:21:21 +02:00
parent 7f734b42b6
commit 9c47b1998d
3 changed files with 99 additions and 1 deletions

View File

@ -2721,6 +2721,24 @@ def _fetch_backend_voices(settings: dict, backend: str) -> list:
return []
# ── Server-side directory browser ────────────────────────────────────────────
@app.get("/api/browse-dirs")
async def browse_dirs(path: str = "/"):
p = Path(path).resolve()
if not p.is_dir():
raise HTTPException(404, "Not a directory")
try:
entries = sorted(
[d.name for d in p.iterdir() if d.is_dir() and not d.name.startswith(".")],
key=str.lower,
)
except PermissionError:
raise HTTPException(403, "Permission denied")
parent = str(p.parent) if p.parent != p else None
return {"path": str(p), "parent": parent, "dirs": entries}
# ── ElevenLabs shared voice library proxy ────────────────────────────────────
@app.get("/api/elevenlabs/voices")

View File

@ -4135,8 +4135,17 @@ function renderVoiceList() {
<p>Set the path where your <code>.wav</code> voice files live <em>inside the container</em>.</p>
<div class="voices-folder-row">
<input id="voices-empty-scan-dir" type="text" class="voices-folder-input" placeholder="/voices">
<button class="btn-secondary" id="voices-empty-browse-btn" title="Browse container filesystem">&#128193; Browse</button>
<button class="btn-primary" id="voices-empty-save-btn">Set &amp; reload</button>
</div>
<div class="vef-dir-browser" id="vef-dir-browser" hidden>
<div class="vef-breadcrumb" id="vef-breadcrumb"></div>
<div class="vef-dir-list" id="vef-dir-list"><span class="vef-loading">Loading</span></div>
<div class="vef-browser-actions">
<span class="vef-selected-path note" id="vef-selected-path"></span>
<button class="btn-primary" id="vef-select-btn">Use this folder</button>
</div>
</div>
<p class="voices-empty-hint">Map a host folder via docker-compose:<br>
<code>- /your/host/path:/voices:rw</code><br>
or set <code>VOICE_HOST_DIR=/your/host/path</code> in the stack env.</p>
@ -4160,6 +4169,61 @@ function renderVoiceList() {
if (inp) inp.value = s.voices_scan_dir || '/voices';
}).catch(() => {});
// ── Directory browser ──────────────────────────────────────────────────
let _vefCurrentPath = '/';
const vefBrowser = document.getElementById('vef-dir-browser');
const vefDirList = document.getElementById('vef-dir-list');
const vefCrumb = document.getElementById('vef-breadcrumb');
const vefSelPath = document.getElementById('vef-selected-path');
async function vefNavigate(path) {
_vefCurrentPath = path;
vefDirList.innerHTML = '<span class="vef-loading">Loading…</span>';
if (vefSelPath) vefSelPath.textContent = path;
try {
const data = await fetch('/api/browse-dirs?path=' + encodeURIComponent(path)).then(r => r.json());
// Breadcrumb
const parts = data.path.split('/').filter(Boolean);
const crumbs = [{ label: '/', path: '/' }];
parts.forEach((p, i) => crumbs.push({ label: p, path: '/' + parts.slice(0, i + 1).join('/') }));
vefCrumb.innerHTML = crumbs.map((c, i) =>
i < crumbs.length - 1
? `<button class="vef-crumb-btn" data-path="${escHtml(c.path)}">${escHtml(c.label)}</button><span class="vef-sep">/</span>`
: `<span class="vef-crumb-cur">${escHtml(c.label)}</span>`
).join('');
vefCrumb.querySelectorAll('.vef-crumb-btn').forEach(b => b.addEventListener('click', () => vefNavigate(b.dataset.path)));
// Directory list
if (!data.dirs.length) {
vefDirList.innerHTML = '<span class="vef-empty">No subdirectories here.</span>';
} else {
vefDirList.innerHTML = data.dirs.map(d =>
`<button class="vef-dir-item" data-path="${escHtml(data.path === '/' ? '/' + d : data.path + '/' + d)}">&#128193; ${escHtml(d)}</button>`
).join('');
vefDirList.querySelectorAll('.vef-dir-item').forEach(b => b.addEventListener('click', () => vefNavigate(b.dataset.path)));
}
if (vefSelPath) vefSelPath.textContent = data.path;
_vefCurrentPath = data.path;
} catch(e) {
vefDirList.innerHTML = `<span class="vef-error">Error: ${escHtml(e.message)}</span>`;
}
}
document.getElementById('voices-empty-browse-btn')?.addEventListener('click', () => {
const open = vefBrowser.hidden;
vefBrowser.hidden = !open;
if (open) {
const cur = document.getElementById('voices-empty-scan-dir')?.value?.trim() || '/voices';
vefNavigate(cur);
}
});
document.getElementById('vef-select-btn')?.addEventListener('click', () => {
const inp = document.getElementById('voices-empty-scan-dir');
if (inp) inp.value = _vefCurrentPath;
if (vefBrowser) vefBrowser.hidden = true;
});
// ──────────────────────────────────────────────────────────────────────
document.getElementById('voices-empty-save-btn')?.addEventListener('click', async () => {
const inp = document.getElementById('voices-empty-scan-dir');
const dir = inp?.value?.trim();

View File

@ -767,6 +767,22 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
}
.voices-folder-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.12); }
.voices-empty-hint { font-size: 14.5px; color: var(--subtext); opacity: .75; line-height: 1.7; margin-top: 2px; }
/* Directory browser */
.vef-dir-browser { margin-top: 8px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); overflow: hidden; }
.vef-breadcrumb { display: flex; align-items: center; flex-wrap: wrap; gap: 0; padding: 6px 10px; background: var(--panel); border-bottom: 1px solid var(--border); font-size: 12px; }
.vef-crumb-btn { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 12px; padding: 1px 3px; font-family: monospace; }
.vef-crumb-btn:hover { text-decoration: underline; }
.vef-crumb-cur { font-family: monospace; font-size: 12px; font-weight: 700; color: var(--text); padding: 1px 3px; }
.vef-sep { color: var(--subtext); font-size: 12px; padding: 0 1px; }
.vef-dir-list { max-height: 160px; overflow-y: auto; padding: 4px 0; }
.vef-dir-item { display: flex; align-items: center; gap: 6px; width: 100%; text-align: left; padding: 5px 12px; font-size: 12px; font-family: monospace; background: none; border: none; color: var(--text); cursor: pointer; transition: background .1s; }
.vef-dir-item:hover { background: var(--panel); color: var(--accent); }
.vef-loading, .vef-empty, .vef-error { display: block; padding: 12px 12px; font-size: 12px; color: var(--subtext); }
.vef-error { color: #e53e3e; }
.vef-browser-actions { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-top: 1px solid var(--border); background: var(--panel); }
.vef-selected-path { flex: 1; font-family: monospace; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--accent); }
.vef-browser-actions .btn-primary { font-size: 12px; padding: 5px 12px; flex-shrink: 0; }
.voices-empty-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
.voices-empty-action-btn { width: 100%; }
@ -904,7 +920,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.vl-preview-lang-btn .fi { width: 22px; height: 16px; background-size: cover; background-position: center; border-radius: 2px; display: block; }
.vl-preview-input {
flex: 1; min-width: 0; display: block; box-sizing: border-box;
font-size: 13px; line-height: 1.5;
font-size: 16px; line-height: 1.5;
background: var(--bg); border: 1px solid var(--border); border-radius: 5px;
color: var(--text); padding: 5px 7px; font-family: var(--font);
resize: vertical; min-height: 42px;