471 lines
16 KiB
JavaScript
471 lines
16 KiB
JavaScript
/* =========================================================
|
||
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, '&')
|
||
.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();
|