// 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 = ` 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 = `
${escapeHtml(v.title)}
${v.duration ? `${v.duration}` : ""} ${v.published_relative ? `${v.published_relative}` : ""}
${escapeHtml(v.why_suggested || '✨ Curated for you')}

${escapeHtml(v.title)}

${escapeHtml(v.channel)} ${v.views || ''}
`; // 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(() => `
`).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 = `

No videos in this view.

Click "Watch Later" on any suggested video to preserve it here.
`; return; } container.innerHTML = ""; items.forEach(item => { const div = document.createElement("div"); div.className = `wl-item ${item.watched ? 'watched' : ''}`; div.innerHTML = `
${item.duration ? `${item.duration}` : ""}

${escapeHtml(item.title)}

${escapeHtml(item.channel)} • ${formatDate(item.added_at)}
`; // 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 = ` `; 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 = `${t.type === 'person' ? '👤 ' : '# '}${escapeHtml(t.name)}`; 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 = `
${t.type === 'person' ? '👤' : '#'} ${escapeHtml(t.name)} Affinity Weight: ${t.weight.toFixed(2)}x
`; 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 = `No channels or videos noped yet.`; return; } container.innerHTML = ""; nopes.forEach(n => { const div = document.createElement("div"); div.className = "nope-item"; div.innerHTML = ` ${n.type.toUpperCase()}: ${escapeHtml(n.target)} `; 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 = ` ${escapeHtml(message)} `; 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, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function formatDate(iso) { if (!iso) return "recently"; const date = new Date(iso); return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); }