/* ========================================================= WATCHLIST app.js ========================================================= */ /* State */ const state = { filter: 'all', type: '', service: '', search: '', items: [], stats: { total: 0, watched: 0, unwatched: 0 }, services: [], }; /* DOM refs */ const grid = document.getElementById('grid'); const loadingState = document.getElementById('loadingState'); const statTotal = document.getElementById('statTotal'); const statUnwatched = document.getElementById('statUnwatched'); const statWatched = document.getElementById('statWatched'); const progressFill = document.getElementById('progressFill'); const statPercent = document.getElementById('statPercent'); const serviceRow = document.getElementById('serviceRow'); const searchInput = document.getElementById('searchInput'); // Modal Add/Edit const modalOverlay = document.getElementById('modalOverlay'); const modal = document.getElementById('modal'); const modalTitle = document.getElementById('modalTitle'); const openAddModal = document.getElementById('openAddModal'); const openAddModalEmpty = document.getElementById('openAddModalEmpty'); const closeModal = document.getElementById('closeModal'); const cancelModal = document.getElementById('cancelModal'); const addForm = document.getElementById('addForm'); const editId = document.getElementById('editId'); const inputTitle = document.getElementById('inputTitle'); const inputService = document.getElementById('inputService'); const inputGenre = document.getElementById('inputGenre'); const inputNotes = document.getElementById('inputNotes'); const submitBtn = document.getElementById('submitBtn'); const serviceList = document.getElementById('serviceList'); // Modal Delete const deleteOverlay = document.getElementById('deleteOverlay'); const closeDeleteModal = document.getElementById('closeDeleteModal'); const cancelDelete = document.getElementById('cancelDelete'); const confirmDelete = document.getElementById('confirmDelete'); const deleteTitle = document.getElementById('deleteTitle'); // Filter pills const filterGroup = document.getElementById('filterGroup'); const typeGroup = document.getElementById('typeGroup'); // Toast const toastContainer = document.getElementById('toastContainer'); /* Type toggle state */ let selectedType = 'movie'; /* API helper */ async function api(params = {}, body = null) { const url = new URL('api.php', location.href); Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); const opts = body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : { method: 'GET' }; const res = await fetch(url, opts); return res.json(); } /* Fetch & render */ async function loadItems() { showLoading(true); try { const data = await api({ action: 'list', filter: state.filter, type: state.type, service: state.service, search: state.search, }); if (!data.success) throw new Error(data.error || 'Unknown error'); state.items = data.items || []; state.stats = data.stats || { total: 0, watched: 0, unwatched: 0 }; state.services = data.services || []; renderStats(); renderServiceRow(); renderGrid(); populateServiceDatalist(); } catch (err) { console.error(err); toast('Failed to load watchlist', 'error'); } finally { showLoading(false); } } function showLoading(on) { loadingState.style.display = on ? 'flex' : 'none'; } /* Render Stats */ function renderStats() { const { total, watched, unwatched } = state.stats; const t = parseInt(total) || 0; const w = parseInt(watched) || 0; const u = parseInt(unwatched)|| 0; statTotal.textContent = t; statWatched.textContent = w; statUnwatched.textContent = u; const pct = t > 0 ? Math.round((w / t) * 100) : 0; progressFill.style.width = pct + '%'; statPercent.textContent = pct + '% complete'; } /* Render Service Row */ function renderServiceRow() { serviceRow.innerHTML = ''; if (!state.services.length) return; // "All" chip const all = chip('All Services', '', state.service === ''); serviceRow.appendChild(all); state.services.forEach(({ streaming_service }) => { const c = chip(streaming_service, streaming_service, state.service === streaming_service); serviceRow.appendChild(c); }); } function chip(label, value, active) { const btn = document.createElement('button'); btn.className = 'service-chip' + (active ? ' active' : ''); btn.textContent = label; btn.addEventListener('click', () => { state.service = value; loadItems(); }); return btn; } /* Populate datalist for service input */ function populateServiceDatalist() { if (!serviceList) return; serviceList.innerHTML = ''; state.services.forEach(({ streaming_service }) => { const opt = document.createElement('option'); opt.value = streaming_service; serviceList.appendChild(opt); }); } /* Render Grid */ function renderGrid() { // Remove old cards (keep loading div) Array.from(grid.children).forEach(el => { if (!el.id || el.id !== 'loadingState') el.remove(); }); const emptyEl = document.getElementById('emptyState'); if (emptyEl) emptyEl.remove(); if (!state.items.length) { renderEmpty(); return; } state.items.forEach(item => { grid.appendChild(buildCard(item)); }); } function renderEmpty() { const div = document.createElement('div'); div.className = 'empty-state'; div.id = 'emptyState'; div.innerHTML = `

Nothing here yet

Add your first movie or show to get started.

`; grid.appendChild(div); div.querySelector('#openAddModalEmpty').addEventListener('click', openAdd); } /* Build Card */ function buildCard(item) { const watched = item.watched == 1; const isMovie = item.type === 'movie'; const card = document.createElement('div'); card.className = 'card' + (watched ? ' watched' : ''); card.dataset.id = item.id; // Format date const dateStr = item.watched_at ? 'Watched ' + formatDate(item.watched_at) : 'Added ' + formatDate(item.created_at); card.innerHTML = `
${isMovie ? 'Movie' : 'Show'} ${item.streaming_service ? `${escHtml(item.streaming_service)}` : ''}
${item.genre ? `${escHtml(item.genre)}` : ''}

${escHtml(item.title)}

${item.notes ? `

${escHtml(item.notes)}

` : ''}

${dateStr}

`; // Events card.querySelector('.btn-watch').addEventListener('click', () => toggleWatched(item.id)); card.querySelector('.btn-edit').addEventListener('click', () => openEdit(item)); card.querySelector('.btn-delete').addEventListener('click', () => openDeleteConfirm(item.id, item.title)); return card; } /* Toggle watched */ async function toggleWatched(id) { try { const data = await api({}, { action: 'toggle', id }); if (!data.success) throw new Error(data.error); toast(data.item.watched ? '- Marked as watched' : 'Moved back to watchlist', 'success'); loadItems(); } catch (err) { toast('Could not update item', 'error'); } } /* Add Modal */ function openAdd() { editId.value = ''; addForm.reset(); selectedType = 'movie'; updateTypeToggle(); modalTitle.textContent = 'Add Title'; submitBtn.textContent = 'Add to Watchlist'; showModal(true); setTimeout(() => inputTitle.focus(), 50); } function openEdit(item) { editId.value = item.id; inputTitle.value = item.title; inputService.value = item.streaming_service || ''; inputGenre.value = item.genre || ''; inputNotes.value = item.notes || ''; selectedType = item.type || 'movie'; updateTypeToggle(); modalTitle.textContent = 'Edit Title'; submitBtn.textContent = 'Save Changes'; showModal(true); setTimeout(() => inputTitle.focus(), 50); } function showModal(on) { modalOverlay.classList.toggle('hidden', !on); document.body.style.overflow = on ? 'hidden' : ''; } /* Type toggle inside form */ function updateTypeToggle() { document.querySelectorAll('.type-option').forEach(btn => { btn.classList.toggle('active', btn.dataset.typeVal === selectedType); }); } document.querySelectorAll('.type-option').forEach(btn => { btn.addEventListener('click', () => { selectedType = btn.dataset.typeVal; updateTypeToggle(); }); }); /* Form submit */ addForm.addEventListener('submit', async (e) => { e.preventDefault(); const title = inputTitle.value.trim(); if (!title) { inputTitle.focus(); inputTitle.style.borderColor = 'var(--danger)'; setTimeout(() => inputTitle.style.borderColor = '', 1200); return; } submitBtn.disabled = true; submitBtn.textContent = editId.value ? 'Saving' : 'Adding'; try { const payload = { action: editId.value ? 'edit' : 'add', id: editId.value ? parseInt(editId.value) : undefined, title, type: selectedType, streaming_service: inputService.value.trim(), genre: inputGenre.value.trim(), notes: inputNotes.value.trim(), }; const data = await api({}, payload); if (!data.success) throw new Error(data.error); toast(editId.value ? '- Changes saved' : '- Added to watchlist', 'success'); showModal(false); loadItems(); } catch (err) { toast(err.message || 'Something went wrong', 'error'); } finally { submitBtn.disabled = false; submitBtn.textContent = editId.value ? 'Save Changes' : 'Add to Watchlist'; } }); /* Delete */ let pendingDeleteId = null; function openDeleteConfirm(id, title) { pendingDeleteId = id; deleteTitle.textContent = title; deleteOverlay.classList.remove('hidden'); document.body.style.overflow = 'hidden'; } function closeDelete() { pendingDeleteId = null; deleteOverlay.classList.add('hidden'); document.body.style.overflow = ''; } confirmDelete.addEventListener('click', async () => { if (!pendingDeleteId) return; confirmDelete.disabled = true; try { const data = await api({}, { action: 'delete', id: pendingDeleteId }); if (!data.success) throw new Error(data.error); toast('Removed from watchlist', 'success'); closeDelete(); loadItems(); } catch (err) { toast('Could not remove item', 'error'); } finally { confirmDelete.disabled = false; } }); /* Filter pills */ filterGroup.addEventListener('click', (e) => { const btn = e.target.closest('.pill'); if (!btn) return; filterGroup.querySelectorAll('.pill').forEach(b => b.classList.remove('active')); btn.classList.add('active'); state.filter = btn.dataset.filter; loadItems(); }); typeGroup.addEventListener('click', (e) => { const btn = e.target.closest('.type-pill'); if (!btn) return; typeGroup.querySelectorAll('.type-pill').forEach(b => b.classList.remove('active')); btn.classList.add('active'); state.type = btn.dataset.type; loadItems(); }); /* Search (debounced) */ let searchTimer; searchInput.addEventListener('input', () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { state.search = searchInput.value.trim(); loadItems(); }, 320); }); /* Modal close handlers */ openAddModal.addEventListener('click', openAdd); closeModal.addEventListener('click', () => showModal(false)); cancelModal.addEventListener('click', () => showModal(false)); closeDeleteModal.addEventListener('click', closeDelete); cancelDelete.addEventListener('click', closeDelete); // Close on overlay click modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) showModal(false); }); deleteOverlay.addEventListener('click', (e) => { if (e.target === deleteOverlay) closeDelete(); }); // Keyboard document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (!modalOverlay.classList.contains('hidden')) showModal(false); if (!deleteOverlay.classList.contains('hidden')) closeDelete(); } }); /* Toast */ function toast(msg, type = 'default') { const el = document.createElement('div'); el.className = 'toast' + (type === 'success' ? ' toast-success' : type === 'error' ? ' toast-error' : ''); const icon = type === 'success' ? '-' : type === 'error' ? '' : ''; el.innerHTML = `${icon}${escHtml(msg)}`; toastContainer.appendChild(el); setTimeout(() => { el.style.animation = 'toastOut 280ms ease forwards'; setTimeout(() => el.remove(), 280); }, 3000); } /* Helpers */ function escHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function escAttr(str) { return String(str).replace(/"/g, '"').replace(/'/g, '''); } function formatDate(dateStr) { if (!dateStr) return ''; const d = new Date(dateStr); if (isNaN(d)) return ''; return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } /* Init */ loadItems();