829 lines
28 KiB
JavaScript
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, "&")
|
|
.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" });
|
|
}
|