Initial commit: Spectra YouTube Suggest Engine
This commit is contained in:
commit
e90505a39b
16 files changed
+4495
No files matched your search
+828
@@ -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, "&")
|
||||
.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" });
|
||||
}
|
||||
@@ -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 |
@@ -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 (<48h)</button>
|
||||
<button class="recency-btn active" data-bias="recent">📅 Recent (<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">×</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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user