Add ElevenLabs Voice Library browser to Get Voices Online
- Backend proxy at /api/elevenlabs/voices forwarding to ElevenLabs shared-voices API - elevenlabs_api_key added to settings (free key unlocks 12 000+ voices; 3 without key) - Category pills: All, Featured, Professional, Narration, Conversational, News, Characters, Meditation, Gaming, Training - Language filter with most common European languages + Arabic/Hindi/ZH/JA/KO - Gender and age filters, debounced search, pagination (prev/next) - Voice cards: colored avatar, name, language/gender/age/use-case tags, description, ▶ Play and ↓ Clone buttons - Play button streams preview MP3 directly; Clone imports the audio into the Clone a Voice flow Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
79aaed4b7d
commit
ff783a8ab9
25
server.py
25
server.py
@ -229,7 +229,7 @@ _SETTINGS_KEYS = {
|
||||
"output_dir", "voices_scan_dir", "voice_design_url", "customvoice_url",
|
||||
"nvidia_router_url", "nvidia_tts_url", "nvidia_asr_url", "nvidia_clone_url",
|
||||
"nvidia_zeroshot_url", "nvidia_flow_url",
|
||||
"whisper_api_key", "tts_api_key", "voice_design_api_key",
|
||||
"whisper_api_key", "tts_api_key", "voice_design_api_key", "elevenlabs_api_key",
|
||||
"tts_stability_enabled", "tts_extra_params", "tts_extra_params_by_backend",
|
||||
}
|
||||
|
||||
@ -2721,6 +2721,29 @@ def _fetch_backend_voices(settings: dict, backend: str) -> list:
|
||||
return []
|
||||
|
||||
|
||||
# ── ElevenLabs shared voice library proxy ────────────────────────────────────
|
||||
|
||||
@app.get("/api/elevenlabs/voices")
|
||||
async def elevenlabs_shared_voices(request: Request):
|
||||
settings = _load_settings()
|
||||
api_key = (settings.get("elevenlabs_api_key") or "").strip()
|
||||
_allowed = {"page_size", "page", "language", "gender", "age", "accent",
|
||||
"use_case", "category", "search", "featured"}
|
||||
params: dict = {k: v for k, v in request.query_params.items() if k in _allowed}
|
||||
params.setdefault("page_size", "24")
|
||||
headers = {"xi-api-key": api_key} if api_key else {}
|
||||
try:
|
||||
r = requests.get(
|
||||
"https://api.elevenlabs.io/v1/shared-voices",
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
return r.json()
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise HTTPException(502, f"ElevenLabs API error: {exc}")
|
||||
|
||||
|
||||
@app.get("/api/tts-voices")
|
||||
async def tts_voices(backend: str = "voice_clone"):
|
||||
return _fetch_backend_voices(_load_settings(), backend)
|
||||
|
||||
184
static/app.js
184
static/app.js
@ -5906,4 +5906,188 @@ loadSettings().then(() => {
|
||||
renderIntegrationSnippets();
|
||||
if (!localStorage.getItem(SETTINGS_SEEN_KEY)) openSettings(true);
|
||||
}).catch(e => status('Settings load failed: ' + e.message));
|
||||
|
||||
// ── ElevenLabs Voice Library Browser ──────────────────────────────────────
|
||||
|
||||
(function initElevenLabsBrowser() {
|
||||
if (!$('el-browser-card')) return;
|
||||
|
||||
let _elPage = 0, _elHasMore = false, _elTotal = 0;
|
||||
let _elAudio = null, _elAudioBtn = null;
|
||||
let _elFilters = {};
|
||||
let _elSearchTimer;
|
||||
|
||||
const _elHue = str => {
|
||||
let h = 0;
|
||||
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) & 0xffffffff;
|
||||
return Math.abs(h) % 360;
|
||||
};
|
||||
const _elEsc = s => String(s || '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
// Load key state from settings
|
||||
function elLoadKey() {
|
||||
fetch('/api/settings').then(r => r.json()).then(s => {
|
||||
const key = (s.elevenlabs_api_key || '').trim();
|
||||
const inp = $('el-api-key'), st = $('el-key-status');
|
||||
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'); }
|
||||
elFetch();
|
||||
}).catch(() => elFetch());
|
||||
}
|
||||
|
||||
$('el-key-save').addEventListener('click', () => {
|
||||
const inp = $('el-api-key');
|
||||
const val = (inp?.value || '').trim();
|
||||
if (!val || val.startsWith('•')) { _elPage = 0; elFetch(); return; }
|
||||
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ elevenlabs_api_key: val }) })
|
||||
.then(() => {
|
||||
const st = $('el-key-status');
|
||||
if (st) { st.textContent = '✓ Key saved'; st.className = 'el-key-status el-key-ok'; }
|
||||
if (inp) { inp.value = ''; inp.placeholder = '••••••••••••••• (key saved)'; }
|
||||
_elPage = 0; elFetch();
|
||||
});
|
||||
});
|
||||
$('el-api-key')?.addEventListener('keydown', e => { if (e.key === 'Enter') $('el-key-save')?.click(); });
|
||||
|
||||
$('el-cats')?.querySelectorAll('.el-cat').forEach(pill => {
|
||||
pill.addEventListener('click', () => {
|
||||
$('el-cats').querySelectorAll('.el-cat').forEach(p => p.classList.remove('active'));
|
||||
pill.classList.add('active');
|
||||
_elFilters = {};
|
||||
for (const attr of pill.getAttributeNames()) {
|
||||
if (attr.startsWith('data-el-') && pill.getAttribute(attr))
|
||||
_elFilters[attr.slice('data-el-'.length)] = pill.getAttribute(attr);
|
||||
}
|
||||
_elPage = 0; elFetch();
|
||||
});
|
||||
});
|
||||
|
||||
['el-lang', 'el-gender', 'el-age'].forEach(id => {
|
||||
$(id)?.addEventListener('change', () => { _elPage = 0; elFetch(); });
|
||||
});
|
||||
$('el-search')?.addEventListener('input', () => {
|
||||
clearTimeout(_elSearchTimer);
|
||||
_elSearchTimer = setTimeout(() => { _elPage = 0; elFetch(); }, 420);
|
||||
});
|
||||
$('el-fetch')?.addEventListener('click', () => { _elPage = 0; elFetch(); });
|
||||
$('el-prev')?.addEventListener('click', () => { if (_elPage > 0) { _elPage--; elFetch(); } });
|
||||
$('el-next')?.addEventListener('click', () => { if (_elHasMore) { _elPage++; elFetch(); } });
|
||||
|
||||
function elParams() {
|
||||
const p = new URLSearchParams({ page_size: '24', page: String(_elPage) });
|
||||
const lang = $('el-lang')?.value, gender = $('el-gender')?.value, age = $('el-age')?.value;
|
||||
const search = ($('el-search')?.value || '').trim();
|
||||
if (lang) p.set('language', lang);
|
||||
if (gender) p.set('gender', gender);
|
||||
if (age) p.set('age', age);
|
||||
if (search) p.set('search', search);
|
||||
Object.entries(_elFilters).forEach(([k, v]) => { if (v) p.set(k, v); });
|
||||
return p.toString();
|
||||
}
|
||||
|
||||
async function elFetch() {
|
||||
const grid = $('el-grid'), sb = $('el-status-bar'), st = $('el-status-text');
|
||||
if (grid) grid.innerHTML = '<div class="el-loading"><span class="el-spinner"></span> Loading voices…</div>';
|
||||
try {
|
||||
const res = await fetch('/api/elevenlabs/voices?' + elParams());
|
||||
const data = await res.json();
|
||||
if (data.detail) {
|
||||
if (grid) grid.innerHTML = `<div class="el-error">⚠ ${_elEsc(data.detail?.message || JSON.stringify(data.detail))}</div>`;
|
||||
if (sb) sb.hidden = true;
|
||||
return;
|
||||
}
|
||||
_elHasMore = !!data.has_more;
|
||||
_elTotal = data.total_count || 0;
|
||||
const voices = data.voices || [];
|
||||
if (st) {
|
||||
const cap = voices.length < 24 && _elTotal > 3 ? ' · \u{1F511} Add your free API key for full access' : '';
|
||||
st.innerHTML = `<strong>${_elTotal.toLocaleString()}</strong> voices · showing ${voices.length} · page ${_elPage + 1}${cap}`;
|
||||
}
|
||||
if (sb) sb.hidden = false;
|
||||
if (!voices.length) {
|
||||
if (grid) grid.innerHTML = '<div class="el-empty">No voices found — try different filters.</div>';
|
||||
} else {
|
||||
if (grid) { grid.innerHTML = voices.map(elCard).join(''); }
|
||||
grid?.querySelectorAll('.el-play').forEach(b => b.addEventListener('click', () => elPlay(b)));
|
||||
grid?.querySelectorAll('.el-clone').forEach(b => b.addEventListener('click', () => elImport(b)));
|
||||
}
|
||||
const pager = $('el-pager');
|
||||
if (pager) {
|
||||
pager.hidden = _elPage === 0 && !_elHasMore;
|
||||
const pb = $('el-prev'), nb = $('el-next'), pi = $('el-pager-info');
|
||||
if (pb) pb.disabled = _elPage === 0;
|
||||
if (nb) nb.disabled = !_elHasMore;
|
||||
if (pi) pi.textContent = `Page ${_elPage + 1}${_elTotal > 24 ? ' of ~' + Math.ceil(_elTotal / 24) : ''}`;
|
||||
}
|
||||
} catch (e) {
|
||||
if (grid) grid.innerHTML = `<div class="el-error">⚠ ${_elEsc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function elCard(v) {
|
||||
const hue = _elHue(v.voice_id || v.name || '');
|
||||
const letter = _elEsc((v.name || '?')[0].toUpperCase());
|
||||
const lang = (v.language || '').toUpperCase();
|
||||
const g = v.gender === 'female' ? 'F' : v.gender === 'male' ? 'M' : (v.gender || '');
|
||||
const age = (v.age || '').replace(/_/g, ' ');
|
||||
const uc = (v.use_case || '').replace(/_/g, ' ');
|
||||
const clones = v.cloned_by_count || 0;
|
||||
const cl = clones >= 1000 ? (clones / 1000).toFixed(1) + 'k' : clones > 0 ? String(clones) : '';
|
||||
const desc = (v.description || '').slice(0, 88) + ((v.description || '').length > 88 ? '…' : '');
|
||||
const prev = _elEsc(v.preview_url || '');
|
||||
const nm = _elEsc(v.name || '');
|
||||
const tags = [
|
||||
lang ? `<span class="el-tag el-lang">${lang}</span>` : '',
|
||||
g ? `<span class="el-tag el-tag-g">${g}</span>` : '',
|
||||
age ? `<span class="el-tag">${_elEsc(age)}</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.free_users_allowed ? '<span class="el-tag el-free">Free</span>' : '',
|
||||
v.featured ? '<span class="el-tag el-feat">★</span>' : '',
|
||||
cl ? `<span class="el-tag el-clones">↓ ${cl}</span>` : '',
|
||||
].join('');
|
||||
return `<div class="el-card-voice">
|
||||
<div class="el-vc-av" style="--elh:${hue}">${letter}</div>
|
||||
<div class="el-vc-body">
|
||||
<div class="el-vc-name">${nm}</div>
|
||||
<div class="el-vc-tags">${tags}</div>
|
||||
${desc ? `<div class="el-vc-desc">${_elEsc(desc)}</div>` : ''}
|
||||
</div>
|
||||
<div class="el-vc-btns">
|
||||
<button class="el-play" title="Play preview" data-preview="${prev}">▶</button>
|
||||
${prev ? `<button class="el-clone" title="Import to Clone a Voice" data-preview="${prev}" data-name="${nm}">↓ Clone</button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function elPlay(btn) {
|
||||
const url = btn.dataset.preview;
|
||||
if (!url) return;
|
||||
if (_elAudio && _elAudioBtn === btn) {
|
||||
_elAudio.paused ? _elAudio.play() : _elAudio.pause();
|
||||
btn.textContent = _elAudio.paused ? '▶' : '⏸';
|
||||
return;
|
||||
}
|
||||
if (_elAudio) { _elAudio.pause(); if (_elAudioBtn) _elAudioBtn.textContent = '▶'; }
|
||||
_elAudio = new Audio(url);
|
||||
_elAudioBtn = btn;
|
||||
btn.textContent = '⏸';
|
||||
_elAudio.play().catch(() => { btn.textContent = '▶'; _elAudio = null; _elAudioBtn = null; });
|
||||
_elAudio.addEventListener('ended', () => { btn.textContent = '▶'; _elAudio = null; _elAudioBtn = null; });
|
||||
}
|
||||
|
||||
function elImport(btn) {
|
||||
const url = btn.dataset.preview;
|
||||
if (!url) return;
|
||||
if (typeof navTo === 'function') navTo('s-clone');
|
||||
setTimeout(() => {
|
||||
const inp = $('lib-add-url'), btn2 = $('lib-add-url-btn');
|
||||
if (inp) { inp.value = url; inp.dispatchEvent(new Event('input')); }
|
||||
if (btn2) btn2.click();
|
||||
}, 120);
|
||||
}
|
||||
|
||||
elLoadKey();
|
||||
})();
|
||||
loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice library load failed: ' + e.message));
|
||||
|
||||
@ -34,3 +34,106 @@
|
||||
<div class="card"><p class="note">Click <strong>Scrape sources</strong> to fetch Aiartes VoiceAI clips, yaph/tts-samples MP3 files, and the jim-schwoebel voice dataset index.</p></div>
|
||||
</div>
|
||||
</div><!-- /tab-getvoices -->
|
||||
|
||||
<!-- ElevenLabs Voice Library Browser -->
|
||||
<div class="card el-browser" id="el-browser-card">
|
||||
|
||||
<!-- Header: brand + API key -->
|
||||
<div class="el-head">
|
||||
<div class="el-brand">
|
||||
<span class="el-brand-logo">11</span>
|
||||
<div>
|
||||
<h2>ElevenLabs Voice Library</h2>
|
||||
<p class="note">Browse 12 000+ shared voices and import previews directly into Clone. <a href="https://elevenlabs.io/voice-library" target="_blank" rel="noopener">elevenlabs.io ↗</a></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="el-key-row">
|
||||
<div class="el-key-field">
|
||||
<input type="password" id="el-api-key" placeholder="ElevenLabs API key (free — unlocks full library)…" autocomplete="new-password">
|
||||
<button class="btn-primary el-key-btn" id="el-key-save">Save key</button>
|
||||
</div>
|
||||
<span class="el-key-status" id="el-key-status">No key — max 3 results</span>
|
||||
<a class="note" href="https://elevenlabs.io/api" target="_blank" rel="noopener">Get free key ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category pills -->
|
||||
<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" data-el-featured="true">⭐ Featured</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="conversational">Conversational</button>
|
||||
<button class="el-cat" data-el-use_case="news">News</button>
|
||||
<button class="el-cat" data-el-use_case="characters_animation">Characters</button>
|
||||
<button class="el-cat" data-el-use_case="meditation">Meditation</button>
|
||||
<button class="el-cat" data-el-use_case="gaming">Gaming</button>
|
||||
<button class="el-cat" data-el-use_case="training">Training</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="el-filters">
|
||||
<input type="search" class="el-search" id="el-search" placeholder="Search voices…" autocomplete="off">
|
||||
<select id="el-lang" title="Language">
|
||||
<option value="">All languages</option>
|
||||
<option value="en">English</option>
|
||||
<option value="de">German</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="es">Spanish</option>
|
||||
<option value="it">Italian</option>
|
||||
<option value="pt">Portuguese</option>
|
||||
<option value="nl">Dutch</option>
|
||||
<option value="pl">Polish</option>
|
||||
<option value="sv">Swedish</option>
|
||||
<option value="da">Danish</option>
|
||||
<option value="no">Norwegian</option>
|
||||
<option value="fi">Finnish</option>
|
||||
<option value="cs">Czech</option>
|
||||
<option value="sk">Slovak</option>
|
||||
<option value="hu">Hungarian</option>
|
||||
<option value="ro">Romanian</option>
|
||||
<option value="hr">Croatian</option>
|
||||
<option value="bg">Bulgarian</option>
|
||||
<option value="el">Greek</option>
|
||||
<option value="uk">Ukrainian</option>
|
||||
<option value="ru">Russian</option>
|
||||
<option value="tr">Turkish</option>
|
||||
<option value="ca">Catalan</option>
|
||||
<option value="ar">Arabic</option>
|
||||
<option value="hi">Hindi</option>
|
||||
<option value="zh">Chinese</option>
|
||||
<option value="ja">Japanese</option>
|
||||
<option value="ko">Korean</option>
|
||||
</select>
|
||||
<select id="el-gender" title="Gender">
|
||||
<option value="">Any gender</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
</select>
|
||||
<select id="el-age" title="Age">
|
||||
<option value="">Any age</option>
|
||||
<option value="young">Young</option>
|
||||
<option value="middle_aged">Middle aged</option>
|
||||
<option value="old">Old</option>
|
||||
</select>
|
||||
<button class="btn-primary" id="el-fetch">Browse</button>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="el-status-bar" id="el-status-bar" hidden>
|
||||
<span id="el-status-text"></span>
|
||||
</div>
|
||||
|
||||
<!-- Voice grid -->
|
||||
<div class="el-grid" id="el-grid">
|
||||
<div class="el-idle">Use the filters above and click <strong>Browse</strong> — or enter your API key for full access to 12 000+ voices.</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="el-pager" id="el-pager" hidden>
|
||||
<button class="btn-secondary" id="el-prev" disabled>← Prev</button>
|
||||
<span class="el-pager-info" id="el-pager-info">Page 1</span>
|
||||
<button class="btn-secondary" id="el-next">Next →</button>
|
||||
</div>
|
||||
|
||||
</div><!-- /el-browser-card -->
|
||||
|
||||
@ -514,7 +514,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.vl-row.edit-open .vr-optimizer { display: block; }
|
||||
.optimizer-grid { display: grid; grid-template-columns: minmax(280px,1.2fr) minmax(280px,1fr); gap: 12px; align-items: start; }
|
||||
.opt-group { box-shadow: var(--shadow); margin-top: 20px; margin-right: 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); padding: 10px; min-width: 0; }
|
||||
.opt-group-title { font-size: 14px; font-weight: 800; color: var(--accent); text-transform: uppercase; letter-spacing: .08em; margin-bottom: 8px; }
|
||||
.opt-group-title { font-size: 14px; font-weight: 800; color: var(--accent); text-transform: uppercase; letter-spacing: .08em;}
|
||||
.opt-group-note { font-size: 12px; color: var(--subtext); line-height: 1.4; margin-top: 7px; }
|
||||
.opt-compare-panel { grid-column: 1 / -1; }
|
||||
.opt-compare-grid { display: grid; grid-template-columns: repeat(2, minmax(240px,1fr)); gap: 10px; }
|
||||
@ -1139,9 +1139,9 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
}
|
||||
.inspector-body .opt-group-title {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 9px 14px; font-size: 10.5px; font-weight: 700; letter-spacing: .05em;
|
||||
padding: 10px 10px; font-size: 14px; font-weight: 700; letter-spacing: .05em;
|
||||
text-transform: uppercase; color: var(--subtext);
|
||||
background: var(--panel); cursor: pointer; user-select: none;
|
||||
cursor: pointer; user-select: none;
|
||||
transition: background .15s;
|
||||
}
|
||||
.inspector-body .opt-group-title:hover { background: var(--border); }
|
||||
@ -1162,7 +1162,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.inspector-body .opt-group .opt-group-body > textarea {
|
||||
display: block; width: 100%; box-sizing: border-box; padding: 10px 14px; margin: 0;
|
||||
background: transparent; border: none; outline: none; resize: vertical;
|
||||
font-size: 13px; color: var(--text); font-family: inherit; min-height: 80px;
|
||||
font-size: 16px; color: var(--text); font-family: inherit; min-height: 80px;
|
||||
}
|
||||
|
||||
/* Section order */
|
||||
@ -1434,4 +1434,60 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.llm-howto-body p { margin: 0; color: var(--subtext); }
|
||||
.llm-howto-body em { font-style: normal; font-weight: 600; color: var(--text); }
|
||||
|
||||
/* ── ElevenLabs Voice Browser ──────────────────────────────────────────────── */
|
||||
.el-browser { padding: 0; overflow: hidden; }
|
||||
.el-head { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 14px; padding: 16px 20px 14px; border-bottom: 1px solid var(--border); }
|
||||
.el-brand { display: flex; align-items: flex-start; gap: 12px; flex: 1; min-width: 200px; }
|
||||
.el-brand-logo { display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; border-radius: 9px; background: #131314; color: #fff; font-weight: 800; font-size: 13px; letter-spacing: -0.5px; flex-shrink: 0; margin-top: 3px; }
|
||||
.el-brand h2 { margin: 0 0 3px; font-size: 14px; font-weight: 700; }
|
||||
.el-brand p { margin: 0; font-size: 12px; }
|
||||
.el-key-row { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.el-key-field { display: flex; gap: 6px; }
|
||||
.el-key-field input { width: 230px; font-size: 12px; padding: 5px 9px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); font-family: monospace; }
|
||||
.el-key-btn { font-size: 12px; padding: 5px 12px; }
|
||||
.el-key-status { font-size: 11px; font-weight: 600; }
|
||||
.el-key-ok { color: #38a169; }
|
||||
.el-key-none { color: var(--subtext); }
|
||||
|
||||
.el-cats { display: flex; flex-wrap: wrap; gap: 6px; padding: 11px 20px; border-bottom: 1px solid var(--border); }
|
||||
.el-cat { padding: 4px 13px; border-radius: 20px; border: 1px solid var(--border); background: transparent; color: var(--text); font-size: 12px; cursor: pointer; transition: border-color .15s, color .15s, background .15s; white-space: nowrap; }
|
||||
.el-cat:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.el-cat.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
.el-filters { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 20px; border-bottom: 1px solid var(--border); align-items: center; }
|
||||
.el-search { flex: 1; min-width: 130px; font-size: 13px; padding: 5px 9px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); font-family: inherit; }
|
||||
.el-filters select { font-size: 12px; padding: 5px 8px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); font-family: inherit; }
|
||||
|
||||
.el-status-bar { padding: 6px 20px; background: var(--panel); border-bottom: 1px solid var(--border); font-size: 12px; color: var(--subtext); }
|
||||
|
||||
.el-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(272px, 1fr)); }
|
||||
.el-grid > .el-loading,
|
||||
.el-grid > .el-error,
|
||||
.el-grid > .el-empty,
|
||||
.el-grid > .el-idle { grid-column: 1/-1; padding: 36px 20px; text-align: center; color: var(--subtext); font-size: 13px; }
|
||||
.el-spinner { display: inline-block; width: 13px; height: 13px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: elSpin .75s linear infinite; vertical-align: middle; margin-right: 6px; }
|
||||
@keyframes elSpin { to { transform: rotate(360deg); } }
|
||||
.el-error { color: #e53e3e !important; }
|
||||
|
||||
.el-card-voice { display: flex; align-items: flex-start; gap: 10px; padding: 11px 13px; border-bottom: 1px solid var(--border); border-right: 1px solid var(--border); transition: background .12s; }
|
||||
.el-card-voice:hover { background: var(--panel); }
|
||||
.el-vc-av { flex-shrink: 0; width: 34px; height: 34px; border-radius: 50%; background: hsl(var(--elh, 220), 52%, 52%); color: #fff; font-weight: 700; font-size: 15px; display: flex; align-items: center; justify-content: center; margin-top: 3px; }
|
||||
.el-vc-body { flex: 1; min-width: 0; }
|
||||
.el-vc-name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 4px; }
|
||||
.el-vc-tags { display: flex; flex-wrap: wrap; gap: 3px; margin-bottom: 4px; }
|
||||
.el-tag { font-size: 10px; padding: 1px 5px; border-radius: 3px; background: var(--panel); border: 1px solid var(--border); color: var(--subtext); white-space: nowrap; }
|
||||
.el-lang { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 700; }
|
||||
.el-uc { color: var(--accent); border-color: var(--accent); background: transparent; }
|
||||
.el-free { color: #38a169; border-color: #38a169; background: transparent; }
|
||||
.el-feat { color: #d69e2e; border-color: #d69e2e; background: transparent; }
|
||||
.el-clones { opacity: .7; }
|
||||
.el-vc-desc { font-size: 11px; color: var(--subtext); line-height: 1.4; }
|
||||
.el-vc-btns { display: flex; flex-direction: column; gap: 5px; flex-shrink: 0; padding-top: 2px; }
|
||||
.el-play, .el-clone { border: 1px solid var(--border); border-radius: 5px; background: var(--surface); color: var(--text); font-size: 11px; padding: 4px 8px; cursor: pointer; white-space: nowrap; transition: border-color .15s, color .15s; font-family: inherit; }
|
||||
.el-play:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.el-clone:hover { border-color: #38a169; color: #38a169; }
|
||||
|
||||
.el-pager { display: flex; align-items: center; gap: 12px; padding: 12px 20px; border-top: 1px solid var(--border); }
|
||||
.el-pager-info { flex: 1; text-align: center; font-size: 12px; color: var(--subtext); }
|
||||
|
||||
/* Bottom action buttons */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user