From a325f53e62785a060086342b1f9f8d0c09c2a8e7 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Mon, 22 Jun 2026 14:40:39 +0200 Subject: [PATCH] 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. --- core/config.py | 3 +- static/index.html | 5 +- static/js/voice-inspector.js | 55 ++++++++++++- static/js/voice-library.js | 141 ++++++++++++++++++++++++++++++++-- static/sections/s-voices.html | 23 ++++++ static/style.css | 51 ++++++++++++ 6 files changed, 265 insertions(+), 13 deletions(-) diff --git a/core/config.py b/core/config.py index 24ce7b4..9e3aed5 100644 --- a/core/config.py +++ b/core/config.py @@ -38,7 +38,7 @@ _SETTINGS_KEYS = { "llm_url", "llm_model", # Browser-persistent UI state "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 ──────────────────────────────────────────────────── @@ -275,6 +275,7 @@ def _load_settings() -> dict: "custom_engine_cards": [], "refine_llm_url": "", "conv_llm_url": "", + "seed_finder_text": "", } if CONFIG_FILE.exists(): try: diff --git a/static/index.html b/static/index.html index b03b224..4402987 100644 --- a/static/index.html +++ b/static/index.html @@ -26,7 +26,8 @@ - + + - + diff --git a/static/js/voice-inspector.js b/static/js/voice-inspector.js index b639d83..18036d4 100644 --- a/static/js/voice-inspector.js +++ b/static/js/voice-inspector.js @@ -99,9 +99,9 @@ function selectVoice(wrap) { -
+
- ${(v.seed !== undefined && v.seed !== null) ? `
Pin Seed # ${v.seed}
` : ``} + ${(v.seed !== undefined && v.seed !== null) ? `
Pin Seed # ${v.seed}
` : ``}
@@ -164,10 +164,59 @@ function selectVoice(wrap) { } // ── Avatar click → photo upload ─────────────────────────────────────────── - inspector.querySelector('.inspector-avatar').addEventListener('click', () => { + const inspAvatar = inspector.querySelector('.inspector-avatar'); + inspAvatar.addEventListener('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 ──────────────────────────────────────────────────────── inspector.querySelector('.insp-copy-id-btn').addEventListener('click', () => { copyText(voiceId).then(() => toast('Copied: ' + voiceId)); diff --git a/static/js/voice-library.js b/static/js/voice-library.js index d8a0e73..f2a09e1 100644 --- a/static/js/voice-library.js +++ b/static/js/voice-library.js @@ -84,6 +84,9 @@ function getSortValue(v, field) { case 'benchmark': return voiceBenchmarkElapsed(v) ?? 999999; case 'transcript': return (v.transcript || '').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 'enabled': return v.enabled === false ? 0 : 1; default: return ''; @@ -132,9 +135,42 @@ document.addEventListener('click', e => { if (icon) icon.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline'; 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 = '

Table View Mode
Click a row to exit table view and edit.

'; + } + } }); -// 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._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 => { if (e.target.id === 'voice-sort-field') setSort(e.target.value); }); @@ -260,7 +296,10 @@ function fmtDbfs(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) { @@ -1412,6 +1451,12 @@ function renderVoiceList() { 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 const cat = window._voiceSidebarCat || 'all'; const enabledOk = v => showDisabled || v.enabled !== false; @@ -1796,6 +1841,18 @@ function makeVoiceRow(v) {
+
${flagEmoji} ${escHtml(langCode)}
+
${genderMap[gender]||'?'} ${escHtml(genderLabel[gender]||'—')}
+
${isClone ? 'Clone' : 'Design'}
+
${benchText !== '-' ? ` ${escHtml(benchText)}` : '-'}
+
${dbfs} dB
+
${fmtDuration(v.duration)}
+
${starsHtml}
+
${escHtml(v.origin || '-')}
+
${v.seed != null ? '#' + v.seed : 'Auto'}
+
${escHtml(v.note || '')}
+
${escHtml(v.tag || '')}
+
@@ -2001,6 +2058,37 @@ function makeVoiceRow(v) { // Photo upload const photoCell = wrap.querySelector('.vr-photo'); 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 = ``; + } + + // 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 = ``; + } + + v.has_picture = true; + }; + + photoCell.addEventListener('update-photo', updatePhotoImg); + photoCell.addEventListener('click', () => photoInput.click()); photoInput.addEventListener('change', async () => { if (!photoInput.files.length) return; @@ -2010,14 +2098,53 @@ function makeVoiceRow(v) { try { const r = await fetch('/api/voice/picture', { method:'POST', body:fd }); if (!r.ok) throw new Error((await r.json()).detail); - const img = document.createElement('img'); - img.src = `/api/voice/picture/${encodeURIComponent(v.id)}?t=${Date.now()}`; - img.alt = ''; - photoCell.innerHTML = ''; photoCell.appendChild(img); photoCell.appendChild(photoInput); - v.has_picture = true; toast('Photo uploaded','success'); + updatePhotoImg(); + toast('Photo uploaded','success'); } 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 const normalizeBtn = wrap.querySelector('.normalize-voice-btn'); const dbValue = wrap.querySelector('.vr-db-value'); diff --git a/static/sections/s-voices.html b/static/sections/s-voices.html index c0dd519..44302f9 100644 --- a/static/sections/s-voices.html +++ b/static/sections/s-voices.html @@ -65,10 +65,15 @@ + + + + +
@@ -95,6 +100,24 @@ +
+
+
Img
+
Play
+
Name
+
Lang
+
Gender
+
Type
+
Speed
+
dBFS
+
Length
+
Rating
+
Source
+
Seed
+
Note
+
Tags
+
Active
+
diff --git a/static/style.css b/static/style.css index a679206..9711186 100644 --- a/static/style.css +++ b/static/style.css @@ -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 .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.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-flag { display: none; } .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-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) */ .vl-synth-panel { background: var(--surface); } .vl-synth-mode-row { display: flex; align-items: center; gap: 7px; padding: 5px 8px 5px; }