Files
movie-night/app/static/app.js
T
kbondelie d8c8b473ad Add search history with saved results
Saves each mood search and its recommendations to SQLite per user.
Recent searches appear below the results area with mood text, top
movie titles, and timestamp. Click any entry to reload those results.
Entries can be deleted individually. History toggleable via show/hide.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 19:57:50 -07:00

310 lines
12 KiB
JavaScript

// Movie Night — Frontend Logic
const API = '';
let currentUser = null;
let selectedUserIds = [];
// --- 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() {
const mood = document.getElementById('mood-input').value.trim();
if (!mood) return;
document.getElementById('empty-state').classList.add('hidden');
document.getElementById('results').classList.add('hidden');
document.getElementById('error-state').classList.add('hidden');
document.getElementById('loading').classList.remove('hidden');
try {
const additionalIds = selectedUserIds.filter(id => id !== currentUser.id);
const res = await fetch(`${API}/api/mood`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mood, additional_user_ids: additionalIds })
});
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();
renderResults(data);
loadHistory();
} catch (err) {
document.getElementById('loading').classList.add('hidden');
document.getElementById('error-message').textContent = err.message;
document.getElementById('error-state').classList.remove('hidden');
}
}
function renderResults(data) {
document.getElementById('loading').classList.add('hidden');
const grid = document.getElementById('results-grid');
grid.innerHTML = '';
if (!data.recommendations || data.recommendations.length === 0) {
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.innerHTML = `
<div class="aspect-[2/3] bg-dark-300 relative">
<img src="${movie.poster_url}" alt="${movie.title}"
class="w-full h-full object-cover"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
<div class="absolute inset-0 flex items-center justify-center text-gray-600 text-lg font-semibold" style="display:none;">
${movie.title}
</div>
</div>
<div class="p-4">
<h3 class="font-semibold text-lg leading-tight">${movie.title}</h3>
<div class="flex items-center gap-2 mt-1 text-sm text-gray-400">
${movie.year ? `<span>${movie.year}</span>` : ''}
${movie.runtime_minutes ? `<span>&middot; ${movie.runtime_minutes}m</span>` : ''}
${movie.community_rating ? `<span>&middot; ★ ${movie.community_rating.toFixed(1)}</span>` : ''}
${movie.content_rating ? `<span>&middot; ${movie.content_rating}</span>` : ''}
</div>
<div class="flex flex-wrap gap-1 mt-2">
${movie.genres.slice(0, 3).map(g => `<span class="genre-pill">${g}</span>`).join('')}
</div>
<p class="text-sm text-gray-400 mt-3 italic leading-relaxed">${movie.reasoning}</p>
<a href="${movie.deep_link}" target="_blank"
class="block mt-4 text-center py-2 bg-indigo-600 hover:bg-indigo-700 rounded-lg font-semibold transition-colors text-sm">
Watch
</a>
</div>
`;
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 = `
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-200 truncate">"${entry.mood}"</p>
<p class="text-xs text-gray-500 mt-1 truncate">${movieTitles}${more}</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<span class="text-xs text-gray-600">${timeStr}</span>
<button class="history-delete text-gray-700 hover:text-red-400 transition-colors text-xs" data-id="${entry.id}">&times;</button>
</div>
</div>
`;
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 */ }
}
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' });
}
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');
}
}
// --- 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();
});
// --- Init ---
checkAuth();