Initial commit: Spectra YouTube Suggest Engine

This commit is contained in:
chrisf committed 2026-10-29 22:56:06 -06:00
commit e90505a39b
16 files changed
+4495

No files matched your search

+828
View File
@@ -0,0 +1,828 @@
// 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" });
}
+59
View File
@@ -0,0 +1,59 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0b0f19" />
<stop offset="100%" stop-color="#04060a" />
</linearGradient>
<linearGradient id="prismEdge" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6366f1" />
<stop offset="50%" stop-color="#06b6d4" />
<stop offset="100%" stop-color="#10b981" />
</linearGradient>
<linearGradient id="beam" x1="0%" y1="50%" x2="100%" y2="50%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.9" />
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.3" />
</linearGradient>
<linearGradient id="spectrumRed" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#ff3b30" />
<stop offset="100%" stop-color="#ff9500" />
</linearGradient>
<linearGradient id="spectrumYellow" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#ffcc00" />
<stop offset="100%" stop-color="#34c759" />
</linearGradient>
<linearGradient id="spectrumCyan" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#00c7be" />
<stop offset="100%" stop-color="#30b0c7" />
</linearGradient>
<linearGradient id="spectrumIndigo" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#5856d6" />
<stop offset="100%" stop-color="#af52de" />
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="6" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<!-- Background rounded squircle -->
<rect x="12" y="12" width="232" height="232" rx="56" fill="url(#bg)" stroke="#1e293b" stroke-width="3" />
<!-- Incoming beam -->
<path d="M 28 138 L 102 128" stroke="url(#beam)" stroke-width="4" stroke-linecap="round" />
<!-- Refractive Triangular Prism (Play button silhouette) -->
<polygon points="128,68 184,168 72,168" fill="#131b2e" stroke="url(#prismEdge)" stroke-width="5" stroke-linejoin="round" />
<!-- Play icon core within prism -->
<polygon points="120,118 120,144 142,131" fill="#ffffff" opacity="0.9" filter="url(#glow)" />
<!-- Dispersed Spectral Rays (Right side) -->
<path d="M 152 118 L 226 84" stroke="url(#spectrumRed)" stroke-width="4" stroke-linecap="round" opacity="0.95" />
<path d="M 156 128 L 230 106" stroke="url(#spectrumYellow)" stroke-width="4" stroke-linecap="round" opacity="0.95" />
<path d="M 158 138 L 230 128" stroke="#34c759" stroke-width="4" stroke-linecap="round" opacity="0.95" />
<path d="M 156 148 L 228 150" stroke="url(#spectrumCyan)" stroke-width="4" stroke-linecap="round" opacity="0.95" />
<path d="M 152 158 L 224 172" stroke="url(#spectrumIndigo)" stroke-width="4" stroke-linecap="round" opacity="0.95" />
<!-- Subtle optical ring -->
<circle cx="128" cy="128" r="92" fill="none" stroke="#334155" stroke-width="1.5" stroke-dasharray="4 8" opacity="0.4" />
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+322
View File
@@ -0,0 +1,322 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spectra — A YouTube Suggest Engine</title>
<meta name="description" content="Better YouTube recommendations with zero repeats, steerable topics & people, recency bias, and persistent watch list.">
<link rel="icon" type="image/svg+xml" href="/icon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<!-- Ambient background glow elements -->
<div class="ambient-glow glow-1"></div>
<div class="ambient-glow glow-2"></div>
<!-- Top App Navigation -->
<header class="app-header">
<div class="header-left">
<div class="brand">
<img src="/icon.svg" alt="Spectra Logo" class="brand-logo">
<div class="brand-text">
<span class="brand-title">SPECTRA</span>
<span class="brand-subtitle">A YouTube Suggest Engine</span>
</div>
</div>
<div class="header-pill-badge">
<span class="pulse-dot"></span>
<span id="headerStatusText">No Repeats Active</span>
</div>
</div>
<div class="header-center">
<div class="recency-picker" title="Bias recommendations toward newer videos">
<span class="recency-label">Timing:</span>
<button class="recency-btn" data-bias="ultra_fresh">⚡ Ultra Fresh (&lt;48h)</button>
<button class="recency-btn active" data-bias="recent">📅 Recent (&lt;30d)</button>
<button class="recency-btn" data-bias="broad">🌐 Broad</button>
</div>
</div>
<div class="header-right">
<button id="btnRegenerate" class="action-btn btn-primary" title="Suggest fresh batch of videos with no repeats">
<svg class="btn-icon spin-icon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/>
</svg>
<span>Suggest Next Batch</span>
<kbd class="kbd-shortcut">R</kbd>
</button>
<button id="btnOpenWatchlist" class="action-btn btn-glass" title="View preserved Watch List">
<svg class="btn-icon" viewBox="0 0 24 24" width="16" height="16" fill="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>Watch List</span>
<span id="watchlistCountBadge" class="count-badge">0</span>
</button>
<button id="btnOpenTuning" class="action-btn btn-glass icon-only" title="Algorithm Tuning & Steering">
<svg class="btn-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20v-6M6 20V10M18 20V4M2 10h8M14 14h8M8 4h8"/>
</svg>
</button>
</div>
</header>
<!-- Interactive Topics & People Ribbon -->
<section class="topics-ribbon">
<div class="ribbon-label">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
</svg>
<span>Focus Stream:</span>
</div>
<div id="topicsContainer" class="topics-scroll">
<button class="topic-chip active" data-topic-id="all">
<span>✨ All Topics & People</span>
</button>
<!-- Injected dynamically via app.js -->
</div>
<button id="btnAddTopic" class="btn-add-chip" title="Add a custom topic or YouTube creator">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
<span>Add Topic/Person</span>
</button>
</section>
<!-- Main Feed Area -->
<main class="main-content">
<div class="feed-header">
<div class="feed-title-wrap">
<h2 id="currentFeedTitle">Curated Suggestions</h2>
<span id="feedSubtitle" class="feed-subtitle">No repeats • Newer bias • Steered by your taste</span>
</div>
<div class="feed-meta">
<span class="meta-pill" id="seenCounterPill" title="Videos previously suggested and prevented from repeating">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<span id="seenCountText">0 Seen</span>
</span>
<button id="btnRefreshCatalog" class="btn-text-action" title="Re-query candidates">
Refresh Candidates
</button>
</div>
</div>
<!-- Video Grid -->
<div id="videoGrid" class="video-grid">
<!-- Loading skeletons or dynamic cards -->
</div>
<!-- Empty or Exhausted state -->
<div id="emptyState" class="empty-state hidden">
<div class="empty-icon-wrap">
<svg viewBox="0 0 24 24" width="36" height="36" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</div>
<h3>All candidate suggestions seen!</h3>
<p>Spectra guarantees no repeats and has shown every un-noped video in current topics. You can add new topics, fetch live YouTube queries, or reset your seen history.</p>
<div class="empty-actions">
<button id="btnResetSeenEmpty" class="action-btn btn-primary">Reset Seen History</button>
<button id="btnFetchMoreLive" class="action-btn btn-glass">Search YouTube for More</button>
</div>
</div>
</main>
<!-- Watch List Drawer (Preserved Across Regenerations) -->
<div id="watchlistDrawer" class="drawer-overlay hidden">
<div class="drawer-panel">
<div class="drawer-header">
<div class="drawer-title-wrap">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#6366f1" stroke-width="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
</svg>
<h3>Your Watch List</h3>
<span class="preserved-tag">Preserved across batches</span>
</div>
<button id="btnCloseWatchlist" class="btn-close-drawer">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="drawer-tabs">
<button class="drawer-tab active" data-filter="all">All (<span id="wlTabAll">0</span>)</button>
<button class="drawer-tab" data-filter="unwatched">Unwatched (<span id="wlTabUnwatched">0</span>)</button>
<button class="drawer-tab" data-filter="watched">Watched (<span id="wlTabWatched">0</span>)</button>
</div>
<div id="watchlistItems" class="watchlist-items-scroll">
<!-- Injected dynamically -->
</div>
</div>
</div>
<!-- Algorithm Tuning & Steering Drawer -->
<div id="tuningDrawer" class="drawer-overlay hidden">
<div class="drawer-panel">
<div class="drawer-header">
<div class="drawer-title-wrap">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#06b6d4" stroke-width="2">
<path d="M12 20v-6M6 20V10M18 20V4M2 10h8M14 14h8M8 4h8"/>
</svg>
<h3>Algorithm Steering & Tuning</h3>
</div>
<button id="btnCloseTuning" class="btn-close-drawer">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="tuning-content">
<!-- Telemetry Summary -->
<div class="telemetry-grid">
<div class="telemetry-card">
<span class="telemetry-val" id="statThumbsUp">0</span>
<span class="telemetry-key">👍 Upvotes</span>
</div>
<div class="telemetry-card">
<span class="telemetry-val" id="statThumbsDown">0</span>
<span class="telemetry-key">👎 Downvotes</span>
</div>
<div class="telemetry-card">
<span class="telemetry-val" id="statBigNopes">0</span>
<span class="telemetry-key">🚫 Big Nopes</span>
</div>
<div class="telemetry-card">
<span class="telemetry-val" id="statTotalSeen">0</span>
<span class="telemetry-key">👁️ Seen (No Repeats)</span>
</div>
</div>
<!-- Topics & Weights Steering -->
<div class="tuning-section">
<h4>Active Topic & Creator Weights</h4>
<p class="section-desc">Upvotes and downvotes steer these affinity weights automatically. You can also toggle topics on/off or fine-tune them.</p>
<div id="tuningTopicsList" class="tuning-topics-list">
<!-- Dynamic topic sliders -->
</div>
</div>
<!-- Big Nopes Manager -->
<div class="tuning-section">
<h4>The Big Nopes (Blocklist)</h4>
<p class="section-desc">Channels or videos you marked with a Big Nope. Permanently excluded from all suggestions.</p>
<div id="nopesList" class="nopes-list">
<!-- Dynamic nopes -->
</div>
</div>
<!-- Seen History Reset -->
<div class="tuning-section">
<h4>Seen History Management</h4>
<p class="section-desc">Spectra remembers all suggested videos so you never see repeats. If you want to cycle back through everything, reset here.</p>
<button id="btnResetSeenHistory" class="action-btn btn-danger">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
</svg>
<span>Reset Seen Videos History</span>
</button>
</div>
</div>
</div>
</div>
<!-- Add Topic / Person Modal -->
<div id="addTopicModal" class="modal-overlay hidden">
<div class="modal-dialog">
<div class="modal-header">
<h3>Add Stream or Creator</h3>
<button id="btnCloseAddModal" class="btn-close-modal">&times;</button>
</div>
<form id="addTopicForm" class="modal-body">
<div class="form-group">
<label for="topicNameInput">Name / Title</label>
<input type="text" id="topicNameInput" placeholder="e.g. Vintage Audio Restoration or Jeff Geerling" required>
</div>
<div class="form-group">
<label for="topicTypeSelect">Type</label>
<select id="topicTypeSelect">
<option value="topic">Topic / Interest</option>
<option value="person">Person / Creator</option>
</select>
</div>
<div class="form-group">
<label for="topicQueryInput">YouTube Search Query or Keywords</label>
<input type="text" id="topicQueryInput" placeholder="e.g. vintage tube amp repair vinyl hi-fi">
<small>Leave blank to search by the name.</small>
</div>
<div class="modal-actions">
<button type="button" id="btnCancelAddModal" class="action-btn btn-glass">Cancel</button>
<button type="submit" class="action-btn btn-primary">Add to Spectra</button>
</div>
</form>
</div>
</div>
<!-- Video Player Modal -->
<div id="playerModal" class="player-overlay hidden">
<div class="player-container">
<div class="player-header">
<div class="player-title-info">
<h4 id="playerVideoTitle">Loading video...</h4>
<span id="playerVideoChannel">Creator</span>
</div>
<div class="player-header-actions">
<a id="playerExternalLink" href="#" target="_blank" rel="noopener noreferrer" class="btn-icon-link" title="Open directly in YouTube">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
<button id="btnClosePlayer" class="btn-close-player" title="Close player (Esc)">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<div class="player-video-wrap">
<iframe id="playerIframe" src="" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
</div>
<div class="player-footer">
<div class="player-footer-left">
<button id="playerBtnWatchlist" class="action-btn btn-glass">
<svg viewBox="0 0 24 24" width="16" height="16" fill="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 id="playerWatchlistText">Add to Watch List</span>
</button>
</div>
<div class="player-footer-right">
<button id="playerBtnThumbsUp" class="card-btn btn-thumb-up" title="Thumbs Up">👍 Boost</button>
<button id="playerBtnThumbsDown" class="card-btn btn-thumb-down" title="Thumbs Down">👎 Less</button>
<button id="playerBtnBigNope" class="card-btn btn-nope" title="Big Nope">🚫 Big Nope</button>
</div>
</div>
</div>
</div>
<!-- Toast Notification System -->
<div id="toastContainer" class="toast-container"></div>
<script type="module" src="/app.js"></script>
</body>
</html>
+1352
View File
File diff suppressed because it is too large. Load diff