feat: Implement sortable Table View and Inspector drag-and-drop support
- Added a new 'Table View' button to the library sort bar. - Implemented a CSS grid layout to display voice properties in a high-density table. - Added sortable column headers (Name, Lang, Gender, Speed, dBFS, Length, Rating, Source, Seed, Note, Tags, Active). - Aligned CSS grid to account for the bulk edit checkbox injection. - Added drag-and-drop profile image support to the Inspector's large avatar. - Ensured picture updates instantly synchronize across the List, Table, and Inspector views.
This commit is contained in:
parent
5becb4341f
commit
a325f53e62
@ -38,7 +38,7 @@ _SETTINGS_KEYS = {
|
|||||||
"llm_url", "llm_model",
|
"llm_url", "llm_model",
|
||||||
# Browser-persistent UI state
|
# Browser-persistent UI state
|
||||||
"engine_local_urls", "engine_container_names", "custom_engine_cards",
|
"engine_local_urls", "engine_container_names", "custom_engine_cards",
|
||||||
"refine_llm_url", "conv_llm_url",
|
"refine_llm_url", "conv_llm_url", "seed_finder_text",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── TTS stability defaults ────────────────────────────────────────────────────
|
# ── TTS stability defaults ────────────────────────────────────────────────────
|
||||||
@ -275,6 +275,7 @@ def _load_settings() -> dict:
|
|||||||
"custom_engine_cards": [],
|
"custom_engine_cards": [],
|
||||||
"refine_llm_url": "",
|
"refine_llm_url": "",
|
||||||
"conv_llm_url": "",
|
"conv_llm_url": "",
|
||||||
|
"seed_finder_text": "",
|
||||||
}
|
}
|
||||||
if CONFIG_FILE.exists():
|
if CONFIG_FILE.exists():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -26,7 +26,8 @@
|
|||||||
|
|
||||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css?v=1.8.0-3">
|
||||||
|
|
||||||
|
|
||||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||||
<link rel="preload" as="style"
|
<link rel="preload" as="style"
|
||||||
@ -274,7 +275,7 @@
|
|||||||
<script src="/static/vendor/wavesurfer-regions.min.js"></script>
|
<script src="/static/vendor/wavesurfer-regions.min.js"></script>
|
||||||
|
|
||||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||||
<script src="/static/loader.js"></script>
|
<script src="/static/loader.js?v=1.8.0-2"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -99,9 +99,9 @@ function selectVoice(wrap) {
|
|||||||
<button class="insp-edit-id-btn" type="button" title="Rename voice ID"><span class="mdi mdi-pencil-outline"></span> edit ID</button>
|
<button class="insp-edit-id-btn" type="button" title="Rename voice ID"><span class="mdi mdi-pencil-outline"></span> edit ID</button>
|
||||||
<button class="insp-copy-id-btn" type="button" title="Copy voice ID">copy ID</button>
|
<button class="insp-copy-id-btn" type="button" title="Copy voice ID">copy ID</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="insp-hd-row2-right" style="display:flex; align-items:center;">
|
<div class="insp-hd-row2-right" style="display:flex; flex-direction:column; align-items:flex-end; gap:4px;">
|
||||||
<div class="insp-actions-active"></div>
|
<div class="insp-actions-active"></div>
|
||||||
${(v.seed !== undefined && v.seed !== null) ? `<div class="insp-pinned-seed" style="color:#d32f2f; font-weight:600; font-size:12px; margin-left:12px;">Pin Seed # ${v.seed}</div>` : `<div class="insp-pinned-seed" style="color:#d32f2f; font-weight:600; font-size:12px; margin-left:12px; display:none;"></div>`}
|
${(v.seed !== undefined && v.seed !== null) ? `<div class="insp-pinned-seed" style="color:#d32f2f; font-weight:600; font-size:12px;">Pin Seed # ${v.seed}</div>` : `<div class="insp-pinned-seed" style="color:#d32f2f; font-weight:600; font-size:12px; display:none;"></div>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="insp-hd-divider"></div>
|
<div class="insp-hd-divider"></div>
|
||||||
@ -164,10 +164,59 @@ function selectVoice(wrap) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Avatar click → photo upload ───────────────────────────────────────────
|
// ── Avatar click → photo upload ───────────────────────────────────────────
|
||||||
inspector.querySelector('.inspector-avatar').addEventListener('click', () => {
|
const inspAvatar = inspector.querySelector('.inspector-avatar');
|
||||||
|
inspAvatar.addEventListener('click', () => {
|
||||||
body.querySelector('.photo-input')?.click();
|
body.querySelector('.photo-input')?.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
inspAvatar.addEventListener('dragenter', e => { e.preventDefault(); inspAvatar.classList.add('drag-over'); });
|
||||||
|
inspAvatar.addEventListener('dragover', e => { e.preventDefault(); inspAvatar.classList.add('drag-over'); });
|
||||||
|
inspAvatar.addEventListener('dragleave', () => inspAvatar.classList.remove('drag-over'));
|
||||||
|
inspAvatar.addEventListener('drop', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
inspAvatar.classList.remove('drag-over');
|
||||||
|
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||||
|
const photoInput = body.querySelector('.photo-input');
|
||||||
|
if (photoInput) {
|
||||||
|
photoInput.files = e.dataTransfer.files;
|
||||||
|
photoInput.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = e.dataTransfer.getData('text/uri-list');
|
||||||
|
if (!url) {
|
||||||
|
const html = e.dataTransfer.getData('text/html');
|
||||||
|
if (html) {
|
||||||
|
const match = html.match(/src=["'](.*?)["']/);
|
||||||
|
if (match) url = match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!url) url = e.dataTransfer.getData('text/plain');
|
||||||
|
|
||||||
|
if (url && /^https?:\/\//i.test(url)) {
|
||||||
|
if (typeof status === 'function') status('Downloading picture from URL...');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/voice/picture-url', {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({voice_id: voiceId, image_url: url})
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error((await r.json()).detail);
|
||||||
|
|
||||||
|
// Trigger the photo update in the row
|
||||||
|
const photoCell = body.querySelector('.vr-photo');
|
||||||
|
if (photoCell) photoCell.dispatchEvent(new Event('update-photo'));
|
||||||
|
|
||||||
|
if (typeof toast === 'function') toast('Photo saved from URL', 'success');
|
||||||
|
if (typeof status === 'function') status('Photo saved successfully');
|
||||||
|
} catch(err) {
|
||||||
|
if (typeof toast === 'function') toast('Photo URL download failed: ' + err.message, 'error');
|
||||||
|
if (typeof status === 'function') status('Photo URL download failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Copy ID button ────────────────────────────────────────────────────────
|
// ── Copy ID button ────────────────────────────────────────────────────────
|
||||||
inspector.querySelector('.insp-copy-id-btn').addEventListener('click', () => {
|
inspector.querySelector('.insp-copy-id-btn').addEventListener('click', () => {
|
||||||
copyText(voiceId).then(() => toast('Copied: ' + voiceId));
|
copyText(voiceId).then(() => toast('Copied: ' + voiceId));
|
||||||
|
|||||||
@ -84,6 +84,9 @@ function getSortValue(v, field) {
|
|||||||
case 'benchmark': return voiceBenchmarkElapsed(v) ?? 999999;
|
case 'benchmark': return voiceBenchmarkElapsed(v) ?? 999999;
|
||||||
case 'transcript': return (v.transcript || '').toLowerCase();
|
case 'transcript': return (v.transcript || '').toLowerCase();
|
||||||
case 'note': return (v.note || '').toLowerCase();
|
case 'note': return (v.note || '').toLowerCase();
|
||||||
|
case 'source': return (v.origin || '').toLowerCase();
|
||||||
|
case 'seed': return v.seed != null ? v.seed : 9999999;
|
||||||
|
case 'tag': return (v.tag || '').toLowerCase();
|
||||||
case 'rating': return v.rating || 0;
|
case 'rating': return v.rating || 0;
|
||||||
case 'enabled': return v.enabled === false ? 0 : 1;
|
case 'enabled': return v.enabled === false ? 0 : 1;
|
||||||
default: return '';
|
default: return '';
|
||||||
@ -132,9 +135,42 @@ document.addEventListener('click', e => {
|
|||||||
if (icon) icon.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline';
|
if (icon) icon.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline';
|
||||||
renderVoiceList();
|
renderVoiceList();
|
||||||
}
|
}
|
||||||
|
if (e.target.closest('#voice-table-view-btn')) {
|
||||||
|
window._voiceTableView = !window._voiceTableView;
|
||||||
|
try { localStorage.setItem('vl-table-view', window._voiceTableView ? '1' : '0'); } catch (_) {}
|
||||||
|
const btn = document.getElementById('voice-table-view-btn');
|
||||||
|
btn?.classList.toggle('active', window._voiceTableView);
|
||||||
|
document.querySelector('.voices-workbench')?.classList.toggle('table-view', window._voiceTableView);
|
||||||
|
// Auto-close any open inspector row when toggling table view
|
||||||
|
if (window._voiceTableView) {
|
||||||
|
document.querySelectorAll('.edit-open').forEach(r => r.classList.remove('edit-open'));
|
||||||
|
const inspector = document.getElementById('voices-inspector');
|
||||||
|
if (inspector) inspector.innerHTML = '<div class="inspector-placeholder"><span><span class="mdi mdi-table"></span></span><p>Table View Mode<br>Click a row to exit table view and edit.</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
// Restore the grouped-by-tag preference on load
|
// Restore the preferences on load
|
||||||
try { window._voiceGroupByTag = localStorage.getItem('vl-group-by-tag') === '1'; } catch (_) {}
|
try { window._voiceGroupByTag = localStorage.getItem('vl-group-by-tag') === '1'; } catch (_) {}
|
||||||
|
try { window._voiceTableView = localStorage.getItem('vl-table-view') === '1'; } catch (_) {}
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const th = e.target.closest('.vl-th-sortable');
|
||||||
|
if (th) {
|
||||||
|
const field = th.dataset.sort;
|
||||||
|
if (field) {
|
||||||
|
const sel = document.getElementById('voice-sort-field');
|
||||||
|
if (sel && sel.value === field) {
|
||||||
|
window._voiceSortDir *= -1; // toggle dir
|
||||||
|
} else if (sel) {
|
||||||
|
sel.value = field;
|
||||||
|
window._voiceSortDir = 1; // default to desc for new field (or asc if you want)
|
||||||
|
}
|
||||||
|
readLibrarySortConfig();
|
||||||
|
renderVoiceList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener('change', e => {
|
document.addEventListener('change', e => {
|
||||||
if (e.target.id === 'voice-sort-field') setSort(e.target.value);
|
if (e.target.id === 'voice-sort-field') setSort(e.target.value);
|
||||||
});
|
});
|
||||||
@ -260,7 +296,10 @@ function fmtDbfs(v) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function voiceBenchmark(v) {
|
function voiceBenchmark(v) {
|
||||||
return v.benchmark && typeof v.benchmark === 'object' ? v.benchmark : null;
|
if (v.benchmark && typeof v.benchmark === 'object' && Object.keys(v.benchmark).length > 0) {
|
||||||
|
return v.benchmark;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function voiceBenchmarkElapsed(v) {
|
function voiceBenchmarkElapsed(v) {
|
||||||
@ -1412,6 +1451,12 @@ function renderVoiceList() {
|
|||||||
if (ic) ic.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline';
|
if (ic) ic.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _tvBtn = $('voice-table-view-btn');
|
||||||
|
if (_tvBtn) {
|
||||||
|
_tvBtn.classList.toggle('active', !!window._voiceTableView);
|
||||||
|
document.querySelector('.voices-workbench')?.classList.toggle('table-view', !!window._voiceTableView);
|
||||||
|
}
|
||||||
|
|
||||||
// Apply sidebar category filter
|
// Apply sidebar category filter
|
||||||
const cat = window._voiceSidebarCat || 'all';
|
const cat = window._voiceSidebarCat || 'all';
|
||||||
const enabledOk = v => showDisabled || v.enabled !== false;
|
const enabledOk = v => showDisabled || v.enabled !== false;
|
||||||
@ -1796,6 +1841,18 @@ function makeVoiceRow(v) {
|
|||||||
<div class="vr-play vr-play-original"><button class="play-btn-orig" title="Play original recording" aria-label="Play original recording"><span class="mdi mdi-play"></span></button></div>
|
<div class="vr-play vr-play-original"><button class="play-btn-orig" title="Play original recording" aria-label="Play original recording"><span class="mdi mdi-play"></span></button></div>
|
||||||
<div class="vr-play vr-play-synth"><button class="play-btn-synth" title="Generate and play TTS preview" aria-label="Generate and play TTS preview"><span class="mdi mdi-play"></span></button></div>
|
<div class="vr-play vr-play-synth"><button class="play-btn-synth" title="Generate and play TTS preview" aria-label="Generate and play TTS preview"><span class="mdi mdi-play"></span></button></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="vl-tbl-lang" title="${escHtml(currentFlag || langCode)}">${flagEmoji} ${escHtml(langCode)}</div>
|
||||||
|
<div class="vl-tbl-gender" title="${escHtml(genderLabel[gender]||'')}">${genderMap[gender]||'?'} ${escHtml(genderLabel[gender]||'—')}</div>
|
||||||
|
<div class="vl-tbl-type"><span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span></div>
|
||||||
|
<div class="vl-tbl-bench">${benchText !== '-' ? `<span class="vl-bench-chip ${benchCls}" title="${escHtml(benchTitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(benchText)}</span>` : '-'}</div>
|
||||||
|
<div class="vl-tbl-dbfs" title="${dbTitle}">${dbfs} dB</div>
|
||||||
|
<div class="vl-tbl-len">${fmtDuration(v.duration)}</div>
|
||||||
|
<div class="vl-tbl-rating">${starsHtml}</div>
|
||||||
|
<div class="vl-tbl-source">${escHtml(v.origin || '-')}</div>
|
||||||
|
<div class="vl-tbl-seed">${v.seed != null ? '#' + v.seed : 'Auto'}</div>
|
||||||
|
<div class="vl-tbl-note" title="${escHtml(v.note||'')}">${escHtml(v.note || '')}</div>
|
||||||
|
<div class="vl-tbl-tag" title="${escHtml(v.tag||'')}">${escHtml(v.tag || '')}</div>
|
||||||
|
<div class="vl-tbl-active"><span class="mdi ${v.enabled !== false ? 'mdi-check-circle' : 'mdi-close-circle'}" style="color:${v.enabled !== false ? 'var(--green)' : 'var(--subtext)'}"></span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="vr-main-row">
|
<div class="vr-main-row">
|
||||||
@ -2001,6 +2058,37 @@ function makeVoiceRow(v) {
|
|||||||
// Photo upload
|
// Photo upload
|
||||||
const photoCell = wrap.querySelector('.vr-photo');
|
const photoCell = wrap.querySelector('.vr-photo');
|
||||||
const photoInput = wrap.querySelector('.photo-input');
|
const photoInput = wrap.querySelector('.photo-input');
|
||||||
|
|
||||||
|
const updatePhotoImg = () => {
|
||||||
|
const ts = Date.now();
|
||||||
|
const imgSrc = `/api/voice/picture/${encodeURIComponent(v.id)}?t=${ts}`;
|
||||||
|
// Update main photo cell
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = imgSrc;
|
||||||
|
img.alt = '';
|
||||||
|
photoCell.innerHTML = ''; photoCell.appendChild(img); photoCell.appendChild(photoInput);
|
||||||
|
|
||||||
|
// Update compact row avatar
|
||||||
|
const compactAvatar = wrap.querySelector('.vl-avatar');
|
||||||
|
if (compactAvatar) {
|
||||||
|
compactAvatar.className = 'vl-avatar vl-avatar-photo';
|
||||||
|
compactAvatar.style.background = '';
|
||||||
|
compactAvatar.innerHTML = `<img src="${imgSrc}" alt="" class="vl-avatar-img">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update inspector avatar if this voice is currently open
|
||||||
|
const inspectorAvatar = document.querySelector('.inspector-avatar');
|
||||||
|
if (inspectorAvatar && wrap.classList.contains('vr-selected')) {
|
||||||
|
inspectorAvatar.classList.add('insp-avatar-photo');
|
||||||
|
inspectorAvatar.style.background = '';
|
||||||
|
inspectorAvatar.innerHTML = `<img src="${imgSrc}" alt="" class="insp-avatar-img">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
v.has_picture = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
photoCell.addEventListener('update-photo', updatePhotoImg);
|
||||||
|
|
||||||
photoCell.addEventListener('click', () => photoInput.click());
|
photoCell.addEventListener('click', () => photoInput.click());
|
||||||
photoInput.addEventListener('change', async () => {
|
photoInput.addEventListener('change', async () => {
|
||||||
if (!photoInput.files.length) return;
|
if (!photoInput.files.length) return;
|
||||||
@ -2010,14 +2098,53 @@ function makeVoiceRow(v) {
|
|||||||
try {
|
try {
|
||||||
const r = await fetch('/api/voice/picture', { method:'POST', body:fd });
|
const r = await fetch('/api/voice/picture', { method:'POST', body:fd });
|
||||||
if (!r.ok) throw new Error((await r.json()).detail);
|
if (!r.ok) throw new Error((await r.json()).detail);
|
||||||
const img = document.createElement('img');
|
updatePhotoImg();
|
||||||
img.src = `/api/voice/picture/${encodeURIComponent(v.id)}?t=${Date.now()}`;
|
toast('Photo uploaded','success');
|
||||||
img.alt = '';
|
|
||||||
photoCell.innerHTML = ''; photoCell.appendChild(img); photoCell.appendChild(photoInput);
|
|
||||||
v.has_picture = true; toast('Photo uploaded','success');
|
|
||||||
} catch(e) { toast('Photo upload failed: '+e.message,'error'); }
|
} catch(e) { toast('Photo upload failed: '+e.message,'error'); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
photoCell.addEventListener('dragenter', e => { e.preventDefault(); photoCell.classList.add('drag-over'); });
|
||||||
|
photoCell.addEventListener('dragover', e => { e.preventDefault(); photoCell.classList.add('drag-over'); });
|
||||||
|
photoCell.addEventListener('dragleave', () => photoCell.classList.remove('drag-over'));
|
||||||
|
photoCell.addEventListener('drop', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
photoCell.classList.remove('drag-over');
|
||||||
|
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||||
|
photoInput.files = e.dataTransfer.files;
|
||||||
|
photoInput.dispatchEvent(new Event('change'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = e.dataTransfer.getData('text/uri-list');
|
||||||
|
if (!url) {
|
||||||
|
const html = e.dataTransfer.getData('text/html');
|
||||||
|
if (html) {
|
||||||
|
const match = html.match(/src=["'](.*?)["']/);
|
||||||
|
if (match) url = match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!url) url = e.dataTransfer.getData('text/plain');
|
||||||
|
|
||||||
|
if (url && /^https?:\/\//i.test(url)) {
|
||||||
|
status('Downloading picture from URL...');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/voice/picture-url', {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({voice_id: v.id, image_url: url})
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error((await r.json()).detail);
|
||||||
|
updatePhotoImg();
|
||||||
|
toast('Photo saved from URL', 'success');
|
||||||
|
status('Photo saved successfully');
|
||||||
|
} catch(err) {
|
||||||
|
toast('Photo URL download failed: ' + err.message, 'error');
|
||||||
|
status('Photo URL download failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// Per-voice loudness normalization
|
// Per-voice loudness normalization
|
||||||
const normalizeBtn = wrap.querySelector('.normalize-voice-btn');
|
const normalizeBtn = wrap.querySelector('.normalize-voice-btn');
|
||||||
const dbValue = wrap.querySelector('.vr-db-value');
|
const dbValue = wrap.querySelector('.vr-db-value');
|
||||||
|
|||||||
@ -65,10 +65,15 @@
|
|||||||
<option value="rating">Rating</option>
|
<option value="rating">Rating</option>
|
||||||
<option value="duration">Duration</option>
|
<option value="duration">Duration</option>
|
||||||
<option value="dbfs">Volume dB</option>
|
<option value="dbfs">Volume dB</option>
|
||||||
|
<option value="source">Source</option>
|
||||||
|
<option value="seed">Seed</option>
|
||||||
|
<option value="note">Note</option>
|
||||||
|
<option value="tag">Tags</option>
|
||||||
<option value="enabled">Active</option>
|
<option value="enabled">Active</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="voice-sort-dir" class="vl-sort-dir-btn" title="Toggle sort direction"><span class="mdi mdi-arrow-up"></span></button>
|
<button id="voice-sort-dir" class="vl-sort-dir-btn" title="Toggle sort direction"><span class="mdi mdi-arrow-up"></span></button>
|
||||||
<button id="voice-group-tag-btn" class="vl-sort-dir-btn" title="Group into virtual folders by tag"><span class="mdi mdi-folder-outline"></span></button>
|
<button id="voice-group-tag-btn" class="vl-sort-dir-btn" title="Group into virtual folders by tag"><span class="mdi mdi-folder-outline"></span></button>
|
||||||
|
<button id="voice-table-view-btn" class="vl-sort-dir-btn" title="Toggle full-width table view"><span class="mdi mdi-table"></span></button>
|
||||||
<button id="vl-select-all-visible" class="vl-sort-dir-btn" title="Select all visible voices (toggle)"><span class="mdi mdi-checkbox-multiple-marked-outline"></span></button>
|
<button id="vl-select-all-visible" class="vl-sort-dir-btn" title="Select all visible voices (toggle)"><span class="mdi mdi-checkbox-multiple-marked-outline"></span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -95,6 +100,24 @@
|
|||||||
<button class="vl-bulk-btn vl-bulk-danger" id="vl-bulk-delete" title="Delete selected voices permanently"><span class="mdi mdi-delete-outline"></span> Delete</button>
|
<button class="vl-bulk-btn vl-bulk-danger" id="vl-bulk-delete" title="Delete selected voices permanently"><span class="mdi mdi-delete-outline"></span> Delete</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="vl-table-header" id="vl-table-header">
|
||||||
|
<div></div>
|
||||||
|
<div>Img</div>
|
||||||
|
<div>Play</div>
|
||||||
|
<div class="vl-th-sortable" data-sort="id">Name <span class="mdi mdi-sort"></span></div>
|
||||||
|
<div class="vl-th-sortable" data-sort="flag">Lang <span class="mdi mdi-sort"></span></div>
|
||||||
|
<div class="vl-th-sortable" data-sort="gender">Gender <span class="mdi mdi-sort"></span></div>
|
||||||
|
<div>Type</div>
|
||||||
|
<div class="vl-th-sortable" data-sort="benchmark">Speed <span class="mdi mdi-sort"></span></div>
|
||||||
|
<div class="vl-th-sortable" data-sort="dbfs">dBFS <span class="mdi mdi-sort"></span></div>
|
||||||
|
<div class="vl-th-sortable" data-sort="duration">Length <span class="mdi mdi-sort"></span></div>
|
||||||
|
<div class="vl-th-sortable" data-sort="rating">Rating <span class="mdi mdi-sort"></span></div>
|
||||||
|
<div class="vl-th-sortable" data-sort="source">Source <span class="mdi mdi-sort"></span></div>
|
||||||
|
<div class="vl-th-sortable" data-sort="seed">Seed <span class="mdi mdi-sort"></span></div>
|
||||||
|
<div class="vl-th-sortable" data-sort="note">Note <span class="mdi mdi-sort"></span></div>
|
||||||
|
<div class="vl-th-sortable" data-sort="tag">Tags <span class="mdi mdi-sort"></span></div>
|
||||||
|
<div class="vl-th-sortable" data-sort="enabled">Active <span class="mdi mdi-sort"></span></div>
|
||||||
|
</div>
|
||||||
<div id="voice-list" tabindex="0" role="region" aria-label="Voice library"></div>
|
<div id="voice-list" tabindex="0" role="region" aria-label="Voice library"></div>
|
||||||
<div class="vl-toolbar">
|
<div class="vl-toolbar">
|
||||||
<button class="btn-secondary vl-tb-btn" id="refresh-voices-btn" title="Refresh voice list"><span class="mdi mdi-refresh"></span> Refresh</button>
|
<button class="btn-secondary vl-tb-btn" id="refresh-voices-btn" title="Refresh voice list"><span class="mdi mdi-refresh"></span> Refresh</button>
|
||||||
|
|||||||
@ -611,6 +611,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
.vr-photo img { width: 100%; height: 100%; object-fit: cover; }
|
.vr-photo img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
.vr-photo .ph-icon { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 64px; color: white; font-weight: 700; }
|
.vr-photo .ph-icon { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 64px; color: white; font-weight: 700; }
|
||||||
.vr-photo:hover::after { content: ''; }
|
.vr-photo:hover::after { content: ''; }
|
||||||
|
.vr-photo.drag-over { opacity: 0.8; outline: 3px dashed var(--accent); outline-offset: -4px; transition: all 0.2s; }
|
||||||
.vr-identity { display: flex; gap: 10px; align-items: center; min-width: 0; width: 100%; }
|
.vr-identity { display: flex; gap: 10px; align-items: center; min-width: 0; width: 100%; }
|
||||||
.vr-flag { display: none; }
|
.vr-flag { display: none; }
|
||||||
.vr-flag .flag-emoji { font-size: 22px; line-height: 1; transition: transform .15s; }
|
.vr-flag .flag-emoji { font-size: 22px; line-height: 1; transition: transform .15s; }
|
||||||
@ -1360,6 +1361,56 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
.vl-bench-chip.bench-bad { background: rgba(239,68,68,.1); }
|
.vl-bench-chip.bench-bad { background: rgba(239,68,68,.1); }
|
||||||
.vl-dbfs { display: none; }
|
.vl-dbfs { display: none; }
|
||||||
|
|
||||||
|
/* Table View mode */
|
||||||
|
.vl-tbl-lang, .vl-tbl-gender, .vl-tbl-type, .vl-tbl-bench, .vl-tbl-dbfs, .vl-tbl-len, .vl-tbl-rating, .vl-tbl-source, .vl-tbl-seed, .vl-tbl-note, .vl-tbl-tag, .vl-tbl-active { display: none; }
|
||||||
|
.vl-table-header { display: none; }
|
||||||
|
|
||||||
|
.voices-workbench.table-view .voices-list-pane { width: 100% !important; max-width: 100%; }
|
||||||
|
.voices-workbench.table-view .voices-inspector-pane { display: none !important; }
|
||||||
|
|
||||||
|
.voices-workbench.table-view .vl-table-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 24px 32px 60px minmax(140px, 1.2fr) 60px 70px 60px 80px 70px 60px 90px 70px 60px minmax(120px, 1fr) minmax(80px, 1fr) 50px;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--subtext);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
position: sticky; top: 0; z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vl-th-sortable { cursor: pointer; display: flex; align-items: center; gap: 3px; transition: color .15s; }
|
||||||
|
.vl-th-sortable:hover { color: var(--text); }
|
||||||
|
.vl-th-sortable .mdi { font-size: 12px; opacity: .6; }
|
||||||
|
.vl-th-sortable:hover .mdi { opacity: 1; color: var(--accent); }
|
||||||
|
|
||||||
|
.voices-workbench.table-view .vl-compact {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 24px 32px 60px minmax(140px, 1.2fr) 60px 70px 60px 80px 70px 60px 90px 70px 60px minmax(120px, 1fr) minmax(80px, 1fr) 50px;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.voices-workbench.table-view .vl-bulk-cb { order: 0; margin: 0; justify-self: center; }
|
||||||
|
.voices-workbench.table-view .vl-info { display: contents; }
|
||||||
|
.voices-workbench.table-view .vl-meta { display: none; }
|
||||||
|
.voices-workbench.table-view .vl-avatar { order: 1; width: 32px; height: 32px; }
|
||||||
|
.voices-workbench.table-view .vr-play-group { order: 2; flex-shrink: 0; }
|
||||||
|
.voices-workbench.table-view .vl-name { order: 3; display: block; font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
|
||||||
|
.voices-workbench.table-view .vl-tbl-lang { display: block; order: 4; font-size: 12px; }
|
||||||
|
.voices-workbench.table-view .vl-tbl-gender { display: block; order: 5; font-size: 12px; }
|
||||||
|
.voices-workbench.table-view .vl-tbl-type { display: block; order: 6; font-size: 12px; }
|
||||||
|
.voices-workbench.table-view .vl-tbl-bench { display: block; order: 7; font-size: 12px; }
|
||||||
|
.voices-workbench.table-view .vl-tbl-dbfs { display: block; order: 8; font-size: 12px; font-family: monospace; }
|
||||||
|
.voices-workbench.table-view .vl-tbl-len { display: block; order: 9; font-size: 12px; font-family: monospace; }
|
||||||
|
.voices-workbench.table-view .vl-tbl-rating { display: flex; order: 10; font-size: 14px; color: var(--subtext); }
|
||||||
|
.voices-workbench.table-view .vl-tbl-rating .star.on { color: #f59e0b; }
|
||||||
|
.voices-workbench.table-view .vl-tbl-source { display: block; order: 11; font-size: 12px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.voices-workbench.table-view .vl-tbl-seed { display: block; order: 12; font-size: 12px; font-family: monospace; }
|
||||||
|
.voices-workbench.table-view .vl-tbl-note { display: block; order: 13; font-size: 12px; color: var(--subtext); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-style: italic; }
|
||||||
|
.voices-workbench.table-view .vl-tbl-tag { display: block; order: 14; font-size: 12px; color: var(--accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
|
||||||
|
.voices-workbench.table-view .vl-tbl-active { display: block; order: 15; font-size: 16px; text-align: center; }
|
||||||
|
|
||||||
/* Synth mode panel (segmented control + preview input) */
|
/* Synth mode panel (segmented control + preview input) */
|
||||||
.vl-synth-panel { background: var(--surface); }
|
.vl-synth-panel { background: var(--surface); }
|
||||||
.vl-synth-mode-row { display: flex; align-items: center; gap: 7px; padding: 5px 8px 5px; }
|
.vl-synth-mode-row { display: flex; align-items: center; gap: 7px; padding: 5px 8px 5px; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user