876 lines
28 KiB
JavaScript
876 lines
28 KiB
JavaScript
(() => {
|
||
let qs = new URLSearchParams(window.location.search);
|
||
const qInput = document.getElementById("q");
|
||
const channelDropdown = document.getElementById("channelDropdown");
|
||
const channelSummary = document.getElementById("channelSummary");
|
||
const channelOptions = document.getElementById("channelOptions");
|
||
const sortSel = document.getElementById("sort");
|
||
const sizeSel = document.getElementById("size");
|
||
const exactToggle = document.getElementById("exactToggle");
|
||
const fuzzyToggle = document.getElementById("fuzzyToggle");
|
||
const phraseToggle = document.getElementById("phraseToggle");
|
||
const queryToggle = document.getElementById("queryStringToggle");
|
||
const searchBtn = document.getElementById("searchBtn");
|
||
const resultsDiv = document.getElementById("results");
|
||
const metaDiv = document.getElementById("meta");
|
||
const metricsContainer = document.getElementById("metrics");
|
||
const metricsStatus = document.getElementById("metricsStatus");
|
||
const metricsContent = document.getElementById("metricsContent");
|
||
const freqSummary = document.getElementById("frequencySummary");
|
||
const freqChart = document.getElementById("frequencyChart");
|
||
const channelMap = new Map();
|
||
const selectedChannels = new Set();
|
||
let pendingChannelSelection = [];
|
||
let channelsReady = false;
|
||
let suppressChannelChange = false;
|
||
let allChannelsCheckbox = null;
|
||
let previousToggleState = { exact: true, fuzzy: true, phrase: true };
|
||
let currentPage =
|
||
parseInt(qs.get("page") || "0", 10) ||
|
||
0;
|
||
|
||
function parseBoolParam(name, defaultValue) {
|
||
const raw = qs.get(name);
|
||
if (raw === null) return defaultValue;
|
||
const lowered = raw.toLowerCase();
|
||
return !["0", "false", "no"].includes(lowered);
|
||
}
|
||
|
||
function parseChannelParams(params) {
|
||
const collected = [];
|
||
if (!params) return collected;
|
||
const seen = new Set();
|
||
const rawValues = params.getAll("channel_id");
|
||
const legacy = params.get("channel");
|
||
if (legacy) rawValues.push(legacy);
|
||
rawValues.forEach((value) => {
|
||
if (value == null) return;
|
||
String(value)
|
||
.split(",")
|
||
.map((part) => part.trim())
|
||
.filter((part) => part && part.toLowerCase() !== "all")
|
||
.forEach((part) => {
|
||
if (!seen.has(part)) {
|
||
seen.add(part);
|
||
collected.push(part);
|
||
}
|
||
});
|
||
});
|
||
return collected;
|
||
}
|
||
|
||
function getSelectedChannels() {
|
||
return Array.from(selectedChannels);
|
||
}
|
||
|
||
function ensureAllCheckboxState() {
|
||
if (allChannelsCheckbox) {
|
||
allChannelsCheckbox.checked = selectedChannels.size === 0;
|
||
}
|
||
}
|
||
|
||
function updateChannelSummary() {
|
||
if (!channelSummary) return;
|
||
if (!selectedChannels.size) {
|
||
channelSummary.textContent = "All Channels";
|
||
return;
|
||
}
|
||
const names = Array.from(selectedChannels).map(
|
||
(id) => channelMap.get(id) || id
|
||
);
|
||
if (names.length > 1) {
|
||
names.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
|
||
}
|
||
let label = names.slice(0, 3).join(", ");
|
||
if (names.length > 3) {
|
||
label += ` +${names.length - 3} more`;
|
||
}
|
||
channelSummary.textContent = label;
|
||
}
|
||
|
||
function applyChannelSelection(ids, { silent = false } = {}) {
|
||
selectedChannels.clear();
|
||
ids.forEach((id) => selectedChannels.add(id));
|
||
pendingChannelSelection = getSelectedChannels();
|
||
ensureAllCheckboxState();
|
||
if (channelOptions) {
|
||
suppressChannelChange = true;
|
||
const checkboxes = channelOptions.querySelectorAll(
|
||
'input[type="checkbox"][data-channel="1"]'
|
||
);
|
||
checkboxes.forEach((checkbox) => {
|
||
checkbox.checked = selectedChannels.has(checkbox.value);
|
||
});
|
||
suppressChannelChange = false;
|
||
}
|
||
updateChannelSummary();
|
||
if (!silent && channelsReady) {
|
||
runSearch(0);
|
||
}
|
||
}
|
||
|
||
function setFromQuery() {
|
||
qInput.value = qs.get("q") || "";
|
||
sortSel.value = qs.get("sort") || "relevant";
|
||
sizeSel.value = qs.get("size") || "10";
|
||
pendingChannelSelection = parseChannelParams(qs);
|
||
applyChannelSelection(pendingChannelSelection, { silent: true });
|
||
exactToggle.checked = parseBoolParam("exact", true);
|
||
fuzzyToggle.checked = parseBoolParam("fuzzy", true);
|
||
phraseToggle.checked = parseBoolParam("phrase", true);
|
||
queryToggle.checked = parseBoolParam("query_string", false);
|
||
applyQueryMode();
|
||
rememberToggleState();
|
||
}
|
||
|
||
function applyQueryMode() {
|
||
if (!queryToggle) return;
|
||
if (queryToggle.checked) {
|
||
if (!exactToggle.disabled) {
|
||
previousToggleState = {
|
||
exact: exactToggle.checked,
|
||
fuzzy: fuzzyToggle.checked,
|
||
phrase: phraseToggle.checked,
|
||
};
|
||
}
|
||
exactToggle.checked = false;
|
||
fuzzyToggle.checked = false;
|
||
phraseToggle.checked = false;
|
||
exactToggle.disabled = true;
|
||
fuzzyToggle.disabled = true;
|
||
phraseToggle.disabled = true;
|
||
} else {
|
||
exactToggle.disabled = false;
|
||
fuzzyToggle.disabled = false;
|
||
phraseToggle.disabled = false;
|
||
exactToggle.checked = previousToggleState.exact;
|
||
fuzzyToggle.checked = previousToggleState.fuzzy;
|
||
phraseToggle.checked = previousToggleState.phrase;
|
||
}
|
||
}
|
||
|
||
function rememberToggleState() {
|
||
if (queryToggle && !queryToggle.checked) {
|
||
previousToggleState = {
|
||
exact: !!exactToggle.checked,
|
||
fuzzy: !!fuzzyToggle.checked,
|
||
phrase: !!phraseToggle.checked,
|
||
};
|
||
}
|
||
}
|
||
|
||
if (channelOptions) {
|
||
channelOptions.addEventListener("change", (event) => {
|
||
const target = event.target;
|
||
if (!(target instanceof HTMLInputElement) || target.type !== "checkbox") {
|
||
return;
|
||
}
|
||
if (suppressChannelChange) {
|
||
return;
|
||
}
|
||
if (target.dataset.all === "1") {
|
||
if (!target.checked && !selectedChannels.size) {
|
||
suppressChannelChange = true;
|
||
target.checked = true;
|
||
suppressChannelChange = false;
|
||
return;
|
||
}
|
||
if (target.checked) {
|
||
selectedChannels.clear();
|
||
pendingChannelSelection = [];
|
||
suppressChannelChange = true;
|
||
const others = channelOptions.querySelectorAll(
|
||
'input[type="checkbox"][data-channel="1"]'
|
||
);
|
||
others.forEach((checkbox) => {
|
||
checkbox.checked = false;
|
||
});
|
||
suppressChannelChange = false;
|
||
ensureAllCheckboxState();
|
||
updateChannelSummary();
|
||
if (channelsReady) {
|
||
runSearch(0);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
const id = target.value;
|
||
if (!id) return;
|
||
if (target.checked) {
|
||
selectedChannels.add(id);
|
||
} else {
|
||
selectedChannels.delete(id);
|
||
}
|
||
pendingChannelSelection = getSelectedChannels();
|
||
ensureAllCheckboxState();
|
||
updateChannelSummary();
|
||
if (channelsReady) {
|
||
runSearch(0);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function loadChannels() {
|
||
if (!channelOptions) {
|
||
channelsReady = true;
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch("/api/channels");
|
||
const data = await res.json();
|
||
channelMap.clear();
|
||
channelOptions.innerHTML = "";
|
||
|
||
const listFragment = document.createDocumentFragment();
|
||
|
||
const allLabel = document.createElement("label");
|
||
allLabel.className = "channel-option";
|
||
allChannelsCheckbox = document.createElement("input");
|
||
allChannelsCheckbox.type = "checkbox";
|
||
allChannelsCheckbox.dataset.all = "1";
|
||
allChannelsCheckbox.checked = selectedChannels.size === 0;
|
||
const allText = document.createElement("span");
|
||
allText.textContent = "All Channels";
|
||
allLabel.appendChild(allChannelsCheckbox);
|
||
allLabel.appendChild(allText);
|
||
listFragment.appendChild(allLabel);
|
||
|
||
data.forEach((item) => {
|
||
const label = document.createElement("label");
|
||
label.className = "channel-option";
|
||
const checkbox = document.createElement("input");
|
||
checkbox.type = "checkbox";
|
||
checkbox.value = item.Id;
|
||
checkbox.dataset.channel = "1";
|
||
const text = document.createElement("span");
|
||
text.textContent = `${item.Name} (${item.Count})`;
|
||
label.appendChild(checkbox);
|
||
label.appendChild(text);
|
||
listFragment.appendChild(label);
|
||
channelMap.set(item.Id, item.Name);
|
||
});
|
||
|
||
channelOptions.appendChild(listFragment);
|
||
|
||
if (!data.length) {
|
||
const empty = document.createElement("div");
|
||
empty.textContent = "No channels available.";
|
||
channelOptions.appendChild(empty);
|
||
}
|
||
|
||
const initialSelection = pendingChannelSelection.length
|
||
? pendingChannelSelection
|
||
: Array.from(selectedChannels);
|
||
applyChannelSelection(initialSelection, { silent: true });
|
||
channelsReady = true;
|
||
updateChannelSummary();
|
||
} catch (err) {
|
||
console.error("Failed to load channels", err);
|
||
channelOptions.innerHTML = "<div>Failed to load channels.</div>";
|
||
channelsReady = true;
|
||
ensureAllCheckboxState();
|
||
updateChannelSummary();
|
||
}
|
||
}
|
||
|
||
function updateUrl(q, sort, channels, page, size, exact, fuzzy, phrase, queryMode) {
|
||
const next = new URL(window.location.href);
|
||
next.searchParams.set("q", q);
|
||
next.searchParams.set("sort", sort);
|
||
next.searchParams.delete("channel_id");
|
||
next.searchParams.delete("channel");
|
||
channels.forEach((id) => next.searchParams.append("channel_id", id));
|
||
next.searchParams.set("page", page);
|
||
next.searchParams.set("size", size);
|
||
next.searchParams.set("exact", exact ? "1" : "0");
|
||
next.searchParams.set("fuzzy", fuzzy ? "1" : "0");
|
||
next.searchParams.set("phrase", phrase ? "1" : "0");
|
||
next.searchParams.set("query_string", queryMode ? "1" : "0");
|
||
history.pushState({}, "", next.toString());
|
||
}
|
||
|
||
function fmtDate(value) {
|
||
try {
|
||
return (value || "").split("T")[0];
|
||
} catch {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
function fmtNumber(n) {
|
||
if (typeof n === "number") return n.toLocaleString();
|
||
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);
|
||
|
||
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;
|
||
metricsContent.innerHTML = "";
|
||
if (!data) return;
|
||
|
||
if (metricsStatus) {
|
||
metricsStatus.textContent = "";
|
||
}
|
||
|
||
const summary = document.createElement("div");
|
||
summary.innerHTML = `<strong>Entries:</strong> ${fmtNumber(data.totalItems)} • <strong>Channels:</strong> ${fmtNumber(data.totalChannels)}`;
|
||
metricsContent.appendChild(summary);
|
||
|
||
if (Array.isArray(data.itemsPerChannel) && data.itemsPerChannel.length) {
|
||
const top = data.itemsPerChannel.slice(0, 5);
|
||
const channelHeader = document.createElement("div");
|
||
channelHeader.style.marginTop = "8px";
|
||
channelHeader.innerHTML = "<strong>Top Channels</strong>";
|
||
metricsContent.appendChild(channelHeader);
|
||
|
||
const channelList = document.createElement("div");
|
||
channelList.className = "muted";
|
||
top.forEach((entry) => {
|
||
const row = document.createElement("div");
|
||
row.textContent = `${entry.label}: ${fmtNumber(entry.count)}`;
|
||
channelList.appendChild(row);
|
||
});
|
||
metricsContent.appendChild(channelList);
|
||
}
|
||
}
|
||
|
||
async function loadMetrics() {
|
||
if (!metricsContainer) return;
|
||
metricsContainer.dataset.loading = "1";
|
||
if (!metricsContainer.dataset.loaded && metricsStatus) {
|
||
metricsStatus.textContent = "Loading metrics…";
|
||
}
|
||
try {
|
||
const res = await fetch("/api/metrics");
|
||
const data = await res.json();
|
||
renderMetrics(data);
|
||
metricsContainer.dataset.loaded = "1";
|
||
} catch (err) {
|
||
console.error("Failed to load metrics", err);
|
||
if (!metricsContainer.dataset.loaded && metricsStatus) {
|
||
metricsStatus.textContent = "Metrics unavailable.";
|
||
}
|
||
} finally {
|
||
delete metricsContainer.dataset.loading;
|
||
}
|
||
}
|
||
|
||
function clearFrequency(message) {
|
||
if (freqSummary) {
|
||
freqSummary.textContent = message || "";
|
||
}
|
||
if (freqChart) {
|
||
freqChart.innerHTML = "";
|
||
}
|
||
}
|
||
|
||
function renderFrequencyChart(buckets, channelTotals) {
|
||
if (!freqChart || typeof d3 === "undefined") {
|
||
return;
|
||
}
|
||
freqChart.innerHTML = "";
|
||
if (!buckets.length) {
|
||
clearFrequency("No matches for this query.");
|
||
return;
|
||
}
|
||
|
||
let channelsOrder =
|
||
(channelTotals && channelTotals.length
|
||
? channelTotals.map((entry) => entry.id)
|
||
: []) || [];
|
||
if (!channelsOrder.length) {
|
||
const unique = new Set();
|
||
buckets.forEach((bucket) => {
|
||
(bucket.channels || []).forEach((entry) => unique.add(entry.id));
|
||
});
|
||
channelsOrder = Array.from(unique);
|
||
}
|
||
channelsOrder = channelsOrder.slice(0, 6);
|
||
if (!channelsOrder.length) {
|
||
clearFrequency("No matches for this query.");
|
||
return;
|
||
}
|
||
|
||
const dateKeyFormat = d3.timeFormat("%Y-%m-%d");
|
||
const parsed = buckets
|
||
.map((bucket) => {
|
||
const parsedDate = d3.isoParse(bucket.date) || new Date(bucket.date);
|
||
if (!(parsedDate instanceof Date) || Number.isNaN(parsedDate.valueOf())) {
|
||
return null;
|
||
}
|
||
const counts = {};
|
||
(bucket.channels || []).forEach((entry) => {
|
||
if (channelsOrder.includes(entry.id)) {
|
||
counts[entry.id] = entry.count || 0;
|
||
}
|
||
});
|
||
return {
|
||
date: parsedDate,
|
||
dateKey: dateKeyFormat(parsedDate),
|
||
counts,
|
||
};
|
||
})
|
||
.filter(Boolean);
|
||
|
||
if (!parsed.length) {
|
||
clearFrequency("Timeline unavailable.");
|
||
return;
|
||
}
|
||
|
||
const margin = { top: 12, right: 12, bottom: 52, left: 56 };
|
||
const fullWidth = freqChart.clientWidth || 360;
|
||
const fullHeight = 220;
|
||
const width = fullWidth - margin.left - margin.right;
|
||
const height = fullHeight - margin.top - margin.bottom;
|
||
|
||
const svg = d3
|
||
.select(freqChart)
|
||
.append("svg")
|
||
.attr("width", fullWidth)
|
||
.attr("height", fullHeight);
|
||
|
||
const g = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||
|
||
const x = d3
|
||
.scaleBand()
|
||
.domain(parsed.map((entry) => entry.dateKey))
|
||
.range([0, width])
|
||
.padding(0.25);
|
||
|
||
const yMax = d3.max(parsed, (entry) =>
|
||
d3.sum(channelsOrder, (key) => entry.counts[key] || 0)
|
||
);
|
||
|
||
const y = d3
|
||
.scaleLinear()
|
||
.domain([0, yMax || 0])
|
||
.nice()
|
||
.range([height, 0]);
|
||
|
||
const tickValues =
|
||
parsed.length <= 6
|
||
? parsed.map((entry) => entry.dateKey)
|
||
: parsed
|
||
.filter((_, index, arr) => index % Math.ceil(arr.length / 6) === 0)
|
||
.map((entry) => entry.dateKey);
|
||
|
||
const xAxis = d3.axisBottom(x).tickValues(tickValues);
|
||
const yAxis = d3.axisLeft(y).ticks(5);
|
||
|
||
g.append("g")
|
||
.attr("class", "axis")
|
||
.attr("transform", `translate(0,${height})`)
|
||
.call(xAxis)
|
||
.selectAll("text")
|
||
.attr("text-anchor", "end")
|
||
.attr("transform", "rotate(-35)")
|
||
.attr("dx", "-0.8em")
|
||
.attr("dy", "0.15em");
|
||
|
||
g.append("g").attr("class", "axis").call(yAxis);
|
||
|
||
const stack = d3.stack().keys(channelsOrder).value((entry, key) => entry.counts[key] || 0);
|
||
const stacked = stack(parsed);
|
||
const color = d3.scaleOrdinal(channelsOrder, d3.schemeTableau10);
|
||
|
||
const layers = g
|
||
.selectAll(".freq-layer")
|
||
.data(stacked)
|
||
.enter()
|
||
.append("g")
|
||
.attr("class", "freq-layer")
|
||
.attr("fill", (d) => color(d.key));
|
||
|
||
layers
|
||
.selectAll("rect")
|
||
.data((d) => d)
|
||
.enter()
|
||
.append("rect")
|
||
.attr("x", (d) => x(d.data.dateKey))
|
||
.attr("width", x.bandwidth())
|
||
.attr("y", (d) => y(d[1]))
|
||
.attr("height", (d) => y(d[0]) - y(d[1]))
|
||
.append("title")
|
||
.text(function (d) {
|
||
const group = this.parentNode ? this.parentNode.parentNode : null;
|
||
const key = group ? d3.select(group).datum().key : undefined;
|
||
const label = key ? channelMap.get(key) || key : key || '';
|
||
return `${dateKeyFormat(d.data.date)}: ${d[1] - d[0]}${label ? " (" + label + ")" : ''}`;
|
||
});
|
||
|
||
const legend = document.createElement("div");
|
||
legend.className = "freq-legend";
|
||
channelsOrder.forEach((key) => {
|
||
const item = document.createElement("div");
|
||
item.className = "freq-legend-item";
|
||
const swatch = document.createElement("span");
|
||
swatch.className = "freq-legend-swatch";
|
||
swatch.style.backgroundColor = color(key);
|
||
const label = document.createElement("span");
|
||
label.textContent = channelMap.get(key) || key;
|
||
item.appendChild(swatch);
|
||
item.appendChild(label);
|
||
legend.appendChild(item);
|
||
});
|
||
freqChart.appendChild(legend);
|
||
}
|
||
|
||
async function updateFrequencyChart(term, channels, queryMode) {
|
||
if (!freqChart || typeof d3 === "undefined") {
|
||
return;
|
||
}
|
||
let trimmed = term.trim();
|
||
if (!trimmed) {
|
||
if (queryMode) {
|
||
trimmed = "*";
|
||
} else {
|
||
clearFrequency("Enter a query to see timeline.");
|
||
return;
|
||
}
|
||
}
|
||
|
||
const params = new URLSearchParams();
|
||
params.set("term", trimmed);
|
||
params.set("interval", "month");
|
||
(channels || []).forEach((id) => params.append("channel_id", id));
|
||
if (queryMode) {
|
||
params.set("query_string", "1");
|
||
}
|
||
|
||
clearFrequency("Loading timeline…");
|
||
try {
|
||
const res = await fetch(`/api/frequency?${params.toString()}`);
|
||
if (!res.ok) {
|
||
throw new Error(`Request failed with status ${res.status}`);
|
||
}
|
||
const payload = await res.json();
|
||
const total = payload.totalResults || 0;
|
||
if (freqSummary) {
|
||
if (total === 0) {
|
||
freqSummary.textContent = "No matches for this query.";
|
||
} else if (queryMode) {
|
||
freqSummary.textContent = `Matches: ${total.toLocaleString()} • Interval: ${payload.interval || "month"} (query-string)`;
|
||
} else {
|
||
freqSummary.textContent = `Matches: ${total.toLocaleString()} • Interval: ${payload.interval || "month"}`;
|
||
}
|
||
}
|
||
if (total === 0) {
|
||
freqChart.innerHTML = "";
|
||
return;
|
||
}
|
||
renderFrequencyChart(payload.buckets || [], payload.channels || []);
|
||
} catch (err) {
|
||
console.error(err);
|
||
clearFrequency("Timeline unavailable.");
|
||
}
|
||
}
|
||
|
||
function renderResults(payload, page) {
|
||
resultsDiv.innerHTML = "";
|
||
metaDiv.textContent = `Total: ${payload.totalResults} • Page ${
|
||
page + 1
|
||
} of ${payload.totalPages}`;
|
||
|
||
(payload.items || []).forEach((item) => {
|
||
const el = document.createElement("div");
|
||
el.className = "item";
|
||
const titleHtml =
|
||
item.titleHtml || escapeHtml(item.title || "Untitled");
|
||
const descriptionHtml =
|
||
item.descriptionHtml || escapeHtml(item.description || "");
|
||
|
||
const header = document.createElement("div");
|
||
const badges = [];
|
||
if (item.highlightSource && item.highlightSource.primary) badges.push('primary transcript');
|
||
if (item.highlightSource && item.highlightSource.secondary) badges.push('secondary transcript');
|
||
const badgeHtml = badges.length
|
||
? `<div class="badge-row">${badges
|
||
.map((b) => `<span class="badge">${escapeHtml(b)}</span>` )
|
||
.join('')}</div>`
|
||
: '';
|
||
header.innerHTML = `
|
||
<strong>${titleHtml}</strong>
|
||
<div class="muted">${escapeHtml(item.channel_name || "")} • ${fmtDate(
|
||
item.date
|
||
)}</div>
|
||
<div class="muted"><a href="${item.url}" target="_blank" rel="noopener">Open on YouTube</a></div>
|
||
${badgeHtml}
|
||
`;
|
||
el.appendChild(header);
|
||
|
||
if (descriptionHtml) {
|
||
const desc = document.createElement("div");
|
||
desc.className = "muted";
|
||
desc.innerHTML = descriptionHtml;
|
||
el.appendChild(desc);
|
||
}
|
||
|
||
if (Array.isArray(item.toHighlight) && item.toHighlight.length) {
|
||
const highlights = document.createElement("div");
|
||
highlights.className = "transcript highlight-list";
|
||
item.toHighlight.forEach((entry) => {
|
||
const html = typeof entry === "string" ? entry : entry?.html;
|
||
if (!html) return;
|
||
const row = document.createElement("div");
|
||
row.className = "highlight-row";
|
||
row.innerHTML = html;
|
||
highlights.appendChild(row);
|
||
});
|
||
if (highlights.childElementCount) {
|
||
el.appendChild(highlights);
|
||
}
|
||
}
|
||
|
||
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);
|
||
});
|
||
|
||
const pager = document.createElement("div");
|
||
pager.className = "pager";
|
||
const prev = document.createElement("button");
|
||
prev.textContent = "Prev";
|
||
prev.disabled = page <= 0;
|
||
const next = document.createElement("button");
|
||
next.textContent = "Next";
|
||
next.disabled = page + 1 >= payload.totalPages;
|
||
prev.onclick = () => runSearch(page - 1);
|
||
next.onclick = () => runSearch(page + 1);
|
||
pager.appendChild(prev);
|
||
pager.appendChild(next);
|
||
resultsDiv.appendChild(pager);
|
||
}
|
||
|
||
async function runSearch(pageOverride, pushState = true) {
|
||
const q = qInput.value.trim();
|
||
const channels = getSelectedChannels();
|
||
const sort = sortSel.value;
|
||
const size = parseInt(sizeSel.value, 10) || 10;
|
||
const queryMode = queryToggle && queryToggle.checked;
|
||
let exact = !!exactToggle.checked;
|
||
let fuzzy = !!fuzzyToggle.checked;
|
||
let phrase = !!phraseToggle.checked;
|
||
if (queryMode) {
|
||
exact = false;
|
||
fuzzy = false;
|
||
phrase = false;
|
||
} else {
|
||
previousToggleState = {
|
||
exact,
|
||
fuzzy,
|
||
phrase,
|
||
};
|
||
}
|
||
const page = pageOverride != null ? pageOverride : currentPage;
|
||
currentPage = page;
|
||
|
||
if (pushState) {
|
||
updateUrl(q, sort, channels, page, size, exact, fuzzy, phrase, queryMode);
|
||
}
|
||
|
||
const params = new URLSearchParams();
|
||
params.set("q", q);
|
||
params.set("sort", sort);
|
||
params.set("size", String(size));
|
||
params.set("page", String(page));
|
||
params.set("exact", exact ? "1" : "0");
|
||
params.set("fuzzy", fuzzy ? "1" : "0");
|
||
params.set("phrase", phrase ? "1" : "0");
|
||
params.set("query_string", queryMode ? "1" : "0");
|
||
channels.forEach((id) => params.append("channel_id", id));
|
||
|
||
const res = await fetch(`/api/search?${params.toString()}`);
|
||
const payload = await res.json();
|
||
renderResults(payload, page);
|
||
updateFrequencyChart(q, channels, queryMode);
|
||
}
|
||
|
||
searchBtn.addEventListener("click", () => runSearch(0));
|
||
qInput.addEventListener("keypress", (e) => {
|
||
if (e.key === "Enter") runSearch(0);
|
||
});
|
||
sortSel.addEventListener("change", () => runSearch(0));
|
||
sizeSel.addEventListener("change", () => runSearch(0));
|
||
exactToggle.addEventListener("change", () => { rememberToggleState(); runSearch(0); });
|
||
fuzzyToggle.addEventListener("change", () => { rememberToggleState(); runSearch(0); });
|
||
phraseToggle.addEventListener("change", () => { rememberToggleState(); runSearch(0); });
|
||
if (queryToggle) {
|
||
queryToggle.addEventListener("change", () => { applyQueryMode(); runSearch(0); });
|
||
}
|
||
|
||
window.addEventListener("popstate", () => {
|
||
qs = new URLSearchParams(window.location.search);
|
||
setFromQuery();
|
||
currentPage = parseInt(qs.get("page") || "0", 10) || 0;
|
||
runSearch(currentPage, false);
|
||
});
|
||
|
||
setFromQuery();
|
||
loadMetrics();
|
||
loadChannels().then(() => runSearch(currentPage));
|
||
})();
|
||
|
||
function escapeHtml(str) {
|
||
return (str || "").replace(/[&<>"']/g, (ch) => {
|
||
switch (ch) {
|
||
case "&":
|
||
return "&";
|
||
case "<":
|
||
return "<";
|
||
case ">":
|
||
return ">";
|
||
case '"':
|
||
return """;
|
||
case "'":
|
||
return "'";
|
||
default:
|
||
return ch;
|
||
}
|
||
});
|
||
}
|