feat(transcribe): UX improvements — progress bar, stats row, copy btn, char counter, batch errors
- Vocab textarea now shows live 0/500 char counter (turns amber at 450+) - Animated progress bar during transcription; determinate for multi-clip, indeterminate for single - Results card shows inline stats row (duration, language, speakers) and AI cleanup badge - Copy button + Download TXT moved above transcript box; SRT/VTT remain below - Speaker role legend repeats inside Segments panel for easy cross-reference - Batch errors no longer halt the queue; remaining clips continue, failed files named in status bar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3232,6 +3232,15 @@ a.dr-source-title-link:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.search-cats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* ── Search results ───────────────────────────────────────────────────── */
|
||||
.corpus-search-results {
|
||||
margin: 0 0 32px;
|
||||
@@ -3318,6 +3327,33 @@ a.dr-source-title-link:hover {
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.passage-expand-btn {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
padding: 2px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, color 0.12s;
|
||||
}
|
||||
.passage-expand-btn:hover { border-color: var(--teal); color: var(--teal); }
|
||||
|
||||
.passage-full-text {
|
||||
margin-top: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── Category card browse button ──────────────────────────────────────── */
|
||||
.cat-browse-btn {
|
||||
display: inline-block;
|
||||
@@ -3373,6 +3409,56 @@ a.dr-source-title-link:hover {
|
||||
}
|
||||
.drill-close-btn:hover { border-color: var(--teal); color: var(--teal); }
|
||||
|
||||
.drill-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.drill-count {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.drill-controls-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.drill-search-input {
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
width: 160px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.drill-search-input:focus { border-color: var(--teal); }
|
||||
|
||||
.drill-sort-select {
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.drill-sort-select:focus { border-color: var(--teal); }
|
||||
|
||||
.drill-loading,
|
||||
.drill-empty,
|
||||
.drill-error {
|
||||
@@ -3515,6 +3601,8 @@ a.dr-source-title-link:hover {
|
||||
@media (max-width: 760px) {
|
||||
.source-expand-grid { grid-template-columns: 1fr; }
|
||||
.corpus-search-controls { flex-direction: column; align-items: flex-start; }
|
||||
.drill-controls { flex-direction: column; align-items: flex-start; }
|
||||
.drill-search-input { width: 100%; }
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
@@ -5574,3 +5662,123 @@ body.lt-landing {
|
||||
.lt-auth-nav__email { display: none; }
|
||||
.lt-hero__auth-cta { gap: 8px; }
|
||||
}
|
||||
|
||||
/* ── Transcription UX improvements ──────────────────────────────────────── */
|
||||
|
||||
/* Vocab footer: hint + char counter side by side */
|
||||
.vocab-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.vocab-char-count { margin: 0; white-space: nowrap; }
|
||||
.vocab-char-count--warn { color: #b45309; font-weight: 600; }
|
||||
|
||||
/* Progress bar shown while transcribing */
|
||||
.transcribe-progress-wrap {
|
||||
padding: 2.5rem 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.transcribe-progress-track {
|
||||
height: 6px;
|
||||
background: var(--line);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.transcribe-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--teal);
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.transcribe-progress-fill.is-indeterminate {
|
||||
width: 40%;
|
||||
animation: progress-slide 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes progress-slide {
|
||||
0% { transform: translateX(-150%); }
|
||||
100% { transform: translateX(400%); }
|
||||
}
|
||||
.transcribe-progress-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Transcript meta stats row */
|
||||
.transcript-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.transcript-meta-row span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
/* AI cleanup badge */
|
||||
.cleanup-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
background: #dcfce7;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Action row above transcript (copy + download txt) */
|
||||
.transcript-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Segment legend (speaker role key inside the segments panel) */
|
||||
.segment-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* === Advocate UX additions 2026-05-18 === */
|
||||
.adv-input-footer { display:flex; justify-content:flex-end; margin-top:4px; }
|
||||
.adv-char-count { font-size:.78rem; color:var(--muted); }
|
||||
.adv-char-count.is-warn { color:var(--amber); }
|
||||
.adv-char-count.is-crit { color:var(--coral); font-weight:600; }
|
||||
.dr-section-head { display:flex; align-items:baseline; gap:12px; margin-bottom:10px; }
|
||||
.dr-section-head h3 { margin:0; font-size:1rem; }
|
||||
.dr-copy-btn { font-size:.78rem; padding:2px 10px; border:1px solid var(--line); border-radius:4px; background:var(--panel); color:var(--teal); cursor:pointer; }
|
||||
.dr-copy-btn:hover { background:var(--soft-teal); }
|
||||
.adv-result-actions { display:flex; justify-content:flex-end; margin-bottom:16px; }
|
||||
.adv-new-query-btn { font-size:.82rem; padding:4px 14px; border:1px solid var(--teal); border-radius:4px; background:var(--soft-teal); color:var(--teal-dark); cursor:pointer; }
|
||||
.adv-restore-banner { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 14px; border-radius:6px; background:var(--soft-teal); border:1px solid var(--teal); margin-bottom:12px; font-size:.85rem; flex-wrap:wrap; }
|
||||
.adv-restore-banner__text em { font-style:normal; color:var(--muted); }
|
||||
.adv-restore-banner__actions { display:flex; gap:8px; }
|
||||
.adv-restore-banner__actions button { font-size:.82rem; padding:3px 12px; border-radius:4px; cursor:pointer; }
|
||||
.adv-restore-banner__actions button:first-child { background:var(--teal); color:#fff; border:none; }
|
||||
.adv-restore-banner__actions button:last-child { background:transparent; border:1px solid var(--line); color:var(--muted); }
|
||||
.dr-collapsible > summary { list-style:none; cursor:pointer; display:flex; align-items:baseline; gap:12px; padding:8px 0; }
|
||||
.dr-collapsible > summary::-webkit-details-marker { display:none; }
|
||||
.dr-collapsible > summary h3 { margin:0; font-size:.95rem; }
|
||||
.dr-collapsible > summary::before { content:'▸'; font-size:.7rem; color:var(--muted); margin-right:4px; }
|
||||
.dr-collapsible[open] > summary::before { content:'▾'; }
|
||||
.adv-slice-hint { margin:8px 0 0; font-size:.83rem; color:var(--ink); background:var(--soft-teal); border:1px solid var(--teal); border-radius:6px; padding:8px 12px; display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
.adv-slice-hint button { font-size:.8rem; padding:2px 10px; border-radius:4px; cursor:pointer; }
|
||||
#advSliceHintEnable { background:var(--teal); color:#fff; border:none; }
|
||||
#advSliceHintDismiss { background:transparent; border:1px solid var(--line); color:var(--muted); }
|
||||
|
||||
+262
-30
@@ -381,8 +381,10 @@ let lastTimelineEvents = [];
|
||||
let lastTimelineEventsOriginal = [];
|
||||
let audioQueue = []; // [{file, status: 'pending'|'processing'|'done'|'error', result}]
|
||||
let lastTranscriptData = null;
|
||||
let lastRedactedText = null;
|
||||
let lastRunEngine = null;
|
||||
let lastRedactedText = null;
|
||||
let lastOriginalText = '';
|
||||
let lastRedactPayload = null;
|
||||
let lastRunEngine = null;
|
||||
|
||||
const VOCAB_PRESETS = {
|
||||
barnerett: 'Barnevernet, Fylkesnemnda, barnevernloven, barneloven, barnets beste, samvær, foreldreansvar, omsorgsovertakelse, sakkyndig, advokat, prosessfullmektig, dommer, vitne, tolk, bistandsadvokat, fosterforeldre, fosterhjem, akuttvedtak, statsforvalter, Bufetat, saksbehandler, rettslig medhold, begjæring, samtykke, tilsynsfører',
|
||||
@@ -933,11 +935,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
els.results?.addEventListener('click', (e) => {
|
||||
if (e.target.closest('#exportCsvBtn')) exportTimelineCSV(lastTimelineEvents);
|
||||
if (e.target.closest('#dlTxt')) downloadTranscriptTxt();
|
||||
if (e.target.closest('#dlSrt')) downloadTranscriptSrt();
|
||||
if (e.target.closest('#dlVtt')) downloadTranscriptVtt();
|
||||
if (e.target.closest('#txCopy')) copyTranscriptText();
|
||||
if (e.target.closest('#dlTxt')) downloadTranscriptTxt();
|
||||
if (e.target.closest('#dlSrt')) downloadTranscriptSrt();
|
||||
if (e.target.closest('#dlVtt')) downloadTranscriptVtt();
|
||||
if (e.target.closest('#rdlCopy')) copyRedactedText();
|
||||
if (e.target.closest('#rdlTxt')) downloadRedactedTxt();
|
||||
if (e.target.closest('#rdlTxt')) downloadRedactedTxt();
|
||||
if (e.target.closest('#rdlDocx')) downloadRedactedDocx();
|
||||
});
|
||||
const activeTool = document.body.dataset.activeTool || state.activeTool;
|
||||
@@ -1043,14 +1046,16 @@ async function runTool(event) {
|
||||
payload.limit = 7;
|
||||
}
|
||||
if (state.activeTool === 'redact') {
|
||||
payload.mode = currentRedactionMode();
|
||||
payload.region = currentRedactionRegion();
|
||||
payload.aliases = getAliases();
|
||||
payload.engine = currentRedactEngine();
|
||||
payload.output_format = currentOutputFormat();
|
||||
lastOriginalText = text;
|
||||
payload.mode = currentRedactionMode();
|
||||
payload.region = currentRedactionRegion();
|
||||
payload.aliases = getAliases();
|
||||
payload.engine = currentRedactEngine();
|
||||
payload.output_format = currentOutputFormat();
|
||||
payload.keep_officials = currentKeepOfficials();
|
||||
payload.exempt_names = getExemptNames();
|
||||
payload.redact_types = currentRedactTypes();
|
||||
payload.exempt_names = getExemptNames();
|
||||
payload.redact_types = currentRedactTypes();
|
||||
lastRedactPayload = { ...payload };
|
||||
}
|
||||
if (state.activeTool === 'timeline') {
|
||||
payload.engine = currentTimelineEngine();
|
||||
@@ -1330,6 +1335,105 @@ function renderResults(data) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Redact: tag colour helpers ─────────────────────────────────────────────
|
||||
|
||||
function tagColorClass(root) {
|
||||
if (/^(FATHER|MOTHER|CHILD|GRANDPARENT|SIBLING|ATTORNEY|JUDGE|CASEWORKER|EXPERT_WITNESS|PERSON)/.test(root)) return 'person';
|
||||
if (root === 'ORG') return 'org';
|
||||
if (root === 'PLACE') return 'place';
|
||||
if (/^(DATE|DOB)/.test(root)) return 'date';
|
||||
return 'id';
|
||||
}
|
||||
|
||||
function highlightRedactedText(text) {
|
||||
const escaped = escapeHtml(text);
|
||||
return escaped.replace(/\[([A-Z][A-Z0-9_-]*)(?::\s*([^\]]+))?\]/g, (_m, root, suffix) => {
|
||||
const cls = tagColorClass(root);
|
||||
const display = suffix ? `[${root}: ${suffix}]` : `[${root}]`;
|
||||
return `<span class="redact-tag redact-tag--${cls}">${display}</span>`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderRedactionInventory(redactionMap, entityCounts) {
|
||||
const map = redactionMap || {};
|
||||
const counts = entityCounts || {};
|
||||
const entries = [];
|
||||
|
||||
for (const [tag, info] of Object.entries(map)) {
|
||||
const root = tag.replace(/^\[|\]$/g, '').split(':')[0].trim();
|
||||
entries.push({ tag, originals: info.originals || [], occurrences: info.occurrences || 0, cls: tagColorClass(root) });
|
||||
}
|
||||
|
||||
const mappedTypes = new Set(Object.values(map).map(e => e.type));
|
||||
for (const [type, count] of Object.entries(counts)) {
|
||||
if (Number(count) > 0 && !mappedTypes.has(type)) {
|
||||
entries.push({ tag: type, originals: [], occurrences: Number(count), cls: tagColorClass(type.toUpperCase()) });
|
||||
}
|
||||
}
|
||||
|
||||
if (!entries.length) return '';
|
||||
|
||||
const rows = entries.map(e => {
|
||||
const tagSpan = `<span class="redact-tag redact-tag--${e.cls}">${escapeHtml(e.tag)}</span>`;
|
||||
const originalsHtml = e.originals.length
|
||||
? `<span class="inv-originals">→ ${e.originals.map(o => `<em>${escapeHtml(o)}</em>`).join(', ')}</span> `
|
||||
: '';
|
||||
const countHtml = e.occurrences > 0 ? `<span class="inv-count">${e.occurrences}×</span>` : '';
|
||||
return `<li>${tagSpan} ${originalsHtml}${countHtml}</li>`;
|
||||
}).join('');
|
||||
|
||||
return `<details class="redact-inventory" open>
|
||||
<summary>Redaction inventory <span class="inv-badge">${entries.length}</span></summary>
|
||||
<ul class="inv-list">${rows}</ul>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
async function rerunWithBetterEngine() {
|
||||
if (!lastRedactPayload) return;
|
||||
const btn = document.getElementById('rerunBetterBtn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Running…'; }
|
||||
const payload = { ...lastRedactPayload, engine: 'azure_full' };
|
||||
lastRedactPayload = payload;
|
||||
setBusy(true);
|
||||
renderTrace([{ label: 'Re-run with gpt-4o', detail: 'Submitting same text with Azure gpt-4o engine.', status: 'running' }]);
|
||||
try {
|
||||
const data = await postJson('api/redact.php', payload);
|
||||
if (!data.ok) throw new Error(data.error?.message || 'Re-run failed.');
|
||||
renderResults(data);
|
||||
renderTrace(data.trace || []);
|
||||
els.status.textContent = `Done in ${data.latency_ms || 0} ms.`;
|
||||
} catch (error) {
|
||||
els.status.textContent = error.message;
|
||||
renderTrace([{ label: 'Tool error', detail: error.message, status: 'warning' }]);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function setupRedactViewToggle() {
|
||||
const pre = document.getElementById('redactOutputPre');
|
||||
const btnRedacted = document.getElementById('viewRedacted');
|
||||
const btnOriginal = document.getElementById('viewOriginal');
|
||||
const rerunBtn = document.getElementById('rerunBetterBtn');
|
||||
if (btnRedacted && pre) {
|
||||
btnRedacted.addEventListener('click', () => {
|
||||
pre.innerHTML = highlightRedactedText(lastRedactedText || '');
|
||||
btnRedacted.classList.add('is-active');
|
||||
if (btnOriginal) btnOriginal.classList.remove('is-active');
|
||||
});
|
||||
}
|
||||
if (btnOriginal && pre) {
|
||||
btnOriginal.addEventListener('click', () => {
|
||||
pre.textContent = lastOriginalText || '';
|
||||
btnOriginal.classList.add('is-active');
|
||||
if (btnRedacted) btnRedacted.classList.remove('is-active');
|
||||
});
|
||||
}
|
||||
if (rerunBtn) rerunBtn.addEventListener('click', rerunWithBetterEngine);
|
||||
}
|
||||
|
||||
// ── Main finding renderer ───────────────────────────────────────────────────
|
||||
|
||||
function renderMainFinding(data) {
|
||||
if (data.tool === 'ask') {
|
||||
return `<p class="answer">${escapeHtml(data.answer || data.what_we_found || '')}</p>`;
|
||||
@@ -1337,12 +1441,26 @@ function renderMainFinding(data) {
|
||||
if (data.tool === 'redact') {
|
||||
lastRedactedText = data.redacted_text || '';
|
||||
const t = (k) => currentRedactT(k) || k;
|
||||
|
||||
const viewToggle = `<div class="redact-view-toggle">
|
||||
<button type="button" class="view-btn is-active" id="viewRedacted">Redacted</button>
|
||||
<button type="button" class="view-btn" id="viewOriginal">Original</button>
|
||||
</div>`;
|
||||
|
||||
const inventoryHtml = renderRedactionInventory(data.redaction_map, data.entity_counts);
|
||||
|
||||
const isNotBestEngine = data.engine_used && data.engine_used !== 'Azure gpt-4o' && data.engine_used !== 'gpt-4o';
|
||||
const upgradeBtn = isNotBestEngine
|
||||
? `<button type="button" class="upgrade-engine-btn" id="rerunBetterBtn">Re-run with gpt-4o for higher accuracy →</button>`
|
||||
: '';
|
||||
|
||||
const dlRow = `<div class="redact-downloads">
|
||||
<button type="button" class="redact-dl-btn" id="rdlCopy">${t('redactCopy')}</button>
|
||||
<button type="button" class="redact-dl-btn" id="rdlTxt">${t('redactDownloadTxt')}</button>
|
||||
<button type="button" class="redact-dl-btn" id="rdlDocx">${t('redactDownloadDocx')}</button>
|
||||
</div>`;
|
||||
return `<pre class="redacted-output">${escapeHtml(lastRedactedText)}</pre>${renderEntityCounts(data.entity_counts)}${dlRow}`;
|
||||
|
||||
return `${viewToggle}<pre class="redacted-output" id="redactOutputPre">${highlightRedactedText(lastRedactedText)}</pre>${inventoryHtml}${upgradeBtn}${dlRow}`;
|
||||
}
|
||||
if (data.tool === 'timeline') {
|
||||
lastTimelineEventsOriginal = data.events || [];
|
||||
@@ -1520,6 +1638,36 @@ function currentTask() {
|
||||
return el ? el.value : 'transcribe';
|
||||
}
|
||||
|
||||
function buildTranscribeError(err, file) {
|
||||
const name = file?.name ?? 'file';
|
||||
const sizeMB = file?.size ? (file.size / 1024 / 1024).toFixed(1) : null;
|
||||
let reason = err?.message || 'Unknown error';
|
||||
if (/too large|size/i.test(reason) || reason.includes('413'))
|
||||
reason = `File too large${sizeMB ? ` (${sizeMB} MB)` : ''}`;
|
||||
else if (/format|unsupported|415/i.test(reason))
|
||||
reason = 'Unsupported audio format';
|
||||
else if (/timeout|timed out|504/i.test(reason))
|
||||
reason = 'Request timed out — try a shorter clip';
|
||||
else if (/50[0-9]/.test(reason))
|
||||
reason = 'Server error';
|
||||
return `${name}: ${reason}`;
|
||||
}
|
||||
|
||||
function showTranscribeProgress(clip, total) {
|
||||
if (!els.results) return;
|
||||
const pct = total > 1 ? Math.round(((clip - 1) / total) * 100) : null;
|
||||
const label = total > 1 ? `Clip ${clip} / ${total}` : 'Transcribing…';
|
||||
const barClass = pct !== null ? '' : ' is-indeterminate';
|
||||
const barStyle = pct !== null ? ` style="width:${pct}%"` : '';
|
||||
els.results.innerHTML = `
|
||||
<div class="transcribe-progress-wrap">
|
||||
<div class="transcribe-progress-track">
|
||||
<div class="transcribe-progress-fill${barClass}"${barStyle}></div>
|
||||
</div>
|
||||
<p class="transcribe-progress-label">${escapeHtml(label)}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function runTranscribe() {
|
||||
if (!audioQueue.length) {
|
||||
els.status.textContent = currentUiT('noFileSelected');
|
||||
@@ -1535,7 +1683,7 @@ async function runTranscribe() {
|
||||
const total = audioQueue.length;
|
||||
|
||||
// Reset all items to pending before starting
|
||||
audioQueue.forEach((item) => { item.status = 'pending'; item.result = null; });
|
||||
audioQueue.forEach((item) => { item.status = 'pending'; item.result = null; item.errorMsg = null; });
|
||||
renderAudioQueue();
|
||||
|
||||
let cumulativeOffset = 0;
|
||||
@@ -1549,6 +1697,9 @@ async function runTranscribe() {
|
||||
item.status = 'processing';
|
||||
renderAudioQueue();
|
||||
|
||||
// Show progress bar
|
||||
showTranscribeProgress(i + 1, total);
|
||||
|
||||
const startTime = Date.now();
|
||||
let elapsed = 0;
|
||||
const clipLabel = currentUiT('clipLabel', i + 1, total);
|
||||
@@ -1609,17 +1760,25 @@ async function runTranscribe() {
|
||||
} catch (err) {
|
||||
clearInterval(timer);
|
||||
item.status = 'error';
|
||||
item.errorMsg = buildTranscribeError(err, item.file);
|
||||
renderAudioQueue();
|
||||
els.status.textContent = `${clipLabel}: ${err.message}`;
|
||||
renderTrace([{ label: currentUiT('errorLabel', clipLabel), detail: err.message, status: 'warning' }]);
|
||||
setBusy(false);
|
||||
return;
|
||||
// Continue processing remaining clips rather than halting
|
||||
}
|
||||
|
||||
renderAudioQueue();
|
||||
}
|
||||
|
||||
// Merge results
|
||||
// Merge results from successful clips
|
||||
const errorItems = audioQueue.filter((it) => it.status === 'error');
|
||||
if (errorItems.length && !lastResult) {
|
||||
// All clips failed
|
||||
const errList = errorItems.map((it) => it.errorMsg || it.file.name).join('; ');
|
||||
els.status.textContent = `Failed: ${errList}`;
|
||||
renderTrace([{ label: 'Transcription failed', detail: errList, status: 'warning' }]);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const merged = {
|
||||
...lastResult,
|
||||
transcript: allTranscripts.join('\n\n'),
|
||||
@@ -1636,7 +1795,12 @@ async function runTranscribe() {
|
||||
const totalMin = Math.floor(totalSec / 60);
|
||||
const remSec = totalSec % 60;
|
||||
const durLabel = totalMin > 0 ? `${totalMin}m ${remSec}s` : `${totalSec}s`;
|
||||
els.status.textContent = currentUiT('done', total, durLabel);
|
||||
if (errorItems.length) {
|
||||
const errList = errorItems.map((it) => it.errorMsg || it.file.name).join('; ');
|
||||
els.status.textContent = `Done (${errorItems.length} failed: ${errList})`;
|
||||
} else {
|
||||
els.status.textContent = currentUiT('done', total, durLabel);
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -1662,6 +1826,7 @@ function renderTranscriptResults(data) {
|
||||
|
||||
const speakerOrder = [...new Set(segments.filter((s) => s.speaker).map((s) => s.speaker))];
|
||||
|
||||
// Speaker role tags (shown above transcript)
|
||||
const rolesHtml = speakerOrder.length
|
||||
? `<p class="transcript-roles">${speakerOrder.map((id, i) => {
|
||||
const role = speakerRoles[id] || id;
|
||||
@@ -1669,8 +1834,17 @@ function renderTranscriptResults(data) {
|
||||
}).join('')}</p>`
|
||||
: '';
|
||||
|
||||
// Segments panel — includes inline speaker legend at the top
|
||||
const legendHtml = speakerOrder.length
|
||||
? `<div class="segment-legend">${speakerOrder.map((id, i) => {
|
||||
const role = speakerRoles[id] || id;
|
||||
return `<span class="speaker-tag speaker-tag--${i % 6}">${escapeHtml(role)}<small>${escapeHtml(id)}</small></span>`;
|
||||
}).join('')}</div>`
|
||||
: '';
|
||||
|
||||
const segmentsHtml = hasSpeakers
|
||||
? `<details class="segment-details"><summary class="segment-summary">Segments (${segments.length})</summary>
|
||||
${legendHtml}
|
||||
<div class="segment-list">${segments.map((seg) => {
|
||||
const idx = speakerOrder.indexOf(seg.speaker);
|
||||
const roleLabel = seg.speaker && speakerRoles[seg.speaker]
|
||||
@@ -1684,36 +1858,83 @@ function renderTranscriptResults(data) {
|
||||
}).join('')}</div></details>`
|
||||
: '';
|
||||
|
||||
// SRT/VTT downloads (only if segments available)
|
||||
const dlSrtVtt = segments.length
|
||||
? `<button type="button" class="export-csv-btn" id="dlSrt">Download SRT</button>
|
||||
<button type="button" class="export-csv-btn" id="dlVtt">Download VTT</button>`
|
||||
? `<div class="transcript-downloads">
|
||||
<button type="button" class="export-csv-btn" id="dlSrt">Download SRT</button>
|
||||
<button type="button" class="export-csv-btn" id="dlVtt">Download VTT</button>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
// Meta stats row
|
||||
const durStr = data.duration_sec ? `⏱ ${fmtDuration(data.duration_sec)}` : null;
|
||||
const langStr = data.language ? `🌐 ${data.language.toUpperCase()}` : null;
|
||||
const spkStr = data.num_speakers > 1 ? `🗣 ${data.num_speakers} speakers` : null;
|
||||
const metaParts = [durStr, langStr, spkStr].filter(Boolean);
|
||||
const metaRow = metaParts.length
|
||||
? `<div class="transcript-meta-row">${metaParts.map((p) => `<span>${p}</span>`).join('')}</div>`
|
||||
: '';
|
||||
|
||||
// AI cleanup badge inline with engine label
|
||||
const cleanupBadge = data.cleaned_by
|
||||
? ` <span class="cleanup-badge">✓ Cleaned by ${escapeHtml(data.cleaned_by)}</span>`
|
||||
: '';
|
||||
const engineLine = data.model
|
||||
? `<p class="transcript-engine-badge">Transcribed with <strong>${escapeHtml(data.model)}</strong>${cleanupBadge}</p>`
|
||||
: '';
|
||||
|
||||
// Action row above transcript (copy + TXT download)
|
||||
const actionRow = `
|
||||
<div class="transcript-actions">
|
||||
<button type="button" class="export-csv-btn" id="txCopy">Copy</button>
|
||||
<button type="button" class="export-csv-btn" id="dlTxt">Download TXT</button>
|
||||
</div>`;
|
||||
|
||||
lastRunEngine = data.engine || null;
|
||||
els.results.innerHTML = `
|
||||
<section class="result-section">
|
||||
<h3>Transcript</h3>
|
||||
${data.model ? `<p class="transcript-engine-badge">Transcribed with <strong>${escapeHtml(data.model)}</strong></p>` : ''}
|
||||
${engineLine}
|
||||
${metaRow}
|
||||
${rolesHtml}
|
||||
${actionRow}
|
||||
<div class="transcript-box"><pre class="transcript-text">${escapeHtml(data.transcript)}</pre></div>
|
||||
${segmentsHtml}
|
||||
<div class="transcript-downloads">
|
||||
<button type="button" class="export-csv-btn" id="dlTxt">Download TXT</button>
|
||||
${dlSrtVtt}
|
||||
</div>
|
||||
${dlSrtVtt}
|
||||
</section>
|
||||
${renderFeedbackWidget()}`;
|
||||
setupFeedbackWidget('transcribe');
|
||||
|
||||
const traceMeta = [];
|
||||
if (data.duration_sec) traceMeta.push({ label: `Duration: ${Math.round(data.duration_sec)}s`, detail: '', status: 'complete' });
|
||||
if (data.duration_sec) traceMeta.push({ label: `Duration: ${fmtDuration(data.duration_sec)}`, detail: '', status: 'complete' });
|
||||
if (data.language) traceMeta.push({ label: `Language: ${data.language}`, detail: '', status: 'complete' });
|
||||
if (data.num_speakers > 1) traceMeta.push({ label: `Speakers detected: ${data.num_speakers}`, detail: Object.entries(speakerRoles).map(([id, r]) => `${id}: ${r}`).join(', ') || '', status: 'complete' });
|
||||
if (data.num_speakers > 1) traceMeta.push({ label: `Speakers: ${data.num_speakers}`, detail: Object.entries(speakerRoles).map(([id, r]) => `${id}: ${r}`).join(', ') || '', status: 'complete' });
|
||||
if (data.model) traceMeta.push({ label: data.model, detail: '', status: 'complete' });
|
||||
if (data.cleaned_by) traceMeta.push({ label: `Cleaned by ${data.cleaned_by}`, detail: '', status: 'complete' });
|
||||
renderTrace(traceMeta.length ? traceMeta : [{ label: 'Transcribed', detail: '', status: 'complete' }]);
|
||||
}
|
||||
|
||||
function fmtDuration(secs) {
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = Math.round(secs % 60);
|
||||
return m > 0 ? `${m}m ${s}s` : `${s}s`;
|
||||
}
|
||||
|
||||
async function copyTranscriptText() {
|
||||
if (!lastTranscriptData?.transcript) return;
|
||||
const btn = document.getElementById('txCopy');
|
||||
try {
|
||||
await navigator.clipboard.writeText(lastTranscriptData.transcript);
|
||||
if (btn) {
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => { btn.textContent = orig; }, 1800);
|
||||
}
|
||||
} catch {
|
||||
// clipboard unavailable — silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(secs) {
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
@@ -1859,6 +2080,16 @@ function setupTranscribeControls() {
|
||||
|
||||
function setupVocabPresets() {
|
||||
if (!els.vocabPresets) return;
|
||||
|
||||
const vocabCountEl = document.getElementById('vocabCharCount');
|
||||
function updateVocabCount() {
|
||||
if (!vocabCountEl || !els.initPromptInput) return;
|
||||
const n = els.initPromptInput.value.length;
|
||||
vocabCountEl.textContent = n;
|
||||
vocabCountEl.closest('small')?.classList.toggle('vocab-char-count--warn', n > 450);
|
||||
}
|
||||
els.initPromptInput?.addEventListener('input', updateVocabCount);
|
||||
|
||||
els.vocabPresets.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.vocab-btn');
|
||||
if (!btn) return;
|
||||
@@ -1868,6 +2099,7 @@ function setupVocabPresets() {
|
||||
els.vocabPresets.querySelectorAll('.vocab-btn').forEach((b) => b.classList.remove('is-active'));
|
||||
btn.classList.add('is-active');
|
||||
if (preset !== 'custom') els.initPromptInput.focus();
|
||||
updateVocabCount();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user