Files
movie-checklist-zpilpy/app.js
2026-03-25 01:47:19 +00:00

479 lines
15 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: '',
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);
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 = `
<div class="empty-icon">🎬</div>
<h3 class="empty-title">Nothing here yet</h3>
<p class="empty-sub">Add your first title to get started.</p>
<button class="btn-primary" id="openAddModalEmpty">
<svg width="16" height="16" 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';
// 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 = `
<div class="card-top">
<div class="card-badges">
<span class="badge badge-type">${isMovie ? '🎬 Movie' : '📺 Show'}</span>
${showForBadge ? `<span class="badge badge-for">👤 ${escHtml(forLabel)}</span>` : ''}
${item.streaming_service ? `<span class="badge badge-service">${escHtml(item.streaming_service)}</span>` : ''}
</div>
<div class="card-actions">
<button class="card-btn btn-edit" title="Edit" data-id="${escAttr(item.id)}">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="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="card-btn btn-delete" title="Remove" data-id="${escAttr(item.id)}" data-title="${escAttr(item.title)}">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</button>
</div>
</div>
<h3 class="card-title">${escHtml(item.title)}</h3>
${item.genre ? `<p class="card-genre">${escHtml(item.genre)}</p>` : ''}
${item.notes ? `<p class="card-notes">${escHtml(item.notes)}</p>` : ''}
<div class="card-footer">
<span class="card-date">${dateStr}</span>
<button class="btn-watch ${watched ? 'btn-unwatch' : ''}" data-id="${escAttr(item.id)}">
${watched
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="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"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> Mark Watched'
}
</button>
</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.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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escAttr(str) {
return String(str ?? '').replace(/"/g, '&quot;');
}
function formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
/* Init */
loadItems();