Add full transcript viewer with clickable timestamps
This commit is contained in:
144
static/app.js
144
static/app.js
@@ -303,8 +303,140 @@
|
|||||||
return n;
|
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) {
|
function renderMetrics(data) {
|
||||||
if (!metricsContent) return;
|
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);
|
resultsDiv.appendChild(el);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -223,3 +223,94 @@ mark {
|
|||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
font-size: 12px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user