Redesign settings page (Voicebox-style) and fix remaining old icons

- Settings: flat section layout with consistent 32px padding, groups with
  inline title+subtitle, clean accordion details with chevron, focus rings
- Replace all remaining emoji (  💾 🌙 ☀️ ⚠ ✓ ↓ ★) with MDI icons
- s-studio.html: globe emoji → mdi-earth, star filter → mdi-star-outline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-27 02:48:34 +02:00
parent 61a48ee84a
commit f871486635
5 changed files with 249 additions and 233 deletions

View File

@ -517,7 +517,7 @@ async function clientAutoTrimBounds(fid) {
function applyTheme(t) { function applyTheme(t) {
document.documentElement.dataset.theme = t; document.documentElement.dataset.theme = t;
$('theme-btn').textContent = t === 'dark' ? '☀️' : '🌙'; $('theme-btn').innerHTML = t === 'dark' ? '<span class="mdi mdi-weather-sunny"></span>' : '<span class="mdi mdi-weather-night"></span>';
$('theme-btn').title = t === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'; $('theme-btn').title = t === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
localStorage.setItem('vcf-theme', t); localStorage.setItem('vcf-theme', t);
} }
@ -6244,7 +6244,7 @@ loadSettings().then(() => {
const key = (s.elevenlabs_api_key || '').trim(); const key = (s.elevenlabs_api_key || '').trim();
const inp = $('el-api-key'), st = $('el-key-status'); const inp = $('el-api-key'), st = $('el-key-status');
if (inp) inp.placeholder = key ? '••••••••••••••• (key saved)' : 'ElevenLabs API key (free — unlocks full library)…'; if (inp) inp.placeholder = key ? '••••••••••••••• (key saved)' : 'ElevenLabs API key (free — unlocks full library)…';
if (st) { st.textContent = key ? '✓ Key active' : 'No key — max 3 results'; st.className = 'el-key-status ' + (key ? 'el-key-ok' : 'el-key-none'); } if (st) { st.innerHTML = key ? '<span class="mdi mdi-check-circle-outline"></span> Key active' : 'No key — max 3 results'; st.className = 'el-key-status ' + (key ? 'el-key-ok' : 'el-key-none'); }
elFetch(); elFetch();
}).catch(() => elFetch()); }).catch(() => elFetch());
} }
@ -6256,7 +6256,7 @@ loadSettings().then(() => {
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ elevenlabs_api_key: val }) }) fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ elevenlabs_api_key: val }) })
.then(() => { .then(() => {
const st = $('el-key-status'); const st = $('el-key-status');
if (st) { st.textContent = '✓ Key saved'; st.className = 'el-key-status el-key-ok'; } if (st) { st.innerHTML = '<span class="mdi mdi-check-circle-outline"></span> Key saved'; st.className = 'el-key-status el-key-ok'; }
if (inp) { inp.value = ''; inp.placeholder = '••••••••••••••• (key saved)'; } if (inp) { inp.value = ''; inp.placeholder = '••••••••••••••• (key saved)'; }
_elPage = 0; elFetch(); _elPage = 0; elFetch();
}); });
@ -6306,7 +6306,7 @@ loadSettings().then(() => {
const res = await fetch('/api/elevenlabs/voices?' + elParams()); const res = await fetch('/api/elevenlabs/voices?' + elParams());
const data = await res.json(); const data = await res.json();
if (data.detail) { if (data.detail) {
if (grid) grid.innerHTML = `<div class="el-error"> ${_elEsc(data.detail?.message || JSON.stringify(data.detail))}</div>`; if (grid) grid.innerHTML = `<div class="el-error"><span class="mdi mdi-alert-outline"></span> ${_elEsc(data.detail?.message || JSON.stringify(data.detail))}</div>`;
if (sb) sb.hidden = true; if (sb) sb.hidden = true;
return; return;
} }
@ -6334,7 +6334,7 @@ loadSettings().then(() => {
if (pi) pi.textContent = `Page ${_elPage + 1}${_elTotal > 24 ? ' of ~' + Math.ceil(_elTotal / 24) : ''}`; if (pi) pi.textContent = `Page ${_elPage + 1}${_elTotal > 24 ? ' of ~' + Math.ceil(_elTotal / 24) : ''}`;
} }
} catch (e) { } catch (e) {
if (grid) grid.innerHTML = `<div class="el-error"> ${_elEsc(e.message)}</div>`; if (grid) grid.innerHTML = `<div class="el-error"><span class="mdi mdi-alert-outline"></span> ${_elEsc(e.message)}</div>`;
} }
} }
@ -6357,8 +6357,8 @@ loadSettings().then(() => {
uc ? `<span class="el-tag el-uc">${_elEsc(uc)}</span>` : '', uc ? `<span class="el-tag el-uc">${_elEsc(uc)}</span>` : '',
(v.accent && v.accent !== 'standard') ? `<span class="el-tag">${_elEsc(v.accent)}</span>` : '', (v.accent && v.accent !== 'standard') ? `<span class="el-tag">${_elEsc(v.accent)}</span>` : '',
v.free_users_allowed ? '<span class="el-tag el-free">Free</span>' : '', v.free_users_allowed ? '<span class="el-tag el-free">Free</span>' : '',
v.featured ? '<span class="el-tag el-feat"></span>' : '', v.featured ? '<span class="el-tag el-feat"><span class="mdi mdi-star"></span></span>' : '',
cl ? `<span class="el-tag el-clones"> ${cl}</span>` : '', cl ? `<span class="el-tag el-clones"><span class="mdi mdi-download-outline"></span> ${cl}</span>` : '',
].join(''); ].join('');
return `<div class="el-card-voice"> return `<div class="el-card-voice">
<div class="el-vc-av" style="--elh:${hue}">${letter}</div> <div class="el-vc-av" style="--elh:${hue}">${letter}</div>
@ -6456,16 +6456,20 @@ function renderLocalContainers(containers) {
'magpie-tts': '🐦', 'magpie-tts': '🐦',
'parakeet-rnnt-nim': '🦜', 'parakeet-rnnt-nim': '🦜',
}; };
const _t = (v) => `<span class="mdi mdi-lightning-bolt"></span>`;
const _l = (v) => `<span class="mdi mdi-clock-outline"></span>`;
const _q = (v) => `<span class="mdi mdi-star-outline"></span>`;
const _m = (v) => `<span class="mdi mdi-memory"></span>`;
const DC_METRICS = { const DC_METRICS = {
'faster-qwen3-tts-voiceclone': [['<span class="mdi mdi-lightning-bolt"></span>','~0.3× GPU'],['⏰','13 s'],['⭐','Premium clone'],['💾','68 GB VRAM']], 'faster-qwen3-tts-voiceclone': [[_t(),'~0.3× GPU'],[_l(),'13 s'],[_q(),'Premium clone'],[_m(),'68 GB VRAM']],
'faster-qwen3-tts-voicedesign': [['<span class="mdi mdi-lightning-bolt"></span>','~0.4× GPU'],['⏰','13 s'],['⭐','Premium'], ['💾','68 GB VRAM']], 'faster-qwen3-tts-voicedesign': [[_t(),'~0.4× GPU'],[_l(),'13 s'],[_q(),'Premium'], [_m(),'68 GB VRAM']],
'faster-qwen3-tts-customvoice': [['<span class="mdi mdi-lightning-bolt"></span>','~0.3× GPU'],['⏰','13 s'],['⭐','Premium'], ['💾','68 GB VRAM']], 'faster-qwen3-tts-customvoice': [[_t(),'~0.3× GPU'],[_l(),'13 s'],[_q(),'Premium'], [_m(),'68 GB VRAM']],
'faster-qwen3-tts-streaming': [['<span class="mdi mdi-lightning-bolt"></span>','~0.1× GPU'],['⏰','0.51 s'],['⭐','Premium'], ['💾','68 GB VRAM']], 'faster-qwen3-tts-streaming': [[_t(),'~0.1× GPU'],[_l(),'0.51 s'],[_q(),'Premium'], [_m(),'68 GB VRAM']],
'magpie-tts': [['<span class="mdi mdi-lightning-bolt"></span>','~0.05× GPU'],['⏰','0.30.8 s'],['⭐','High'], ['💾','46 GB VRAM']], 'magpie-tts': [[_t(),'~0.05× GPU'],[_l(),'0.30.8 s'],[_q(),'High'], [_m(),'46 GB VRAM']],
'parakeet-asr': [['<span class="mdi mdi-lightning-bolt"></span>','~200× RT GPU'],['⏰','<0.3 s'],['⭐','Parakeet-TDT'],['💾','2 GB VRAM']], 'parakeet-asr': [[_t(),'~200× RT GPU'],[_l(),'<0.3 s'],[_q(),'Parakeet-TDT'],[_m(),'2 GB VRAM']],
'parakeet-rnnt-nim': [['<span class="mdi mdi-lightning-bolt"></span>','~200× RT GPU'],['⏰','<0.3 s'],['⭐','Parakeet-1B'], ['💾','2 GB VRAM']], 'parakeet-rnnt-nim': [[_t(),'~200× RT GPU'],[_l(),'<0.3 s'],[_q(),'Parakeet-1B'], [_m(),'2 GB VRAM']],
}; };
const roleIcon = { tts: '<span class="mdi mdi-speaker-outline"></span>', stt: '<span class="mdi mdi-microphone-variant"></span>', 'stt+tts': '🔄', llm: '<span class="mdi mdi-robot-outline"></span>' }; const roleIcon = { tts: '<span class="mdi mdi-speaker-outline"></span>', stt: '<span class="mdi mdi-microphone-variant"></span>', 'stt+tts': '<span class="mdi mdi-swap-horizontal"></span>', llm: '<span class="mdi mdi-robot-outline"></span>' };
grid.innerHTML = containers.map(c => { grid.innerHTML = containers.map(c => {
const st = c.status || 'not_found'; const st = c.status || 'not_found';
@ -6605,7 +6609,7 @@ $('dc-refresh-btn')?.addEventListener('click', loadLocalContainers);
const btn = card.querySelector('.llm-local-ping'); const btn = card.querySelector('.llm-local-ping');
if (!btn) return; if (!btn) return;
if (connected) { if (connected) {
btn.textContent = '✓ Disconnect'; btn.dataset.action = 'disconnect'; btn.innerHTML = '<span class="mdi mdi-check"></span> Disconnect'; btn.dataset.action = 'disconnect';
btn.className = 'llm-local-ping ok'; btn.className = 'llm-local-ping ok';
} else { } else {
btn.textContent = 'Connect'; btn.dataset.action = 'connect'; btn.textContent = 'Connect'; btn.dataset.action = 'connect';
@ -6866,7 +6870,7 @@ function renderHistory() {
<div class="history-item-actions"> <div class="history-item-actions">
<button class="btn-secondary btn-xs hist-play-btn" ${item.blob?'':'disabled'}><span class="mdi mdi-play"></span> Play</button> <button class="btn-secondary btn-xs hist-play-btn" ${item.blob?'':'disabled'}><span class="mdi mdi-play"></span> Play</button>
<button class="btn-secondary btn-xs hist-reuse-btn"><span class="mdi mdi-restore"></span> Reuse</button> <button class="btn-secondary btn-xs hist-reuse-btn"><span class="mdi mdi-restore"></span> Reuse</button>
<button class="btn-secondary btn-xs hist-playlist-btn" ${item.blob?'':'disabled'}>&#43; Playlist</button> <button class="btn-secondary btn-xs hist-playlist-btn" ${item.blob?'':'disabled'}><span class="mdi mdi-plus"></span> Playlist</button>
</div> </div>
</div>`; </div>`;
}).join(''); }).join('');

View File

@ -8,42 +8,34 @@
<div class="tab-content" id="tab-settings"> <div class="tab-content" id="tab-settings">
<div class="card" id="settings-box"> <div class="card" id="settings-box">
<div class="settings-title-row">
<!-- Header -->
<div class="s-header">
<div> <div>
<h2>Settings</h2> <h2>Settings</h2>
<p class="card-subtitle">Configure the service URLs you actually use first. Advanced payloads, folders, and keys are tucked away below.</p> <p class="s-subtitle">Configure the service URLs you use. Advanced payloads, folders, and keys are tucked away below.</p>
</div> </div>
<div class="settings-status-pill">Local stack</div> <span class="s-stack-badge"><span class="mdi mdi-check-circle-outline"></span> Local stack</span>
</div> </div>
<div class="setup-intro settings-checklist" id="settings-first-run-note"> <!-- Core connections -->
<strong>Quick setup</strong> <div class="s-section">
<span>1. Check core TTS URLs.</span> <div class="s-section-label">Core connections</div>
<span>2. Point STT at Whisper, Parakeet, or the NVIDIA router.</span> <p class="s-section-desc">These are the endpoints you change most often. NVIDIA TTS and STT are grouped separately.</p>
<span>3. Save settings.</span>
</div>
<section class="settings-section settings-section-primary"> <div class="s-group">
<div class="settings-section-head"> <div class="s-group-head">
<div> <strong>TTS — Text to Speech</strong>
<h3>Core connections</h3>
<p>These are the endpoints you change most often. Qwen3 TTS, NVIDIA TTS, and STT are grouped separately.</p>
</div>
</div>
<div class="settings-cluster-grid">
<div class="settings-cluster">
<div class="settings-cluster-head">
<strong>TTS Text to Speech</strong>
<span>Qwen3 engines: clone, design, custom, streaming &middot; Kokoro FastAPI</span> <span>Qwen3 engines: clone, design, custom, streaming &middot; Kokoro FastAPI</span>
</div> </div>
<div class="settings-grid compact"> <div class="settings-grid compact">
<div class="s-field"> <div class="s-field">
<label>Voice Clone/Base URL <span style="font-weight:400">(WAV voices)</span></label> <label>Voice Clone / Base URL <span class="s-label-note">(WAV voices)</span></label>
<input type="text" id="s-tts-url" placeholder="http://host.docker.internal:8020"> <input type="text" id="s-tts-url" placeholder="http://host.docker.internal:8020">
<span class="s-hint">Uploaded/cloned WAV voices. Expected: <code>POST /v1/audio/speech</code>.</span> <span class="s-hint">Uploaded/cloned WAV voices. Expected: <code>POST /v1/audio/speech</code>.</span>
</div> </div>
<div class="s-field"> <div class="s-field">
<label>Voice Design URL <span style="font-weight:400">(instruction voices)</span></label> <label>Voice Design URL <span class="s-label-note">(instruction voices)</span></label>
<input type="text" id="s-voice-design-url" placeholder="http://host.docker.internal:8021"> <input type="text" id="s-voice-design-url" placeholder="http://host.docker.internal:8021">
<span class="s-hint">Prompt-designed voices and <code>vd_...</code> virtual voices.</span> <span class="s-hint">Prompt-designed voices and <code>vd_...</code> virtual voices.</span>
</div> </div>
@ -58,21 +50,21 @@
<span class="s-hint">Progressive low-latency WAV playback.</span> <span class="s-hint">Progressive low-latency WAV playback.</span>
</div> </div>
<div class="s-field"> <div class="s-field">
<label>Kokoro FastAPI URL <span style="font-weight:400">(82M &middot; CPU-friendly)</span></label> <label>Kokoro FastAPI URL <span class="s-label-note">(82M &middot; CPU-friendly)</span></label>
<input type="text" id="s-kokoro-url" placeholder="http://host.docker.internal:8880/v1"> <input type="text" id="s-kokoro-url" placeholder="http://host.docker.internal:8880/v1">
<span class="s-hint">OpenAI-compatible TTS. Built-in voices: af_bella, bf_emma, am_adam&hellip; ~300 MB RAM &middot; ~0.1&times; CPU RTF.</span> <span class="s-hint">OpenAI-compatible TTS. Built-in voices: af_bella, bf_emma, am_adam&hellip; ~300 MB RAM.</span>
</div> </div>
</div> </div>
</div> </div>
<div class="settings-cluster"> <div class="s-group">
<div class="settings-cluster-head"> <div class="s-group-head">
<strong>NVIDIA speech stack</strong> <strong>NVIDIA speech stack</strong>
<span>router, Magpie TTS, Parakeet ASR, clone NIM</span> <span>router, Magpie TTS, Parakeet ASR, clone NIM</span>
</div> </div>
<div class="settings-grid compact"> <div class="settings-grid compact">
<div class="s-field"> <div class="s-field">
<label>NVIDIA router URL <span style="font-weight:400">(TTS + STT)</span></label> <label>NVIDIA router URL <span class="s-label-note">(TTS + STT)</span></label>
<input type="text" id="s-nvidia-router-url" placeholder="http://host.docker.internal:8090"> <input type="text" id="s-nvidia-router-url" placeholder="http://host.docker.internal:8090">
<span class="s-hint">OpenAI-compatible base URL for the NVIDIA speech router.</span> <span class="s-hint">OpenAI-compatible base URL for the NVIDIA speech router.</span>
</div> </div>
@ -99,14 +91,14 @@
</div> </div>
</div> </div>
<div class="settings-cluster settings-cluster-stt"> <div class="s-group">
<div class="settings-cluster-head"> <div class="s-group-head">
<strong>STT Speech to Text</strong> <strong>STT Speech to Text</strong>
<span>Whisper, faster-whisper, whisper.cpp, Groq, Parakeet, NVIDIA router</span> <span>Whisper, faster-whisper, whisper.cpp, Groq, Parakeet, NVIDIA router</span>
</div> </div>
<div class="settings-grid compact stt-settings-grid"> <div class="settings-grid stt-settings-grid">
<div class="s-field stt-url-field"> <div class="s-field stt-url-field">
<label>Configured Whisper/STT URL</label> <label>Active STT URL</label>
<input type="text" id="s-whisper-url" placeholder="http://host.docker.internal:8010"> <input type="text" id="s-whisper-url" placeholder="http://host.docker.internal:8010">
<span class="s-hint">Default recognition endpoint. Expected: <code>POST /v1/audio/transcriptions</code>.</span> <span class="s-hint">Default recognition endpoint. Expected: <code>POST /v1/audio/transcriptions</code>.</span>
<div class="btn-row settings-mini-actions"> <div class="btn-row settings-mini-actions">
@ -117,35 +109,30 @@
</div> </div>
</div> </div>
<div class="s-field"> <div class="s-field">
<label>faster-whisper-server URL <span style="font-weight:400">(CTranslate2 GPU)</span></label> <label>faster-whisper-server URL <span class="s-label-note">(CTranslate2 GPU)</span></label>
<input type="text" id="s-faster-whisper-url" placeholder="http://host.docker.internal:8000"> <input type="text" id="s-faster-whisper-url" placeholder="http://host.docker.internal:8000">
<span class="s-hint">GPU-accelerated Whisper via CTranslate2. ~70&times; RT &middot; 1.5 GB VRAM &middot; <code>POST /v1/audio/transcriptions</code>.</span> <span class="s-hint">GPU-accelerated Whisper via CTranslate2. ~70&times; RT &middot; 1.5 GB VRAM.</span>
</div> </div>
<div class="s-field"> <div class="s-field">
<label>whisper.cpp URL <span style="font-weight:400">(CPU/CUDA)</span></label> <label>whisper.cpp URL <span class="s-label-note">(CPU/CUDA)</span></label>
<input type="text" id="s-whisper-cpp-url" placeholder="http://host.docker.internal:8080"> <input type="text" id="s-whisper-cpp-url" placeholder="http://host.docker.internal:8080">
<span class="s-hint">Lightweight C++ Whisper server. ~8&ndash;15&times; RT CPU &middot; ~1 GB RAM &middot; <code>POST /v1/audio/transcriptions</code>.</span> <span class="s-hint">Lightweight C++ Whisper server. ~8&ndash;15&times; RT CPU &middot; ~1 GB RAM.</span>
</div> </div>
<div class="s-field"> <div class="s-field">
<label>Groq API key <span style="font-weight:400">(Groq Whisper &amp; LLM)</span></label> <label>Groq API key <span class="s-label-note">(Groq Whisper &amp; LLM)</span></label>
<div class="s-key-row"> <div class="s-key-row">
<input type="password" id="s-groq-api-key" placeholder="gsk_&hellip;" autocomplete="off"> <input type="password" id="s-groq-api-key" placeholder="gsk_&hellip;" autocomplete="off">
<button type="button" class="s-eye-btn" data-target="s-groq-api-key"><span class="mdi mdi-eye-outline"></span></button> <button type="button" class="s-eye-btn" data-target="s-groq-api-key"><span class="mdi mdi-eye-outline"></span></button>
</div> </div>
<span class="s-hint">Used for Groq Whisper STT (whisper-large-v3-turbo) and Groq LLM. Free: 2 000 req/day. Fastest cloud transcription.</span> <span class="s-hint">Used for Groq Whisper STT and LLM. Free: 2 000 req/day. Fastest cloud transcription.</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section>
<section class="settings-section"> <!-- Playback behavior -->
<div class="settings-section-head"> <div class="s-section">
<div> <div class="s-section-label">Playback behavior</div>
<h3>Playback behavior</h3>
<p>Small behavior switches for previews and OpenAI-compatible TTS calls.</p>
</div>
</div>
<div class="settings-grid settings-behavior-grid"> <div class="settings-grid settings-behavior-grid">
<div class="s-field"> <div class="s-field">
<label>TTS preview playback</label> <label>TTS preview playback</label>
@ -167,16 +154,19 @@
<span class="s-hint">Controls payload shape for the normal TTS API URL.</span> <span class="s-hint">Controls payload shape for the normal TTS API URL.</span>
</div> </div>
</div> </div>
</section> </div>
<details class="settings-details"> <!-- Advanced payloads -->
<details class="s-details">
<summary> <summary>
<span>Advanced request payloads</span> <span class="mdi mdi-code-braces"></span>
<span class="s-details-title">Advanced request payloads</span>
<small>JSON extras sent to each backend</small> <small>JSON extras sent to each backend</small>
<span class="mdi mdi-chevron-down s-details-chevron"></span>
</summary> </summary>
<div class="settings-grid three settings-param-grid"> <div class="settings-grid three settings-param-grid s-details-body">
<div class="s-field"> <div class="s-field">
<label>Voice Clone/Base params</label> <label>Voice Clone / Base params</label>
<textarea id="s-tts-extra-voice-clone" spellcheck="false" placeholder='{"temperature":0.1,"top_p":0.8,"seed":0}'></textarea> <textarea id="s-tts-extra-voice-clone" spellcheck="false" placeholder='{"temperature":0.1,"top_p":0.8,"seed":0}'></textarea>
<span class="s-hint">Extra fields for the 8020 WAV voice clone/base model.</span> <span class="s-hint">Extra fields for the 8020 WAV voice clone/base model.</span>
</div> </div>
@ -198,12 +188,12 @@
<div class="s-field"> <div class="s-field">
<label>NVIDIA Magpie params</label> <label>NVIDIA Magpie params</label>
<textarea id="s-tts-extra-nvidia-magpie" spellcheck="false" placeholder="{}"></textarea> <textarea id="s-tts-extra-nvidia-magpie" spellcheck="false" placeholder="{}"></textarea>
<span class="s-hint">Usually empty. Magpie accepts fixed speaker voices such as sofia, aria, jason, leo, and john.</span> <span class="s-hint">Usually empty. Magpie accepts fixed speakers: sofia, aria, jason, leo, john.</span>
</div> </div>
<div class="s-field"> <div class="s-field">
<label>NVIDIA Zeroshot params</label> <label>NVIDIA Zeroshot params</label>
<textarea id="s-tts-extra-nvidia-zeroshot" spellcheck="false" placeholder='{"zero_shot_quality":20}'></textarea> <textarea id="s-tts-extra-nvidia-zeroshot" spellcheck="false" placeholder='{"zero_shot_quality":20}'></textarea>
<span class="s-hint">Optional multipart fields. The app supplies <code>text</code>, <code>language</code>, and <code>audio_prompt</code>.</span> <span class="s-hint">Optional multipart fields. The app supplies <code>text</code>, <code>language</code>, <code>audio_prompt</code>.</span>
</div> </div>
<div class="s-field"> <div class="s-field">
<label>NVIDIA Flow params</label> <label>NVIDIA Flow params</label>
@ -213,17 +203,20 @@
<div class="s-field"> <div class="s-field">
<label>Kokoro FastAPI params</label> <label>Kokoro FastAPI params</label>
<textarea id="s-tts-extra-kokoro" spellcheck="false" placeholder="{}"></textarea> <textarea id="s-tts-extra-kokoro" spellcheck="false" placeholder="{}"></textarea>
<span class="s-hint">Extra JSON fields for the Kokoro backend. Usually empty &mdash; voice is selected from the voice dropdown.</span> <span class="s-hint">Extra JSON fields for Kokoro. Usually empty — voice is selected from the dropdown.</span>
</div> </div>
</div> </div>
</details> </details>
<details class="settings-details"> <!-- Voice folders -->
<details class="s-details">
<summary> <summary>
<span>Voice folders</span> <span class="mdi mdi-folder-outline"></span>
<span class="s-details-title">Voice folders</span>
<small>container paths and Portainer volume mounts</small> <small>container paths and Portainer volume mounts</small>
<span class="mdi mdi-chevron-down s-details-chevron"></span>
</summary> </summary>
<div class="settings-grid"> <div class="settings-grid s-details-body">
<div class="s-field"> <div class="s-field">
<label>Voice scan directory</label> <label>Voice scan directory</label>
<input type="text" id="s-voices-scan-dir" placeholder="/voices"> <input type="text" id="s-voices-scan-dir" placeholder="/voices">
@ -237,28 +230,31 @@
</div> </div>
</details> </details>
<details class="settings-details"> <!-- API keys -->
<details class="s-details">
<summary> <summary>
<span>API keys</span> <span class="mdi mdi-key-outline"></span>
<span class="s-details-title">API keys</span>
<small>usually empty for local containers</small> <small>usually empty for local containers</small>
<span class="mdi mdi-chevron-down s-details-chevron"></span>
</summary> </summary>
<div class="settings-grid three"> <div class="settings-grid three s-details-body">
<div class="s-field"> <div class="s-field">
<label>TTS API key <span style="font-weight:400">(optional)</span></label> <label>TTS API key <span class="s-label-note">(optional)</span></label>
<div class="s-key-row"> <div class="s-key-row">
<input type="password" id="s-tts-key" placeholder="dummy, sk-local, or leave empty" autocomplete="off"> <input type="password" id="s-tts-key" placeholder="dummy, sk-local, or leave empty" autocomplete="off">
<button type="button" class="s-eye-btn" data-target="s-tts-key"><span class="mdi mdi-eye-outline"></span></button> <button type="button" class="s-eye-btn" data-target="s-tts-key"><span class="mdi mdi-eye-outline"></span></button>
</div> </div>
</div> </div>
<div class="s-field"> <div class="s-field">
<label>Voice Design API key <span style="font-weight:400">(optional)</span></label> <label>Voice Design API key <span class="s-label-note">(optional)</span></label>
<div class="s-key-row"> <div class="s-key-row">
<input type="password" id="s-vd-key" placeholder="dummy, sk-local, or leave empty" autocomplete="off"> <input type="password" id="s-vd-key" placeholder="dummy, sk-local, or leave empty" autocomplete="off">
<button type="button" class="s-eye-btn" data-target="s-vd-key"><span class="mdi mdi-eye-outline"></span></button> <button type="button" class="s-eye-btn" data-target="s-vd-key"><span class="mdi mdi-eye-outline"></span></button>
</div> </div>
</div> </div>
<div class="s-field"> <div class="s-field">
<label>Whisper API key <span style="font-weight:400">(optional)</span></label> <label>Whisper API key <span class="s-label-note">(optional)</span></label>
<div class="s-key-row"> <div class="s-key-row">
<input type="password" id="s-whisper-key" placeholder="dummy, sk-local, or leave empty" autocomplete="off"> <input type="password" id="s-whisper-key" placeholder="dummy, sk-local, or leave empty" autocomplete="off">
<button type="button" class="s-eye-btn" data-target="s-whisper-key"><span class="mdi mdi-eye-outline"></span></button> <button type="button" class="s-eye-btn" data-target="s-whisper-key"><span class="mdi mdi-eye-outline"></span></button>
@ -267,16 +263,17 @@
</div> </div>
</details> </details>
<!-- Actions -->
<div class="btn-row settings-actions"> <div class="btn-row settings-actions">
<button class="btn-primary" id="s-save-btn">Save settings</button> <button class="btn-primary" id="s-save-btn">Save settings</button>
<button class="btn-secondary" id="s-close-btn">Reload settings</button> <button class="btn-secondary" id="s-close-btn">Reload settings</button>
</div> </div>
<div class="settings-section-head" style="margin-top:28px"> <!-- Backup & restore -->
<h3>Backup &amp; restore</h3> <div class="s-section s-section-last">
<p class="s-hint" style="margin-top:4px">Export all voices and settings as a ZIP for backup or migration. Import to restore. API keys are excluded from exports.</p> <div class="s-section-label">Backup &amp; restore</div>
</div> <p class="s-hint">Export all voices and settings as a ZIP for backup or migration. Import to restore. API keys are excluded from exports.</p>
<div class="btn-row" style="margin-top:12px;gap:10px;flex-wrap:wrap"> <div class="btn-row" style="margin-top:8px;gap:10px;flex-wrap:wrap">
<a class="btn-secondary" id="s-export-voices-btn" href="/api/voices/export" download><span class="mdi mdi-download"></span> Export voices</a> <a class="btn-secondary" id="s-export-voices-btn" href="/api/voices/export" download><span class="mdi mdi-download"></span> Export voices</a>
<label class="btn-secondary" style="cursor:pointer" title="Import a voices ZIP"> <label class="btn-secondary" style="cursor:pointer" title="Import a voices ZIP">
<span class="mdi mdi-upload"></span> Import voices <span class="mdi mdi-upload"></span> Import voices
@ -285,4 +282,6 @@
<span class="note" id="s-import-status"></span> <span class="note" id="s-import-status"></span>
</div> </div>
</div> </div>
</div>
</div><!-- /tab-settings --> </div><!-- /tab-settings -->

View File

@ -1,5 +1,5 @@
<div class="section-head"> <div class="section-head">
<span class="section-icon">&#127760;</span> <span class="section-icon"><span class="mdi mdi-earth"></span></span>
<div class="section-title"> <div class="section-title">
<h2>Get Voices Online</h2> <h2>Get Voices Online</h2>
<p>Browse public voice clip sources, preview direct audio files, and import voices from the web.</p> <p>Browse public voice clip sources, preview direct audio files, and import voices from the web.</p>
@ -61,7 +61,7 @@
<!-- Category pills --> <!-- Category pills -->
<div class="el-cats" id="el-cats"> <div class="el-cats" id="el-cats">
<button class="el-cat active" data-el-use_case="" data-el-featured="" data-el-category="">All</button> <button class="el-cat active" data-el-use_case="" data-el-featured="" data-el-category="">All</button>
<button class="el-cat" data-el-featured="true">&#11088; Featured</button> <button class="el-cat" data-el-featured="true"><span class="mdi mdi-star-outline"></span> Featured</button>
<button class="el-cat" data-el-category="professional">Professional</button> <button class="el-cat" data-el-category="professional">Professional</button>
<button class="el-cat" data-el-use_case="narration">Narration</button> <button class="el-cat" data-el-use_case="narration">Narration</button>
<button class="el-cat" data-el-use_case="conversational">Conversational</button> <button class="el-cat" data-el-use_case="conversational">Conversational</button>

View File

@ -77,7 +77,7 @@
<button class="btn-primary" id="preview-btn"><span class="mdi mdi-play"></span> Generate &amp; play</button> <button class="btn-primary" id="preview-btn"><span class="mdi mdi-play"></span> Generate &amp; play</button>
<button class="btn-secondary" id="save-preview-mp3-btn" disabled><span class="mdi mdi-download"></span> Save MP3</button> <button class="btn-secondary" id="save-preview-mp3-btn" disabled><span class="mdi mdi-download"></span> Save MP3</button>
<button class="btn-secondary" id="save-preview-btn" disabled><span class="mdi mdi-download"></span> Save WAV</button> <button class="btn-secondary" id="save-preview-btn" disabled><span class="mdi mdi-download"></span> Save WAV</button>
<button class="btn-secondary" id="add-to-playlist-btn" disabled>&#43; Playlist</button> <button class="btn-secondary" id="add-to-playlist-btn" disabled><span class="mdi mdi-plus"></span> Playlist</button>
</div> </div>
<audio id="preview-audio" controls style="display:none"></audio> <audio id="preview-audio" controls style="display:none"></audio>
<div id="preview-chunk-progress" class="chunk-progress" hidden></div> <div id="preview-chunk-progress" class="chunk-progress" hidden></div>

View File

@ -318,62 +318,75 @@ audio { width: 100%; }
@media (max-width: 900px) { .preview-match-panel { grid-template-columns: 1fr; } } @media (max-width: 900px) { .preview-match-panel { grid-template-columns: 1fr; } }
/* ── Settings ───────────────────────────────────────────────────────────── */ /* ── Settings ───────────────────────────────────────────────────────────── */
#settings-box { display: flex; flex-direction: column; gap: 16px; width: 100%; max-width: none; box-sizing: border-box; } #settings-box { display:flex; flex-direction:column; gap:0; width:100%; max-width:none; box-sizing:border-box; padding:0; overflow:hidden; }
#settings-box h2 { font-size: 17px; font-weight: 700; }
.settings-section { border: 1px solid var(--border); border-radius: 6px; background: rgba(37,99,235,.03); padding: 14px; display: flex; flex-direction: column; gap: 12px; } /* Header */
.settings-section-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; } .s-header { display:flex; justify-content:space-between; align-items:flex-start; gap:20px; padding:28px 32px 24px; border-bottom:1px solid var(--border); }
.settings-section-head h3 { font-size: 13px; color: var(--text); margin: 0; text-transform: uppercase; letter-spacing: .07em; } .s-header h2 { font-size:18px; font-weight:700; margin:0 0 5px; }
.settings-section-head p { font-size: 13px; color: var(--subtext); line-height: 1.45; margin: 4px 0 0; max-width: 980px; } .s-subtitle { font-size:13px; color:var(--subtext); line-height:1.55; margin:0; max-width:580px; }
.settings-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; } .s-stack-badge { display:inline-flex; align-items:center; gap:5px; border:1px solid color-mix(in srgb,var(--green) 40%,transparent); background:rgba(22,163,74,.07); color:var(--green); border-radius:999px; padding:5px 11px; font-size:12px; font-weight:600; white-space:nowrap; flex-shrink:0; margin-top:2px; }
.settings-grid.three { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
.settings-actions { padding-top: 12px; margin-top: 2px; border-top: 1px solid var(--border); } /* Sections */
.setup-intro { background: rgba(37,99,235,.06); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; color: var(--subtext); font-size: 13px; line-height: 1.55; } .s-section { padding:26px 32px; border-bottom:1px solid var(--border); display:flex; flex-direction:column; gap:22px; }
.setup-intro strong { color: var(--text); } .s-section-last { border-bottom:none; }
.s-group { font-size: 14px; font-weight: 700; color: var(--subtext); text-transform: uppercase; letter-spacing: .09em; padding-top: 6px; border-top: 1px solid var(--border); } .s-section-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.1em; color:var(--subtext); }
.s-field { display: flex; flex-direction: column; gap: 5px; } .s-section-desc { font-size:13px; color:var(--subtext); margin:0; line-height:1.5; }
.s-field label { font-size: 13px; color: var(--subtext); font-weight: 500; }
.s-field input, .s-field select, .s-field textarea { background: var(--panel); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 9px 14px; font-size: 14px; } /* Field groups within a section */
.s-field textarea { min-height: 76px; resize: vertical; font-family: monospace; } .s-group { display:flex; flex-direction:column; gap:14px; padding-top:4px; }
.s-hint { font-size: 12px; color: var(--subtext); } .s-group + .s-group { border-top:1px solid var(--border); padding-top:22px; }
.s-key-row { display: flex; gap: 6px; } .s-group-head { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; }
.s-key-row input { flex: 1; } .s-group-head strong { font-size:14px; font-weight:600; color:var(--text); }
.s-eye-btn { background: var(--panel); border: 1px solid var(--border); color: var(--subtext); border-radius: var(--radius); padding: 0 13px; font-size: 15px; cursor: pointer; flex-shrink: 0; } .s-group-head span { font-size:12px; color:var(--subtext); }
.s-eye-btn:hover { color: var(--text); }
.settings-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:16px; } /* Field grids */
.settings-title-row h2 { margin:0; } .settings-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(280px,1fr)); gap:16px; }
.settings-title-row .note { margin-top:4px; max-width:760px; } .settings-grid.three { grid-template-columns:repeat(auto-fit, minmax(240px,1fr)); }
.settings-status-pill { border:1px solid var(--border); background:rgba(22,163,74,.10); color:var(--green); border-radius:999px; padding:5px 10px; font-size:12px; font-weight:700; white-space:nowrap; } .settings-grid.compact { grid-template-columns:repeat(2, minmax(220px,1fr)); gap:14px; }
.settings-checklist { display:flex; flex-wrap:wrap; align-items:center; gap:8px 14px; padding:10px 12px; } .settings-behavior-grid { grid-template-columns:minmax(220px,.6fr) minmax(220px,.6fr); gap:16px; align-items:start; }
.settings-checklist strong { margin-right:2px; }
.settings-checklist span { display:inline-flex; align-items:center; min-height:24px; padding:2px 9px; border:1px solid var(--border); border-radius:999px; background:var(--panel); color:var(--subtext); } /* Individual field */
.settings-section { padding:12px; gap:10px; } .s-field { display:flex; flex-direction:column; gap:6px; }
.settings-section-primary { background:rgba(37,99,235,.04); } .s-field label { font-size:13px; color:var(--text); font-weight:500; }
.settings-section-head h3 { font-size:12px; } .s-label-note { font-weight:400; color:var(--subtext); }
.settings-cluster-grid { display:grid; grid-template-columns: repeat(2, minmax(320px,1fr)); gap:12px; } .s-field input, .s-field select, .s-field textarea { background:var(--panel); border:1px solid var(--border); color:var(--text); border-radius:var(--radius); padding:9px 12px; font-size:14px; transition:border-color .15s,box-shadow .15s; width:100%; box-sizing:border-box; }
.settings-cluster { border:1px solid var(--border); border-radius:7px; background:var(--surface); padding:12px; display:flex; flex-direction:column; gap:10px; min-width:0; } .s-field input:focus, .s-field select:focus, .s-field textarea:focus { border-color:var(--accent); outline:none; box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 12%,transparent); }
.settings-cluster-head { display:flex; justify-content:space-between; gap:10px; align-items:baseline; border-bottom:1px solid var(--border); padding-bottom:8px; } .s-field textarea { min-height:76px; resize:vertical; font-family:monospace; }
.settings-cluster-head strong { color:var(--text); font-size:13px; text-transform:uppercase; letter-spacing:.07em; } .s-hint { font-size:12px; color:var(--subtext); line-height:1.5; }
.settings-cluster-head span { color:var(--subtext); font-size:12px; text-align:right; } .s-key-row { display:flex; gap:6px; }
.settings-grid.compact { grid-template-columns: repeat(2, minmax(220px,1fr)); gap:10px; } .s-key-row input { flex:1; }
.settings-behavior-grid { grid-template-columns: minmax(220px,.7fr) minmax(220px,.7fr) minmax(340px,1.1fr); align-items:start; } .s-eye-btn { background:var(--panel); border:1px solid var(--border); color:var(--subtext); border-radius:var(--radius); padding:0 13px; font-size:15px; cursor:pointer; flex-shrink:0; transition:color .15s; }
.settings-mini-actions { margin-top:2px; gap:8px; } .s-eye-btn:hover { color:var(--text); }
.settings-mini-actions button { min-height:34px; padding:7px 12px; }
.settings-details { border:1px solid var(--border); border-radius:7px; background:rgba(37,99,235,.025); overflow:hidden; } /* STT layout */
.settings-details summary { list-style:none; cursor:pointer; padding:12px 14px; display:flex; align-items:center; justify-content:space-between; gap:12px; color:var(--text); font-weight:800; text-transform:uppercase; letter-spacing:.07em; font-size:12px; } .stt-settings-grid { grid-template-columns:minmax(320px,.65fr) minmax(240px,1fr); }
.settings-details summary::-webkit-details-marker { display:none; } .settings-mini-actions { margin-top:4px; gap:8px; flex-wrap:wrap; }
.settings-details summary::before { content:'+'; width:20px; height:20px; border-radius:50%; display:inline-flex; align-items:center; justify-content:center; border:1px solid var(--border); color:var(--accent); margin-right:2px; flex:0 0 auto; } .settings-mini-actions button { min-height:32px; padding:6px 11px; font-size:12px; }
.settings-details[open] summary::before { content:'-'; }
.settings-details summary span { display:flex; align-items:center; gap:8px; margin-right:auto; } /* Expandable details */
.settings-details summary small { color:var(--subtext); font-weight:500; text-transform:none; letter-spacing:0; font-size:12px; } .s-details { border-bottom:1px solid var(--border); }
.settings-details > .settings-grid { padding:0 14px 14px; } .s-details summary { list-style:none; cursor:pointer; padding:16px 32px; display:flex; align-items:center; gap:10px; color:var(--text); font-size:14px; font-weight:600; user-select:none; transition:background .15s; }
.s-details summary:hover { background:rgba(0,0,0,.02); }
.s-details summary::-webkit-details-marker { display:none; }
.s-details-title { flex:1; }
.s-details summary small { color:var(--subtext); font-weight:400; font-size:13px; }
.s-details-chevron { color:var(--subtext); font-size:18px; transition:transform .2s; flex-shrink:0; }
.s-details[open] .s-details-chevron { transform:rotate(180deg); }
.s-details-body { padding:4px 32px 24px; }
.settings-param-grid textarea { min-height:58px; } .settings-param-grid textarea { min-height:58px; }
.settings-actions { display:flex; gap:10px; align-items:center; padding:12px 0 0; }
/* Actions row */
.settings-actions { display:flex; gap:10px; align-items:center; padding:20px 32px; }
.settings-actions button { min-width:128px; } .settings-actions button { min-width:128px; }
.settings-cluster-stt { grid-column: 1 / -1; }
.stt-settings-grid { grid-template-columns: minmax(320px, .65fr) minmax(240px, 1fr); } /* Responsive */
.stt-settings-grid .stt-url-field { max-width: 720px; } @media (max-width:1100px) { .settings-grid.compact { grid-template-columns:1fr; } .settings-behavior-grid { grid-template-columns:1fr; } }
@media (max-width: 1100px) { .settings-cluster-grid { grid-template-columns:1fr; } .settings-behavior-grid { grid-template-columns:1fr; } } @media (max-width:900px) {
@media (max-width: 900px) { .stt-settings-grid { grid-template-columns: 1fr; } .stt-settings-grid .stt-url-field { max-width: none; } } .stt-settings-grid { grid-template-columns:1fr; }
.s-header, .s-section, .settings-actions { padding-left:18px; padding-right:18px; }
.s-details summary { padding-left:18px; padding-right:18px; }
.s-details-body { padding-left:18px; padding-right:18px; }
}
/* ── Toast ──────────────────────────────────────────────────────────────── */ /* ── Toast ──────────────────────────────────────────────────────────────── */
#toast { position: fixed; bottom: 24px; right: 24px; background: var(--surface); border: 1px solid var(--border); color: var(--text); padding: 14px 20px; border-radius: var(--radius); font-size: 14px; opacity: 0; transition: opacity .3s; pointer-events: none; z-index: 200; max-width: 380px; box-shadow: var(--shadow); } #toast { position: fixed; bottom: 24px; right: 24px; background: var(--surface); border: 1px solid var(--border); color: var(--text); padding: 14px 20px; border-radius: var(--radius); font-size: 14px; opacity: 0; transition: opacity .3s; pointer-events: none; z-index: 200; max-width: 380px; box-shadow: var(--shadow); }