/* =========================================================
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();