Add full transcript viewer with clickable timestamps
This commit is contained in:
parent
fcdc6ecb9b
commit
a3c9377ef7
144
static/app.js
144
static/app.js
@ -303,8 +303,140 @@
|
||||
return n;
|
||||
}
|
||||
|
||||
function formatTimestamp(seconds) {
|
||||
if (!seconds && seconds !== 0) return "00:00";
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
// Transcript viewer functionality removed.
|
||||
if (hours > 0) {
|
||||
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getYouTubeTimestampUrl(baseUrl, seconds) {
|
||||
if (!baseUrl) return '#';
|
||||
const url = new URL(baseUrl);
|
||||
url.searchParams.set('t', Math.floor(seconds) + 's');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function renderTranscriptSegment(segment, videoUrl) {
|
||||
const segmentDiv = document.createElement('div');
|
||||
segmentDiv.className = 'transcript-segment';
|
||||
|
||||
const startSeconds = segment.start_seconds || segment.start || 0;
|
||||
const timestampText = formatTimestamp(startSeconds);
|
||||
const timestampUrl = getYouTubeTimestampUrl(videoUrl, startSeconds);
|
||||
|
||||
const timestampLink = document.createElement('a');
|
||||
timestampLink.href = timestampUrl;
|
||||
timestampLink.className = 'timestamp-link';
|
||||
timestampLink.textContent = timestampText;
|
||||
timestampLink.target = '_blank';
|
||||
timestampLink.rel = 'noopener';
|
||||
|
||||
const textSpan = document.createElement('span');
|
||||
textSpan.className = 'transcript-text';
|
||||
textSpan.textContent = segment.text || '';
|
||||
|
||||
segmentDiv.appendChild(timestampLink);
|
||||
segmentDiv.appendChild(textSpan);
|
||||
|
||||
return segmentDiv;
|
||||
}
|
||||
|
||||
async function fetchAndDisplayTranscript(videoId, videoUrl, containerElement, button) {
|
||||
const existingTranscript = containerElement.querySelector('.full-transcript');
|
||||
if (existingTranscript) {
|
||||
existingTranscript.remove();
|
||||
button.textContent = 'View Full Transcript';
|
||||
return;
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = 'Loading...';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/transcript?video_id=${encodeURIComponent(videoId)}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch transcript: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const transcriptDiv = document.createElement('div');
|
||||
transcriptDiv.className = 'full-transcript';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'transcript-header';
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.textContent = 'Full Transcript';
|
||||
|
||||
const closeBtn = document.createElement('span');
|
||||
closeBtn.className = 'transcript-close';
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.title = 'Close transcript';
|
||||
closeBtn.onclick = () => {
|
||||
transcriptDiv.remove();
|
||||
button.textContent = 'View Full Transcript';
|
||||
button.disabled = false;
|
||||
};
|
||||
|
||||
header.appendChild(title);
|
||||
header.appendChild(closeBtn);
|
||||
transcriptDiv.appendChild(header);
|
||||
|
||||
const primaryParts = data.transcript_parts || [];
|
||||
const secondaryParts = data.transcript_secondary_parts || [];
|
||||
|
||||
if (!primaryParts.length && !secondaryParts.length) {
|
||||
const noTranscript = document.createElement('div');
|
||||
noTranscript.className = 'muted';
|
||||
noTranscript.textContent = 'No transcript available for this video.';
|
||||
transcriptDiv.appendChild(noTranscript);
|
||||
} else {
|
||||
if (primaryParts.length) {
|
||||
const primaryHeader = document.createElement('div');
|
||||
primaryHeader.style.marginBottom = '8px';
|
||||
primaryHeader.style.fontWeight = 'bold';
|
||||
primaryHeader.style.fontSize = '12px';
|
||||
primaryHeader.style.color = '#666';
|
||||
primaryHeader.textContent = 'Primary Transcript';
|
||||
transcriptDiv.appendChild(primaryHeader);
|
||||
|
||||
primaryParts.forEach(segment => {
|
||||
transcriptDiv.appendChild(renderTranscriptSegment(segment, videoUrl));
|
||||
});
|
||||
}
|
||||
|
||||
if (secondaryParts.length) {
|
||||
const secondaryHeader = document.createElement('div');
|
||||
secondaryHeader.style.marginTop = primaryParts.length ? '16px' : '0';
|
||||
secondaryHeader.style.marginBottom = '8px';
|
||||
secondaryHeader.style.fontWeight = 'bold';
|
||||
secondaryHeader.style.fontSize = '12px';
|
||||
secondaryHeader.style.color = '#666';
|
||||
secondaryHeader.textContent = 'Secondary Transcript';
|
||||
transcriptDiv.appendChild(secondaryHeader);
|
||||
|
||||
secondaryParts.forEach(segment => {
|
||||
transcriptDiv.appendChild(renderTranscriptSegment(segment, videoUrl));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
containerElement.appendChild(transcriptDiv);
|
||||
button.textContent = 'Hide Transcript';
|
||||
button.disabled = false;
|
||||
} catch (err) {
|
||||
console.error('Error fetching transcript:', err);
|
||||
button.textContent = 'View Full Transcript';
|
||||
button.disabled = false;
|
||||
alert('Failed to load transcript. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
function renderMetrics(data) {
|
||||
if (!metricsContent) return;
|
||||
@ -626,6 +758,16 @@ async function updateFrequencyChart(term, channels, queryMode) {
|
||||
}
|
||||
}
|
||||
|
||||
if (item.video_id) {
|
||||
const transcriptBtn = document.createElement("button");
|
||||
transcriptBtn.className = "transcript-toggle";
|
||||
transcriptBtn.textContent = "View Full Transcript";
|
||||
transcriptBtn.onclick = () => {
|
||||
fetchAndDisplayTranscript(item.video_id, item.url, el, transcriptBtn);
|
||||
};
|
||||
el.appendChild(transcriptBtn);
|
||||
}
|
||||
|
||||
resultsDiv.appendChild(el);
|
||||
});
|
||||
|
||||
|
||||
@ -223,3 +223,94 @@ mark {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.transcript-toggle {
|
||||
margin-top: 8px;
|
||||
padding: 6px 12px;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #0366d6;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.transcript-toggle:hover {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.transcript-toggle:disabled {
|
||||
cursor: not-allowed;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.full-transcript {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #e1e1e1;
|
||||
border-radius: 4px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.transcript-segment {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #ececec;
|
||||
}
|
||||
|
||||
.transcript-segment:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.timestamp-link {
|
||||
display: inline-block;
|
||||
color: #0366d6;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
margin-right: 8px;
|
||||
padding: 2px 6px;
|
||||
background: #e8f4ff;
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.timestamp-link:hover {
|
||||
background: #cce5ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.transcript-text {
|
||||
color: #333;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.transcript-header {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
color: #444;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.transcript-close {
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.transcript-close:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user