Files
spectra/public/app.js
T

829 lines
28 KiB
JavaScript

// Spectra — Client Application Logic
// Implements steerable suggestions, watch list persistence, live feedback, and tuning
let state = {
currentTopic: "all",
recencyBias: "recent",
suggestions: [],
watchlist: [],
topics: [],
tuning: {},
activePlayerVideo: null,
seenCount: 0
};
// DOM References
const videoGrid = document.getElementById("videoGrid");
const emptyState = document.getElementById("emptyState");
const topicsContainer = document.getElementById("topicsContainer");
const watchlistDrawer = document.getElementById("watchlistDrawer");
const tuningDrawer = document.getElementById("tuningDrawer");
const addTopicModal = document.getElementById("addTopicModal");
const playerModal = document.getElementById("playerModal");
const watchlistCountBadge = document.getElementById("watchlistCountBadge");
const seenCountText = document.getElementById("seenCountText");
const toastContainer = document.getElementById("toastContainer");
// Initialize Application
document.addEventListener("DOMContentLoaded", async () => {
setupEventListeners();
await loadInitialData();
});
async function loadInitialData() {
await Promise.all([
fetchTopics(),
fetchWatchlist(),
fetchTuning()
]);
await fetchSuggestions();
}
function setupEventListeners() {
// Regenerate Button
document.getElementById("btnRegenerate").addEventListener("click", () => {
regenerateSuggestions();
});
// Keyboard shortcut 'R' for regenerate
window.addEventListener("keydown", (e) => {
if (e.key.toLowerCase() === "r" && !["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) {
e.preventDefault();
regenerateSuggestions();
}
if (e.key === "Escape") {
closeAllModalsAndDrawers();
}
});
// Watchlist Drawer Triggers
document.getElementById("btnOpenWatchlist").addEventListener("click", () => {
openWatchlistDrawer();
});
document.getElementById("btnCloseWatchlist").addEventListener("click", () => {
watchlistDrawer.classList.add("hidden");
});
watchlistDrawer.addEventListener("click", (e) => {
if (e.target === watchlistDrawer) watchlistDrawer.classList.add("hidden");
});
// Tuning Drawer Triggers
document.getElementById("btnOpenTuning").addEventListener("click", () => {
openTuningDrawer();
});
document.getElementById("btnCloseTuning").addEventListener("click", () => {
tuningDrawer.classList.add("hidden");
});
tuningDrawer.addEventListener("click", (e) => {
if (e.target === tuningDrawer) tuningDrawer.classList.add("hidden");
});
// Add Topic Modal Triggers
document.getElementById("btnAddTopic").addEventListener("click", () => {
addTopicModal.classList.remove("hidden");
document.getElementById("topicNameInput").focus();
});
document.getElementById("btnCloseAddModal").addEventListener("click", () => {
addTopicModal.classList.add("hidden");
});
document.getElementById("btnCancelAddModal").addEventListener("click", () => {
addTopicModal.classList.add("hidden");
});
document.getElementById("addTopicForm").addEventListener("submit", handleAddTopic);
// Recency Timing Buttons
document.querySelectorAll(".recency-btn").forEach(btn => {
btn.addEventListener("click", async () => {
document.querySelectorAll(".recency-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
state.recencyBias = btn.dataset.bias;
await updateSetting("recency_bias", state.recencyBias);
showToast(`Timing set to ${btn.textContent.trim()}`);
regenerateSuggestions();
});
});
// Watchlist Drawer Tabs
document.querySelectorAll(".drawer-tab").forEach(tab => {
tab.addEventListener("click", () => {
document.querySelectorAll(".drawer-tab").forEach(t => t.classList.remove("active"));
tab.classList.add("active");
renderWatchlist(tab.dataset.filter);
});
});
// Reset Seen Buttons
document.getElementById("btnResetSeenHistory").addEventListener("click", handleResetSeen);
document.getElementById("btnResetSeenEmpty").addEventListener("click", handleResetSeen);
// Player Modal Controls
document.getElementById("btnClosePlayer").addEventListener("click", closePlayer);
playerModal.addEventListener("click", (e) => {
if (e.target === playerModal) closePlayer();
});
document.getElementById("playerBtnWatchlist").addEventListener("click", () => {
if (state.activePlayerVideo) toggleWatchlist(state.activePlayerVideo);
});
document.getElementById("playerBtnThumbsUp").addEventListener("click", () => {
if (state.activePlayerVideo) submitFeedback(state.activePlayerVideo, "thumbs_up");
});
document.getElementById("playerBtnThumbsDown").addEventListener("click", () => {
if (state.activePlayerVideo) submitFeedback(state.activePlayerVideo, "thumbs_down");
});
document.getElementById("playerBtnBigNope").addEventListener("click", () => {
if (state.activePlayerVideo) {
const v = state.activePlayerVideo;
closePlayer();
handleBigNope(v);
}
});
// Refresh candidates button
document.getElementById("btnRefreshCatalog").addEventListener("click", () => {
regenerateSuggestions();
});
document.getElementById("btnFetchMoreLive").addEventListener("click", () => {
regenerateSuggestions(true);
});
}
// ----------------------------------------------------
// Suggestions & Regeneration
// ----------------------------------------------------
async function fetchSuggestions(forceFresh = false) {
renderLoadingSkeleton();
try {
const topicParam = state.currentTopic !== "all" ? `&topic=${encodeURIComponent(state.currentTopic)}` : "";
const forceParam = forceFresh ? "&force_fresh=true" : "";
const res = await fetch(`/api/suggestions?limit=12${topicParam}${forceParam}`);
const data = await res.json();
state.suggestions = data.suggestions || [];
state.seenCount = data.totalSeen || 0;
updateSeenCounter();
if (state.suggestions.length === 0) {
videoGrid.innerHTML = "";
emptyState.classList.remove("hidden");
} else {
emptyState.classList.add("hidden");
renderSuggestions(state.suggestions);
}
} catch (err) {
showToast("Failed to fetch suggestions", "error");
}
}
async function regenerateSuggestions(fetchLive = true) {
const btn = document.getElementById("btnRegenerate");
btn.classList.add("loading");
const originalHtml = btn.innerHTML;
btn.innerHTML = `<span class="pulse-dot"></span> Generating...`;
try {
const body = {
limit: 12,
topic: state.currentTopic !== "all" ? state.currentTopic : null,
fetchLive
};
const res = await fetch("/api/suggestions/regenerate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const data = await res.json();
state.suggestions = data.suggestions || [];
state.seenCount = data.totalSeen || 0;
updateSeenCounter();
if (state.suggestions.length === 0) {
videoGrid.innerHTML = "";
emptyState.classList.remove("hidden");
} else {
emptyState.classList.add("hidden");
renderSuggestions(state.suggestions);
showToast(`Refreshed batch (${state.suggestions.length} fresh suggestions, Watch List preserved)`);
}
} catch (err) {
showToast("Error regenerating suggestions", "error");
} finally {
btn.classList.remove("loading");
btn.innerHTML = originalHtml;
}
}
function renderSuggestions(videos) {
videoGrid.innerHTML = "";
const watchlistIds = new Set(state.watchlist.map(w => w.video_id));
videos.forEach(v => {
const card = document.createElement("div");
card.className = "video-card";
card.id = `card-${v.id}`;
const isSaved = watchlistIds.has(v.id);
card.innerHTML = `
<div class="card-thumbnail-wrap" data-id="${v.id}">
<img class="card-thumbnail" src="${v.thumbnail}" alt="${escapeHtml(v.title)}" loading="lazy" onerror="this.src='https://i.ytimg.com/vi/${v.id}/hqdefault.jpg'">
<div class="card-play-overlay">
<div class="play-circle">
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
</div>
${v.duration ? `<span class="pill-duration">${v.duration}</span>` : ""}
${v.published_relative ? `<span class="pill-timing">${v.published_relative}</span>` : ""}
</div>
<div class="card-body">
<span class="card-why-badge" title="${escapeHtml(v.why_suggested || '')}">
${escapeHtml(v.why_suggested || '✨ Curated for you')}
</span>
<h3 class="card-title" title="${escapeHtml(v.title)}" data-id="${v.id}">
${escapeHtml(v.title)}
</h3>
<div class="card-creator-row">
<span class="card-creator-name" title="${escapeHtml(v.channel)}">
${escapeHtml(v.channel)}
</span>
<span class="card-views">${v.views || ''}</span>
</div>
<div class="card-toolbar">
<div class="toolbar-left">
<button class="card-btn btn-watchlist ${isSaved ? 'saved' : ''}" data-id="${v.id}" title="${isSaved ? 'In Watch List' : 'Add to Watch List'}">
<svg viewBox="0 0 24 24" width="13" height="13" fill="${isSaved ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
</svg>
<span>${isSaved ? 'Saved' : 'Watch Later'}</span>
</button>
</div>
<div class="toolbar-right">
<button class="card-btn btn-thumb-up" data-id="${v.id}" title="Thumbs Up (Steers algorithm toward this creator & topic)">
👍
</button>
<button class="card-btn btn-thumb-down" data-id="${v.id}" title="Thumbs Down (Show less like this)">
👎
</button>
<button class="card-btn btn-nope" data-id="${v.id}" title="Big Nope (Block channel & permanently exclude)">
🚫 Nope
</button>
</div>
</div>
</div>
`;
// Attach card event listeners
const thumbWrap = card.querySelector(".card-thumbnail-wrap");
const title = card.querySelector(".card-title");
const btnWl = card.querySelector(".btn-watchlist");
const btnUp = card.querySelector(".btn-thumb-up");
const btnDown = card.querySelector(".btn-thumb-down");
const btnNope = card.querySelector(".btn-nope");
thumbWrap.addEventListener("click", () => openPlayer(v));
title.addEventListener("click", () => openPlayer(v));
btnWl.addEventListener("click", () => toggleWatchlist(v));
btnUp.addEventListener("click", () => {
btnUp.classList.toggle("active");
btnDown.classList.remove("active");
submitFeedback(v, "thumbs_up");
});
btnDown.addEventListener("click", () => {
btnDown.classList.toggle("active");
btnUp.classList.remove("active");
submitFeedback(v, "thumbs_down");
});
btnNope.addEventListener("click", () => handleBigNope(v));
videoGrid.appendChild(card);
});
}
function renderLoadingSkeleton() {
videoGrid.innerHTML = Array(6).fill(0).map(() => `
<div class="video-card" style="opacity: 0.5;">
<div class="card-thumbnail-wrap" style="background: rgba(255,255,255,0.03);"></div>
<div class="card-body">
<div style="height: 14px; width: 60%; background: rgba(255,255,255,0.05); border-radius: 4px; margin-bottom: 8px;"></div>
<div style="height: 20px; width: 90%; background: rgba(255,255,255,0.08); border-radius: 4px; margin-bottom: 12px;"></div>
<div style="height: 12px; width: 40%; background: rgba(255,255,255,0.04); border-radius: 4px;"></div>
</div>
</div>
`).join("");
}
// ----------------------------------------------------
// Feedback & Algorithm Steering
// ----------------------------------------------------
async function submitFeedback(video, action) {
try {
const res = await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
videoId: video.id,
action,
channel: video.channel,
topicId: video.topic_id
})
});
const data = await res.json();
if (action === "thumbs_up") {
showToast(`👍 Steered algorithm: Upvoted "${video.channel}" (+affinity)`);
} else if (action === "thumbs_down") {
showToast(`👎 Steered algorithm: Downvoted "${video.channel}" (-affinity)`);
}
// Refresh telemetry
fetchTuning();
} catch (err) {
showToast("Error recording feedback", "error");
}
}
async function handleBigNope(video) {
const card = document.getElementById(`card-${video.id}`);
if (card) {
card.style.transition = "all 0.3s ease";
card.style.transform = "scale(0.85)";
card.style.opacity = "0";
setTimeout(() => card.remove(), 300);
}
try {
await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
videoId: video.id,
action: "big_nope",
channel: video.channel,
topicId: video.topic_id
})
});
// Provide undo capability in toast
showToastWithUndo(`🚫 Big Nope: Blocked "${video.channel}" permanently`, async () => {
// Find nope entry and remove
const nopesRes = await fetch("/api/nopes");
const nopesData = await nopesRes.json();
const match = nopesData.nopes.find(n => n.target.toLowerCase() === video.channel.toLowerCase());
if (match) {
await fetch(`/api/nopes/${match.id}`, { method: "DELETE" });
showToast(`Unblocked "${video.channel}"`);
regenerateSuggestions();
}
});
fetchTuning();
} catch (err) {
showToast("Error processing Big Nope", "error");
}
}
// ----------------------------------------------------
// Watch List (Preserved Across Regenerations)
// ----------------------------------------------------
async function fetchWatchlist() {
try {
const res = await fetch("/api/watchlist");
const data = await res.json();
state.watchlist = data.items || [];
updateWatchlistBadge();
} catch (err) {
console.error("Watchlist fetch error:", err);
}
}
async function toggleWatchlist(video) {
const isSaved = state.watchlist.some(w => w.video_id === video.id);
if (isSaved) {
// Remove
try {
await fetch(`/api/watchlist/${video.id}`, { method: "DELETE" });
state.watchlist = state.watchlist.filter(w => w.video_id !== video.id);
showToast(`Removed from Watch List`);
} catch (e) {
showToast("Error removing from Watch List", "error");
}
} else {
// Add
try {
const res = await fetch("/api/watchlist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: video.id,
title: video.title,
channel: video.channel,
thumbnail: video.thumbnail,
duration: video.duration,
url: video.url,
topic_id: video.topic_id
})
});
const item = await res.json();
state.watchlist.unshift(item);
showToast(`✓ Added to Watch List (preserved across regenerations)`);
} catch (e) {
showToast("Error adding to Watch List", "error");
}
}
updateWatchlistBadge();
updateCardWatchlistButtons(video.id);
// If player modal is open and shows this video, update its button
if (state.activePlayerVideo && state.activePlayerVideo.id === video.id) {
updatePlayerWatchlistButton();
}
}
function updateWatchlistBadge() {
watchlistCountBadge.textContent = state.watchlist.length;
}
function updateCardWatchlistButtons(videoId) {
const isSaved = state.watchlist.some(w => w.video_id === videoId);
const card = document.getElementById(`card-${videoId}`);
if (card) {
const btn = card.querySelector(".btn-watchlist");
if (btn) {
if (isSaved) {
btn.classList.add("saved");
btn.querySelector("span").textContent = "Saved";
btn.querySelector("svg").setAttribute("fill", "currentColor");
} else {
btn.classList.remove("saved");
btn.querySelector("span").textContent = "Watch Later";
btn.querySelector("svg").setAttribute("fill", "none");
}
}
}
}
function openWatchlistDrawer() {
watchlistDrawer.classList.remove("hidden");
renderWatchlist("all");
}
function renderWatchlist(filter = "all") {
const container = document.getElementById("watchlistItems");
const tabAll = document.getElementById("wlTabAll");
const tabUnwatched = document.getElementById("wlTabUnwatched");
const tabWatched = document.getElementById("wlTabWatched");
const unwatched = state.watchlist.filter(w => !w.watched);
const watched = state.watchlist.filter(w => w.watched);
tabAll.textContent = state.watchlist.length;
tabUnwatched.textContent = unwatched.length;
tabWatched.textContent = watched.length;
let items = state.watchlist;
if (filter === "unwatched") items = unwatched;
if (filter === "watched") items = watched;
if (items.length === 0) {
container.innerHTML = `
<div style="text-align: center; padding: 40px 10px; color: var(--text-muted);">
<p>No videos in this view.</p>
<small>Click "Watch Later" on any suggested video to preserve it here.</small>
</div>
`;
return;
}
container.innerHTML = "";
items.forEach(item => {
const div = document.createElement("div");
div.className = `wl-item ${item.watched ? 'watched' : ''}`;
div.innerHTML = `
<div class="wl-thumb-wrap">
<img class="wl-thumb" src="${item.thumbnail}" alt="">
${item.duration ? `<span class="pill-duration">${item.duration}</span>` : ""}
</div>
<div class="wl-info">
<h4 class="wl-title">${escapeHtml(item.title)}</h4>
<div class="wl-meta">
<span>${escapeHtml(item.channel)}</span> •
<span>${formatDate(item.added_at)}</span>
</div>
<div class="wl-actions">
<button class="btn-toggle-watched ${item.watched ? 'watched-true' : ''}">
${item.watched ? '✓ Watched' : '○ Mark Watched'}
</button>
<button class="btn-wl-remove">Remove</button>
</div>
</div>
`;
// Events
div.querySelector(".wl-thumb-wrap").addEventListener("click", () => {
openPlayer({ ...item, id: item.video_id });
});
div.querySelector(".wl-title").addEventListener("click", () => {
openPlayer({ ...item, id: item.video_id });
});
div.querySelector(".btn-toggle-watched").addEventListener("click", async () => {
const res = await fetch(`/api/watchlist/${item.video_id}/toggle`, { method: "PATCH" });
const updated = await res.json();
item.watched = updated.watched;
renderWatchlist(filter);
});
div.querySelector(".btn-wl-remove").addEventListener("click", async () => {
await fetch(`/api/watchlist/${item.video_id}`, { method: "DELETE" });
state.watchlist = state.watchlist.filter(w => w.video_id !== item.video_id);
updateWatchlistBadge();
updateCardWatchlistButtons(item.video_id);
renderWatchlist(filter);
});
container.appendChild(div);
});
}
// ----------------------------------------------------
// Topics & People Management
// ----------------------------------------------------
async function fetchTopics() {
try {
const res = await fetch("/api/topics");
const data = await res.json();
state.topics = data.topics || [];
renderTopicsRibbon();
} catch (err) {
console.error("Topics fetch error:", err);
}
}
function renderTopicsRibbon() {
topicsContainer.innerHTML = `
<button class="topic-chip ${state.currentTopic === 'all' ? 'active' : ''}" data-topic-id="all">
<span>✨ All Topics & People</span>
</button>
`;
state.topics.forEach(t => {
if (!t.active) return;
const btn = document.createElement("button");
btn.className = `topic-chip ${t.type === 'person' ? 'person-chip' : ''} ${state.currentTopic === t.id ? 'active' : ''}`;
btn.dataset.topicId = t.id;
btn.innerHTML = `<span>${t.type === 'person' ? '👤 ' : '# '}${escapeHtml(t.name)}</span>`;
btn.addEventListener("click", () => {
document.querySelectorAll(".topic-chip").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
state.currentTopic = t.id;
document.getElementById("currentFeedTitle").textContent = `${t.type === 'person' ? 'Creator: ' : 'Topic: '}${t.name}`;
regenerateSuggestions();
});
topicsContainer.appendChild(btn);
});
// Attach All chip click
topicsContainer.querySelector('[data-topic-id="all"]').addEventListener("click", (e) => {
document.querySelectorAll(".topic-chip").forEach(b => b.classList.remove("active"));
e.currentTarget.classList.add("active");
state.currentTopic = "all";
document.getElementById("currentFeedTitle").textContent = "Curated Suggestions";
regenerateSuggestions();
});
}
async function handleAddTopic(e) {
e.preventDefault();
const name = document.getElementById("topicNameInput").value.trim();
const type = document.getElementById("topicTypeSelect").value;
const query = document.getElementById("topicQueryInput").value.trim() || name;
if (!name) return;
try {
const res = await fetch("/api/topics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, type, query, active: 1, weight: 1.2 })
});
const newTopic = await res.json();
state.topics.push(newTopic);
renderTopicsRibbon();
addTopicModal.classList.add("hidden");
document.getElementById("addTopicForm").reset();
showToast(`Added ${type === 'person' ? 'Creator' : 'Topic'}: "${name}"`);
regenerateSuggestions();
} catch (err) {
showToast("Error adding topic", "error");
}
}
// ----------------------------------------------------
// Algorithm Tuning Drawer
// ----------------------------------------------------
async function fetchTuning() {
try {
const res = await fetch("/api/tuning");
const data = await res.json();
state.tuning = data;
document.getElementById("statThumbsUp").textContent = data.feedbackSummary?.thumbsUp || 0;
document.getElementById("statThumbsDown").textContent = data.feedbackSummary?.thumbsDown || 0;
document.getElementById("statBigNopes").textContent = data.nopesCount || 0;
document.getElementById("statTotalSeen").textContent = data.seenCount || 0;
} catch (err) {
console.error("Tuning fetch error:", err);
}
}
function openTuningDrawer() {
tuningDrawer.classList.remove("hidden");
fetchTuning();
renderTuningTopics();
renderNopesList();
}
function renderTuningTopics() {
const container = document.getElementById("tuningTopicsList");
container.innerHTML = "";
state.topics.forEach(t => {
const row = document.createElement("div");
row.className = "topic-row";
row.innerHTML = `
<div class="topic-info-left">
<span class="topic-row-title">${t.type === 'person' ? '👤' : '#'} ${escapeHtml(t.name)}</span>
<span class="topic-row-weight">Affinity Weight: ${t.weight.toFixed(2)}x</span>
</div>
<button class="topic-toggle-btn ${t.active ? '' : 'inactive'}">
${t.active ? 'Active' : 'Paused'}
</button>
`;
row.querySelector(".topic-toggle-btn").addEventListener("click", async () => {
const newActive = t.active ? 0 : 1;
await fetch(`/api/topics/${t.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active: newActive })
});
t.active = newActive;
renderTuningTopics();
renderTopicsRibbon();
});
container.appendChild(row);
});
}
async function renderNopesList() {
const container = document.getElementById("nopesList");
const res = await fetch("/api/nopes");
const data = await res.json();
const nopes = data.nopes || [];
if (nopes.length === 0) {
container.innerHTML = `<span style="font-size: 0.75rem; color: var(--text-muted);">No channels or videos noped yet.</span>`;
return;
}
container.innerHTML = "";
nopes.forEach(n => {
const div = document.createElement("div");
div.className = "nope-item";
div.innerHTML = `
<span><strong>${n.type.toUpperCase()}:</strong> ${escapeHtml(n.target)}</span>
<button class="btn-unblock" data-id="${n.id}">Unblock</button>
`;
div.querySelector(".btn-unblock").addEventListener("click", async () => {
await fetch(`/api/nopes/${n.id}`, { method: "DELETE" });
showToast(`Unblocked "${n.target}"`);
renderNopesList();
fetchTuning();
});
container.appendChild(div);
});
}
async function handleResetSeen() {
if (!confirm("Are you sure you want to reset your seen videos history? This allows videos you've already seen to be suggested again.")) {
return;
}
try {
await fetch("/api/tuning/reset-seen", { method: "POST" });
showToast("Seen history cleared. Starting fresh suggestions cycle.");
fetchTuning();
regenerateSuggestions();
} catch (e) {
showToast("Error clearing seen history", "error");
}
}
// ----------------------------------------------------
// Video Player Modal
// ----------------------------------------------------
function openPlayer(video) {
state.activePlayerVideo = video;
document.getElementById("playerVideoTitle").textContent = video.title;
document.getElementById("playerVideoChannel").textContent = video.channel;
document.getElementById("playerExternalLink").href = video.url || `https://www.youtube.com/watch?v=${video.id}`;
const iframe = document.getElementById("playerIframe");
iframe.src = `https://www.youtube-nocookie.com/embed/${video.id}?autoplay=1`;
updatePlayerWatchlistButton();
playerModal.classList.remove("hidden");
}
function updatePlayerWatchlistButton() {
if (!state.activePlayerVideo) return;
const isSaved = state.watchlist.some(w => w.video_id === state.activePlayerVideo.id);
const text = document.getElementById("playerWatchlistText");
text.textContent = isSaved ? "Saved to Watch List" : "Add to Watch List";
}
function closePlayer() {
playerModal.classList.add("hidden");
document.getElementById("playerIframe").src = "";
state.activePlayerVideo = null;
}
function closeAllModalsAndDrawers() {
closePlayer();
watchlistDrawer.classList.add("hidden");
tuningDrawer.classList.add("hidden");
addTopicModal.classList.add("hidden");
}
// ----------------------------------------------------
// Settings & Telemetry
// ----------------------------------------------------
async function updateSetting(key, value) {
await fetch("/api/tuning/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [key]: value })
});
}
function updateSeenCounter() {
seenCountText.textContent = `${state.seenCount} Seen (0 Repeats)`;
}
// ----------------------------------------------------
// UI Helpers & Toasts
// ----------------------------------------------------
function showToast(message, type = "info") {
const toast = document.createElement("div");
toast.className = "toast";
toast.textContent = message;
toastContainer.appendChild(toast);
setTimeout(() => {
toast.style.opacity = "0";
setTimeout(() => toast.remove(), 300);
}, 3500);
}
function showToastWithUndo(message, onUndo) {
const toast = document.createElement("div");
toast.className = "toast";
toast.innerHTML = `
<span>${escapeHtml(message)}</span>
<button class="toast-btn-undo">Undo</button>
`;
toast.querySelector(".toast-btn-undo").addEventListener("click", () => {
onUndo();
toast.remove();
});
toastContainer.appendChild(toast);
setTimeout(() => {
toast.style.opacity = "0";
setTimeout(() => toast.remove(), 300);
}, 5000);
}
function escapeHtml(str) {
if (!str) return "";
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function formatDate(iso) {
if (!iso) return "recently";
const date = new Date(iso);
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}