/* ========================================================= WATCHLIST app.js ========================================================= */ /* State */ const state = { filter: 'unwatched', type: '', forWhom: '', 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'); const forGroup = document.getElementById('forGroup'); // Toast const toastContainer = document.getElementById('toastContainer'); /* Type & For toggle state (modal) */ let selectedType = 'movie'; let selectedForWhom = 'all'; /* 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); pushUrlState(); try { const data = await api({ action: 'list', filter: state.filter, type: state.type, for_whom: state.forWhom, 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; const all = chip('All Services', '', state.service === ''); all.addEventListener('click', () => { state.service = ''; loadItems(); }); serviceRow.appendChild(all); state.services.forEach(s => { const c = chip(s.streaming_service, s.streaming_service, state.service === s.streaming_service); c.addEventListener('click', () => { state.service = state.service === s.streaming_service ? '' : s.streaming_service; loadItems(); }); serviceRow.appendChild(c); }); } function chip(label, value, active) { const btn = document.createElement('button'); btn.className = 'service-chip' + (active ? ' active' : ''); btn.textContent = label; btn.dataset.value = value; return btn; } function populateServiceDatalist() { serviceList.innerHTML = ''; state.services.forEach(s => { const opt = document.createElement('option'); opt.value = s.streaming_service; serviceList.appendChild(opt); }); } /* Render Grid */ function renderGrid() { // Remove old cards (keep loading state) Array.from(grid.children).forEach(child => { if (child.id !== 'loadingState') child.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.innerHTML = `
🎬

Nothing here yet

Add your first title 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'; // For badge label const forLabels = { all: 'All', nik: 'Nik', tod: 'Tod' }; const forLabel = forLabels[item.for_whom] || 'All'; const showForBadge = item.for_whom && item.for_whom !== 'all'; // Format date const dateStr = item.watched_at ? 'Watched ' + formatDate(item.watched_at) : 'Added ' + formatDate(item.created_at); const card = document.createElement('div'); card.className = 'card' + (watched ? ' watched' : ''); card.dataset.id = item.id; card.innerHTML = `
${isMovie ? 'Movie' : 'Show'} ${showForBadge ? `${escHtml(forLabel)}` : ''} ${item.streaming_service ? `${escHtml(item.streaming_service)}` : ''}

${escHtml(item.title)}

${item.genre ? `

${escHtml(item.genre)}

` : ''} ${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.watched ? 'Marked as watched!' : 'Moved back to watchlist'); loadItems(); } catch (err) { toast('Failed to update', 'error'); } } /* Open Add Modal */ function openAdd() { editId.value = ''; addForm.reset(); selectedType = 'movie'; selectedForWhom = 'all'; updateTypeToggle(); updateForToggle(); modalTitle.textContent = 'Add Title'; submitBtn.textContent = 'Add to Watchlist'; showModal(true); } /* Open Edit Modal */ 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'; selectedForWhom = item.for_whom || 'all'; updateTypeToggle(); updateForToggle(); modalTitle.textContent = 'Edit Title'; submitBtn.textContent = 'Save Changes'; showModal(true); } function showModal(on) { modalOverlay.classList.toggle('hidden', !on); if (on) setTimeout(() => inputTitle.focus(), 50); } /* Type toggle (modal) */ 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(); }); }); /* For toggle (modal) */ function updateForToggle() { document.querySelectorAll('.for-option').forEach(btn => { btn.classList.toggle('active', btn.dataset.forVal === selectedForWhom); }); } document.querySelectorAll('.for-option').forEach(btn => { btn.addEventListener('click', () => { selectedForWhom = btn.dataset.forVal; updateForToggle(); }); }); /* Form submit */ addForm.addEventListener('submit', async (e) => { e.preventDefault(); const title = inputTitle.value.trim(); if (!title) { toast('Title is required', 'error'); return; } submitBtn.disabled = true; submitBtn.textContent = editId.value ? 'Saving...' : 'Adding...'; const payload = { action: editId.value ? 'edit' : 'add', id: editId.value ? parseInt(editId.value) : undefined, title, type: selectedType, for_whom: selectedForWhom, streaming_service: inputService.value.trim(), genre: inputGenre.value.trim(), notes: inputNotes.value.trim(), }; try { const data = await api({}, payload); if (!data.success) throw new Error(data.error); showModal(false); toast(editId.value ? 'Title updated!' : 'Added to watchlist!'); loadItems(); } catch (err) { toast(err.message || 'Something went wrong', 'error'); } finally { submitBtn.disabled = false; submitBtn.textContent = editId.value ? 'Save Changes' : 'Add to Watchlist'; } }); /* Delete confirm */ let pendingDeleteId = null; function openDeleteConfirm(id, title) { pendingDeleteId = id; deleteTitle.textContent = title; deleteOverlay.classList.remove('hidden'); } function closeDelete() { pendingDeleteId = null; deleteOverlay.classList.add('hidden'); } confirmDelete.addEventListener('click', async () => { if (!pendingDeleteId) return; try { const data = await api({}, { action: 'delete', id: pendingDeleteId }); if (!data.success) throw new Error(data.error); closeDelete(); toast('Title removed'); loadItems(); } catch (err) { toast('Failed to delete', 'error'); } }); closeDeleteModal.addEventListener('click', closeDelete); cancelDelete.addEventListener('click', closeDelete); /* Modal close */ closeModal.addEventListener('click', () => showModal(false)); cancelModal.addEventListener('click', () => showModal(false)); modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) showModal(false); }); /* Header Add button */ openAddModal.addEventListener('click', openAdd); /* 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(); }); /* Type filter pills */ 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(); }); /* For filter pills */ forGroup.addEventListener('click', (e) => { const btn = e.target.closest('.for-pill'); if (!btn) return; forGroup.querySelectorAll('.for-pill').forEach(b => b.classList.remove('active')); btn.classList.add('active'); state.forWhom = btn.dataset.for; loadItems(); }); /* Search */ let searchTimer; searchInput.addEventListener('input', () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { state.search = searchInput.value.trim(); loadItems(); }, 300); }); /* Toast */ function toast(msg, type = 'default') { const el = document.createElement('div'); el.className = 'toast toast-' + type; el.textContent = msg; toastContainer.appendChild(el); requestAnimationFrame(() => el.classList.add('show')); setTimeout(() => { el.classList.remove('show'); setTimeout(() => el.remove(), 300); }, 3000); } /* Helpers */ function escHtml(str) { return String(str ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function escAttr(str) { return String(str ?? '').replace(/"/g, '"'); } function formatDate(dateStr) { if (!dateStr) return ''; const d = new Date(dateStr); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } /* URL State Management */ function readUrlParams() { const params = new URLSearchParams(window.location.search); if (params.has('filter')) state.filter = params.get('filter'); if (params.has('type')) state.type = params.get('type'); if (params.has('for')) state.forWhom = params.get('for'); if (params.has('service')) state.service = params.get('service'); if (params.has('search')) state.search = params.get('search'); // Sync pill UI to state filterGroup.querySelectorAll('.pill').forEach(b => { b.classList.toggle('active', b.dataset.filter === state.filter); }); typeGroup.querySelectorAll('.type-pill').forEach(b => { b.classList.toggle('active', b.dataset.type === state.type); }); forGroup.querySelectorAll('.for-pill').forEach(b => { b.classList.toggle('active', b.dataset.for === state.forWhom); }); if (state.search) searchInput.value = state.search; } function pushUrlState() { const params = new URLSearchParams(); if (state.filter && state.filter !== 'unwatched') params.set('filter', state.filter); if (state.type) params.set('type', state.type); if (state.forWhom) params.set('for', state.forWhom); if (state.service) params.set('service', state.service); if (state.search) params.set('search', state.search); const qs = params.toString(); const url = window.location.pathname + (qs ? '?' + qs : ''); history.replaceState(null, '', url); } /* Handle browser back/forward */ window.addEventListener('popstate', () => { readUrlParams(); loadItems(); }); /* Theme Toggle (dark/light mode with cookie persistence) */ (function initTheme() { const toggle = document.getElementById('themeToggle'); const html = document.documentElement; function getCookie(name) { const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); return match ? match[2] : null; } function setCookie(name, value, days) { const d = new Date(); d.setTime(d.getTime() + days * 86400000); document.cookie = name + '=' + value + ';expires=' + d.toUTCString() + ';path=/;SameSite=Lax'; } const saved = getCookie('theme'); const theme = saved === 'dark' ? 'dark' : 'light'; html.setAttribute('data-theme', theme); if (toggle) { toggle.addEventListener('click', () => { const current = html.getAttribute('data-theme'); const next = current === 'dark' ? 'light' : 'dark'; html.setAttribute('data-theme', next); setCookie('theme', next, 365); }); } })(); /* Init */ readUrlParams(); loadItems();