Files
movie-checklist-zpilpy/app.js
2026-03-24 21:16:34 +00:00

471 lines
16 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =========================================================
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 = `
<div class="empty-icon">🎬</div>
<h2 class="empty-title">Nothing here yet</h2>
<p class="empty-sub">Add your first movie or show to get started.</p>
<button class="btn-primary" id="openAddModalEmpty">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Title
</button>
`;
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 = `
<div class="card-accent"></div>
<div class="card-body">
<div class="card-meta">
<span class="badge ${isMovie ? 'badge-movie' : 'badge-show'}">
${isMovie ? '🎬 Movie' : '📺 Show'}
</span>
${item.streaming_service ? `<span class="badge badge-service">${escHtml(item.streaming_service)}</span>` : ''}
${item.genre ? `<span class="badge badge-genre">${escHtml(item.genre)}</span>` : ''}
</div>
<h3 class="card-title">${escHtml(item.title)}</h3>
${item.notes ? `<p class="card-notes">${escHtml(item.notes)}</p>` : ''}
<p class="card-date">${dateStr}</p>
</div>
<div class="card-footer">
<button class="btn-watch" data-id="${item.id}" data-watched="${watched ? 1 : 0}">
${watched
? `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> Watched`
: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/></svg> Mark Watched`}
</button>
<div class="card-actions">
<button class="btn-icon btn-edit" data-id="${item.id}" title="Edit">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="btn-icon btn-delete" data-id="${item.id}" data-title="${escAttr(item.title)}" title="Remove">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
</div>
</div>
`;
// 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 = `<span class="toast-icon">${icon}</span><span>${escHtml(msg)}</span>`;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escAttr(str) {
return String(str).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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();