// Movie Night — Frontend Logic const API = ''; let currentUser = null; let selectedUserIds = []; let maxRuntime = null; let kidFriendly = false; let shownMovieIds = []; let lastMood = ''; let currentHistoryId = null; const SURPRISE_PROMPTS = [ "pizza night with the kids", "something scary but not too gory", "light fun movie after a hard week", "80s action classic", "mind-bending sci-fi", "feel-good comedy", "epic adventure", "rainy Sunday afternoon", "date night romance", "underrated hidden gem", "visually stunning cinematography", "based on a true story", "twisty thriller with a great ending", "nostalgic 90s vibes", "animated movie for all ages", "something weird and quirky", "inspiring sports movie", "cozy mystery", "space exploration", "laugh out loud comedy", ]; // --- Auth --- async function checkAuth() { try { const res = await fetch(`${API}/api/auth/me`); if (res.ok) { currentUser = await res.json(); showMainScreen(); } else { showLoginScreen(); } } catch { showLoginScreen(); } } async function login(username, password) { const res = await fetch(`${API}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: 'Login failed' })); throw new Error(err.detail || 'Login failed'); } currentUser = await res.json(); showMainScreen(); } async function logout() { await fetch(`${API}/api/auth/logout`, { method: 'POST' }); currentUser = null; showLoginScreen(); } // --- Screens --- function showLoginScreen() { document.getElementById('login-screen').classList.remove('hidden'); document.getElementById('main-screen').classList.add('hidden'); } function showMainScreen() { document.getElementById('login-screen').classList.add('hidden'); document.getElementById('main-screen').classList.remove('hidden'); document.getElementById('user-name').textContent = currentUser.name; selectedUserIds = [currentUser.id]; loadUsers(); loadStats(); loadHistory(); } // --- Users --- async function loadUsers() { try { const res = await fetch(`${API}/api/users`); if (!res.ok) return; const users = await res.json(); const container = document.getElementById('user-pills'); container.innerHTML = ''; users.forEach(user => { const pill = document.createElement('button'); pill.className = `user-pill border rounded-full px-4 py-1.5 text-sm ${selectedUserIds.includes(user.id) ? 'active' : ''}`; pill.textContent = user.name; pill.onclick = () => toggleUser(user.id, pill); container.appendChild(pill); }); } catch { /* ignore */ } } function toggleUser(userId, pill) { if (selectedUserIds.includes(userId)) { if (selectedUserIds.length > 1) { selectedUserIds = selectedUserIds.filter(id => id !== userId); pill.classList.remove('active'); } } else { selectedUserIds.push(userId); pill.classList.add('active'); } } // --- Mood / Recommendations --- async function findMovies(excludeIds = []) { const mood = document.getElementById('mood-input').value.trim(); if (!mood) return; // Track mood for re-roll; reset shown IDs if mood changed const isReroll = excludeIds.length > 0; if (mood !== lastMood) { shownMovieIds = []; currentHistoryId = null; lastMood = mood; } document.getElementById('empty-state').classList.add('hidden'); document.getElementById('error-state').classList.add('hidden'); if (isReroll) { // Show skeleton cards at the bottom of the existing grid const grid = document.getElementById('results-grid'); for (let i = 0; i < 3; i++) { const placeholder = document.createElement('div'); placeholder.className = 'skeleton-placeholder bg-dark-200 rounded-xl h-96 animate-pulse'; if (i === 1) placeholder.classList.add('delay-100'); if (i === 2) placeholder.classList.add('delay-200'); grid.appendChild(placeholder); } // Hide the re-roll button while loading, scroll to skeletons document.getElementById('reroll-btn').classList.add('hidden'); grid.lastElementChild.scrollIntoView({ behavior: 'smooth', block: 'center' }); } else { document.getElementById('results').classList.add('hidden'); document.getElementById('loading').classList.remove('hidden'); } try { const additionalIds = selectedUserIds.filter(id => id !== currentUser.id); const payload = { mood, additional_user_ids: additionalIds, exclude_ids: excludeIds, kid_friendly: kidFriendly, }; if (maxRuntime) payload.max_runtime = maxRuntime; if (isReroll && currentHistoryId) payload.history_id = currentHistoryId; const res = await fetch(`${API}/api/mood`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: 'Something went wrong' })); throw new Error(err.detail || 'Failed to get recommendations'); } const data = await res.json(); // Track shown movie IDs and history ID for re-roll if (data.recommendations) { data.recommendations.forEach(r => { if (!shownMovieIds.includes(r.jellyfin_id)) { shownMovieIds.push(r.jellyfin_id); } }); } if (data.meta && data.meta.history_id) { currentHistoryId = data.meta.history_id; } renderResults(data, isReroll); loadHistory(); } catch (err) { document.getElementById('loading').classList.add('hidden'); document.getElementById('results-grid').querySelectorAll('.skeleton-placeholder').forEach(el => el.remove()); document.getElementById('reroll-btn').classList.remove('hidden'); document.getElementById('error-message').textContent = err.message; document.getElementById('error-state').classList.remove('hidden'); } } function renderResults(data, append = false) { document.getElementById('loading').classList.add('hidden'); const grid = document.getElementById('results-grid'); // Remove any skeleton placeholders from re-roll grid.querySelectorAll('.skeleton-placeholder').forEach(el => el.remove()); document.getElementById('reroll-btn').classList.remove('hidden'); if (!append) { grid.innerHTML = ''; } if (!data.recommendations || data.recommendations.length === 0) { if (!append) { document.getElementById('empty-state').classList.remove('hidden'); } return; } data.recommendations.forEach(movie => { const card = document.createElement('div'); card.className = 'movie-card bg-dark-200 rounded-xl overflow-hidden border border-gray-800'; card.dataset.jellyfinId = movie.jellyfin_id; card.innerHTML = `
${movie.title}

${movie.title}

${movie.year ? `${movie.year}` : ''} ${movie.runtime_minutes ? `· ${movie.runtime_minutes}m` : ''} ${movie.community_rating ? `· ★ ${movie.community_rating.toFixed(1)}` : ''} ${movie.content_rating ? `· ${movie.content_rating}` : ''}
${movie.genres.slice(0, 3).map(g => `${g}`).join('')}

${movie.reasoning}

Watch
`; grid.appendChild(card); }); const meta = data.meta; document.getElementById('results-meta').textContent = `Evaluated ${meta.candidates_evaluated} of ${meta.total_unwatched} unwatched movies in ${(meta.processing_time_ms / 1000).toFixed(1)}s`; document.getElementById('results').classList.remove('hidden'); } // --- Search History --- async function loadHistory() { try { const res = await fetch(`${API}/api/history`); if (!res.ok) return; const history = await res.json(); const list = document.getElementById('history-list'); list.innerHTML = ''; if (history.length === 0) { document.getElementById('history-section').classList.add('hidden'); document.getElementById('history-toggle').classList.add('hidden'); return; } history.forEach(entry => { const date = new Date(entry.created_at); const timeStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); const movieTitles = entry.results.slice(0, 3).map(r => r.title).join(', '); const more = entry.results.length > 3 ? ` +${entry.results.length - 3} more` : ''; const item = document.createElement('div'); item.className = 'history-item bg-dark-200 border border-gray-800 rounded-lg p-3 cursor-pointer hover:border-gray-600 transition-colors'; item.innerHTML = `

"${entry.mood}"

${movieTitles}${more}

${timeStr}
`; item.addEventListener('click', (e) => { if (e.target.classList.contains('history-delete')) return; showHistoryResults(entry); }); list.appendChild(item); }); // Add delete handlers list.querySelectorAll('.history-delete').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const id = btn.dataset.id; await fetch(`${API}/api/history/${id}`, { method: 'DELETE' }); loadHistory(); }); }); // Show the toggle button if history is currently hidden if (document.getElementById('history-section').classList.contains('hidden')) { document.getElementById('history-toggle').classList.remove('hidden'); } } catch { /* ignore */ } } async function showHistoryResults(entry) { document.getElementById('mood-input').value = entry.mood; const data = { recommendations: entry.results, meta: entry.meta }; renderResults(data); window.scrollTo({ top: 0, behavior: 'smooth' }); // Check watch state and dim watched movies await markWatchedCards(entry.results.map(r => r.jellyfin_id)); } function toggleHistoryVisibility(show) { const section = document.getElementById('history-section'); const toggle = document.getElementById('history-toggle'); if (show) { section.classList.remove('hidden'); toggle.classList.add('hidden'); } else { section.classList.add('hidden'); toggle.classList.remove('hidden'); } } // --- Watch Check --- async function markWatchedCards(jellyfinIds) { if (!currentUser || !jellyfinIds.length) return; try { const res = await fetch(`${API}/api/library/watch-check`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jellyfin_ids: jellyfinIds }) }); if (!res.ok) return; const { watched } = await res.json(); if (!watched.length) return; const watchedSet = new Set(watched); document.querySelectorAll('.movie-card').forEach(card => { if (watchedSet.has(card.dataset.jellyfinId)) { card.classList.add('watched'); } }); } catch { /* ignore */ } } // --- Shared Search --- async function loadSharedSearch() { const params = new URLSearchParams(window.location.search); const sharedId = params.get('s'); if (!sharedId) return false; try { const res = await fetch(`${API}/api/history/shared/${sharedId}`); if (!res.ok) return false; const entry = await res.json(); document.getElementById('mood-input').value = entry.mood; const data = { recommendations: entry.results, meta: entry.meta }; renderResults(data); // If logged in, check watch state if (currentUser) { await markWatchedCards(entry.results.map(r => r.jellyfin_id)); } return true; } catch { return false; } } // --- Library Stats --- async function loadStats() { try { const res = await fetch(`${API}/api/library/stats`); if (!res.ok) return; const stats = await res.json(); document.getElementById('library-stats').textContent = `${stats.total_movies} movies in library${stats.last_sync ? ` · Last synced ${new Date(stats.last_sync).toLocaleString()}` : ''}`; } catch { /* ignore */ } } async function refreshLibrary() { document.getElementById('refresh-btn').textContent = 'Syncing...'; try { await fetch(`${API}/api/library/sync`, { method: 'POST' }); setTimeout(loadStats, 5000); } catch { /* ignore */ } setTimeout(() => { document.getElementById('refresh-btn').textContent = 'Refresh Library'; }, 3000); } // --- Event Listeners --- document.getElementById('login-form').addEventListener('submit', async (e) => { e.preventDefault(); const errorEl = document.getElementById('login-error'); errorEl.classList.add('hidden'); try { await login( document.getElementById('login-username').value, document.getElementById('login-password').value ); } catch (err) { errorEl.textContent = err.message; errorEl.classList.remove('hidden'); } }); document.getElementById('find-btn').addEventListener('click', () => findMovies()); document.getElementById('mood-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') findMovies(); }); document.getElementById('logout-btn').addEventListener('click', logout); document.getElementById('refresh-btn').addEventListener('click', refreshLibrary); document.getElementById('toggle-history-btn').addEventListener('click', () => toggleHistoryVisibility(false)); document.getElementById('show-history-btn').addEventListener('click', () => { toggleHistoryVisibility(true); loadHistory(); }); // Runtime filter buttons document.querySelectorAll('.runtime-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.runtime-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); maxRuntime = btn.dataset.runtime ? parseInt(btn.dataset.runtime) : null; }); }); // Kid-friendly toggle document.getElementById('kid-friendly-btn').addEventListener('click', () => { kidFriendly = !kidFriendly; const btn = document.getElementById('kid-friendly-btn'); if (kidFriendly) { btn.classList.add('active'); } else { btn.classList.remove('active'); } }); // Surprise me document.getElementById('surprise-btn').addEventListener('click', () => { const prompt = SURPRISE_PROMPTS[Math.floor(Math.random() * SURPRISE_PROMPTS.length)]; document.getElementById('mood-input').value = prompt; findMovies(); }); // Re-roll document.getElementById('reroll-btn').addEventListener('click', () => { findMovies(shownMovieIds); }); // Share / Copy Link document.getElementById('share-btn').addEventListener('click', () => { if (!currentHistoryId) return; const url = `${window.location.origin}${window.location.pathname}?s=${currentHistoryId}`; navigator.clipboard.writeText(url).then(() => { const btn = document.getElementById('share-btn'); btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy Link'; }, 2000); }).catch(() => { // Fallback for insecure contexts prompt('Copy this link:', `${window.location.origin}${window.location.pathname}?s=${currentHistoryId}`); }); }); // Reset document.getElementById('reset-btn').addEventListener('click', () => { // Clear mood input and state document.getElementById('mood-input').value = ''; lastMood = ''; shownMovieIds = []; currentHistoryId = null; // Reset runtime filter maxRuntime = null; document.querySelectorAll('.runtime-btn').forEach(b => b.classList.remove('active')); document.querySelector('.runtime-btn[data-runtime=""]').classList.add('active'); // Reset kid-friendly kidFriendly = false; document.getElementById('kid-friendly-btn').classList.remove('active'); // Reset view to empty state document.getElementById('results').classList.add('hidden'); document.getElementById('loading').classList.add('hidden'); document.getElementById('error-state').classList.add('hidden'); document.getElementById('empty-state').classList.remove('hidden'); document.getElementById('results-grid').innerHTML = ''; }); // --- Init --- async function init() { const isShared = await loadSharedSearch(); if (isShared) { // Show main screen in read-only mode for shared links document.getElementById('login-screen').classList.add('hidden'); document.getElementById('main-screen').classList.remove('hidden'); // Hide controls that require auth document.getElementById('user-picker').classList.add('hidden'); document.getElementById('logout-btn').classList.add('hidden'); document.getElementById('user-name').textContent = ''; document.getElementById('reroll-btn').classList.add('hidden'); // Still try to auth in background for watch-check try { const res = await fetch(`${API}/api/auth/me`); if (res.ok) { currentUser = await res.json(); document.getElementById('user-name').textContent = currentUser.name; document.getElementById('logout-btn').classList.remove('hidden'); // Now check watch state with the logged-in user const grid = document.getElementById('results-grid'); const ids = Array.from(grid.querySelectorAll('.movie-card')).map(c => c.dataset.jellyfinId).filter(Boolean); if (ids.length) await markWatchedCards(ids); } } catch { /* not logged in, that's fine */ } } else { await checkAuth(); } } init();