diff --git a/static/app.js b/static/app.js index 22e259e..e1e7cec 100644 --- a/static/app.js +++ b/static/app.js @@ -325,6 +325,7 @@ function renderTranscriptSegment(segment, videoUrl) { const segmentDiv = document.createElement('div'); segmentDiv.className = 'transcript-segment'; + segmentDiv.dataset.text = (segment.text || '').toLowerCase(); const startSeconds = segment.start_seconds || segment.start || 0; const timestampText = formatTimestamp(startSeconds); @@ -347,14 +348,88 @@ return segmentDiv; } - async function fetchAndDisplayTranscript(videoId, videoUrl, containerElement, button) { + function stripHtmlAndNormalize(html) { + const temp = document.createElement('div'); + temp.innerHTML = html; + return temp.textContent.trim().toLowerCase().replace(/\s+/g, ' '); + } + + function findMatchingSegment(transcriptDiv, searchText) { + const segments = transcriptDiv.querySelectorAll('.transcript-segment'); + const normalized = searchText.toLowerCase().replace(/\s+/g, ' ').trim(); + + // First try exact match + for (const segment of segments) { + const segmentText = segment.dataset.text; + if (segmentText && segmentText.includes(normalized)) { + return segment; + } + } + + // If no exact match, try matching by words (at least 70% of words match) + const searchWords = normalized.split(' ').filter(w => w.length > 2); + if (searchWords.length === 0) return null; + + let bestMatch = null; + let bestScore = 0; + + for (const segment of segments) { + const segmentText = segment.dataset.text; + if (!segmentText) continue; + + let matchCount = 0; + for (const word of searchWords) { + if (segmentText.includes(word)) { + matchCount++; + } + } + + const score = matchCount / searchWords.length; + if (score > bestScore && score >= 0.5) { + bestScore = score; + bestMatch = segment; + } + } + + return bestMatch; + } + + function scrollToSegment(segment) { + if (!segment) return; + + // Remove any existing focus + const previousFocused = segment.parentElement.querySelectorAll('.transcript-segment.focused'); + previousFocused.forEach(s => s.classList.remove('focused')); + + // Add focus to this segment + segment.classList.add('focused'); + + // Scroll to it + segment.scrollIntoView({ behavior: 'smooth', block: 'center' }); + + // Remove focus after animation + setTimeout(() => { + segment.classList.remove('focused'); + }, 3000); + } + + async function fetchAndDisplayTranscript(videoId, videoUrl, containerElement, button, highlightText = null) { const existingTranscript = containerElement.querySelector('.full-transcript'); - if (existingTranscript) { + if (existingTranscript && !highlightText) { existingTranscript.remove(); button.textContent = 'View Full Transcript'; return; } + // If transcript exists and we have highlight text, just scroll to it + if (existingTranscript && highlightText) { + const segment = findMatchingSegment(existingTranscript, highlightText); + if (segment) { + scrollToSegment(segment); + } + return; + } + button.disabled = true; button.textContent = 'Loading...'; @@ -430,6 +505,16 @@ containerElement.appendChild(transcriptDiv); button.textContent = 'Hide Transcript'; button.disabled = false; + + // If highlight text provided, scroll to it after a brief delay + if (highlightText) { + setTimeout(() => { + const segment = findMatchingSegment(transcriptDiv, highlightText); + if (segment) { + scrollToSegment(segment); + } + }, 100); + } } catch (err) { console.error('Error fetching transcript:', err); button.textContent = 'View Full Transcript'; @@ -751,6 +836,17 @@ async function updateFrequencyChart(term, channels, queryMode) { const row = document.createElement("div"); row.className = "highlight-row"; row.innerHTML = html; + row.title = "Click to jump to this location in the transcript"; + + // Make highlight clickable + row.onclick = () => { + const transcriptBtn = el.querySelector(".transcript-toggle"); + if (transcriptBtn && item.video_id) { + const highlightText = stripHtmlAndNormalize(html); + fetchAndDisplayTranscript(item.video_id, item.url, el, transcriptBtn, highlightText); + } + }; + highlights.appendChild(row); }); if (highlights.childElementCount) { diff --git a/static/style.css b/static/style.css index 1d7ac30..6eade4a 100644 --- a/static/style.css +++ b/static/style.css @@ -187,6 +187,12 @@ button { .highlight-row { padding: 4px 0; border-bottom: 1px solid #ececec; + cursor: pointer; + transition: background 0.2s; +} + +.highlight-row:hover { + background: #f0f7ff; } .highlight-row:last-child { @@ -259,6 +265,8 @@ mark { margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #ececec; + transition: background 0.3s, padding 0.3s; + border-radius: 4px; } .transcript-segment:last-child { @@ -266,6 +274,22 @@ mark { margin-bottom: 0; } +.transcript-segment.focused { + background: #fff3cd; + padding: 8px; + border: 2px solid #ffc107; + animation: pulse-highlight 1s ease-in-out; +} + +@keyframes pulse-highlight { + 0%, 100% { + background: #fff3cd; + } + 50% { + background: #ffe69c; + } +} + .timestamp-link { display: inline-block; color: #0366d6;