initial commit

This commit is contained in:
Toni
2026-02-09 11:24:43 +01:00
commit 413f24bc96
15 changed files with 2179 additions and 0 deletions

293
public/app.js Normal file
View File

@@ -0,0 +1,293 @@
const listEl = document.getElementById('list');
const addForm = document.getElementById('addForm');
const titleInput = document.getElementById('title');
const typeInput = document.getElementById('type');
const notesInput = document.getElementById('notes');
const posterInput = document.getElementById('poster');
const refreshBtn = document.getElementById('refresh');
const suggestBtn = document.getElementById('suggest');
const suggestionEl = document.getElementById('suggestion');
const onlyUnwatchedEl = document.getElementById('onlyUnwatched');
const darkToggle = document.getElementById('darkToggle');
const filterInput = document.getElementById('filter');
const searchInput = document.getElementById('search');
const searchResults = document.getElementById('searchResults');
let allItems = []; // cache of fetched watchlist
// Dark mode init & persistence
function applyDark(isDark) {
document.documentElement.classList.toggle('dark', isDark);
darkToggle.checked = isDark;
try { localStorage.setItem('wtw-dark', isDark ? '1' : '0'); } catch (e) {}
}
const stored = (() => { try { return localStorage.getItem('wtw-dark'); } catch (e) { return null; } })();
applyDark(stored === '1' || (stored === null && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches));
darkToggle.addEventListener('change', () => applyDark(darkToggle.checked));
// Fetch and cache list
async function fetchList() {
try {
const res = await fetch('/api/watchlist');
const items = await res.json();
allItems = items || [];
applyFilter();
} catch (e) {
listEl.innerHTML = `<div class="text-red-500">Failed to load list</div>`;
}
}
function applyFilter() {
const q = (filterInput?.value || '').trim().toLowerCase();
let items = allItems.slice();
if (onlyUnwatchedEl.checked) items = items.filter(i => !i.watched);
if (q) {
items = items.filter(it => {
const hay = ((it.title || '') + ' ' + (it.type || '') + ' ' + (it.notes || '') + ' ' + ((it.genres || []).join(' '))).toLowerCase();
return hay.includes(q);
});
}
renderList(items);
}
function renderList(items) {
listEl.innerHTML = '';
if (!items.length) {
listEl.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No items yet.</p>';
return;
}
items.forEach(item => {
const card = document.createElement('div');
card.className = 'flex items-center justify-between p-3 rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900';
const left = document.createElement('div');
left.className = 'flex items-center gap-3';
const imgWrap = document.createElement('div');
imgWrap.className = 'w-16 flex-shrink-0';
if (item.poster) {
const img = document.createElement('img');
img.src = item.poster;
img.alt = item.title || 'poster';
img.className = 'w-16 h-24 object-cover rounded';
imgWrap.appendChild(img);
} else {
const placeholder = document.createElement('div');
placeholder.className = 'w-16 h-24 bg-gray-200 dark:bg-gray-700 rounded flex items-center justify-center text-sm text-gray-500';
placeholder.textContent = 'No\nImage';
imgWrap.appendChild(placeholder);
}
const info = document.createElement('div');
info.innerHTML = `<div class="${item.watched ? 'line-through text-gray-500 dark:text-gray-400' : 'font-medium'}">${escapeHtml(item.title)} <span class="text-sm text-gray-500 dark:text-gray-400">(${escapeHtml(item.type)})</span></div>
<div class="text-sm text-gray-600 dark:text-gray-300">${item.notes ? escapeHtml(item.notes) : ''}</div>`;
left.appendChild(imgWrap);
left.appendChild(info);
const right = document.createElement('div');
right.className = 'flex gap-2';
const toggleBtn = document.createElement('button');
toggleBtn.className = 'px-2 py-1 rounded border text-sm';
toggleBtn.textContent = item.watched ? 'Unwatch' : 'Mark watched';
toggleBtn.onclick = async () => {
await fetch('/api/watchlist/' + item.id + '/toggle', { method: 'PUT' });
await fetchList();
};
const delBtn = document.createElement('button');
delBtn.className = 'px-2 py-1 rounded border text-sm text-red-600 dark:text-red-400';
delBtn.textContent = 'Remove';
delBtn.onclick = async () => {
if (!confirm('Remove this item?')) return;
await fetch('/api/watchlist/' + item.id, { method: 'DELETE' });
await fetchList();
};
right.appendChild(toggleBtn);
right.appendChild(delBtn);
card.appendChild(left);
card.appendChild(right);
listEl.appendChild(card);
});
}
addForm.addEventListener('submit', async (e) => {
e.preventDefault();
const payload = {
title: titleInput.value.trim(),
type: typeInput.value,
notes: notesInput.value.trim(),
poster: posterInput.value.trim() || null
};
if (!payload.title) return alert('Title required');
await fetch('/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
titleInput.value = '';
notesInput.value = '';
posterInput.value = '';
await fetchList();
});
refreshBtn.addEventListener('click', fetchList);
onlyUnwatchedEl.addEventListener('change', applyFilter);
filterInput?.addEventListener('input', applyFilter);
suggestBtn.addEventListener('click', async () => {
const onlyUnwatched = onlyUnwatchedEl.checked ? '?unwatched=1' : '';
const res = await fetch('/api/suggest' + onlyUnwatched);
const data = await res.json();
suggestionEl.classList.remove('hidden');
if (data.message) {
suggestionEl.innerHTML = `<div class="text-sm text-gray-600 dark:text-gray-300">${escapeHtml(data.message)}</div>`;
} else {
suggestionEl.innerHTML = `<div><strong class="font-medium">${escapeHtml(data.title)}</strong> <span class="text-sm text-gray-500 dark:text-gray-400">(${escapeHtml(data.type)})</span></div>`;
}
});
// --- Search UI + logic (unchanged) ---
let searchToken = 0;
function debounce(fn, wait = 300) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), wait);
};
}
async function doSearch(q) {
searchToken++;
const token = searchToken;
if (!q) {
searchResults.classList.add('hidden');
searchResults.innerHTML = '';
return;
}
try {
const res = await fetch('/api/search?q=' + encodeURIComponent(q));
if (token !== searchToken) return; // stale
if (!res.ok) {
searchResults.innerHTML = `<div class="p-3 text-sm text-red-500">Search failed</div>`;
searchResults.classList.remove('hidden');
return;
}
const items = await res.json();
renderSearchResults(items);
} catch (e) {
searchResults.innerHTML = `<div class="p-3 text-sm text-red-500">Search error</div>`;
searchResults.classList.remove('hidden');
}
}
function renderSearchResults(items) {
if (!items || !items.length) {
searchResults.innerHTML = `<div class="p-3 text-sm text-gray-500 dark:text-gray-400">No results</div>`;
searchResults.classList.remove('hidden');
return;
}
searchResults.innerHTML = '';
items.forEach(it => {
const el = document.createElement('div');
el.className = 'flex gap-3 p-3 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer';
const imgWrap = document.createElement('div');
imgWrap.className = 'w-16 flex-shrink-0';
if (it.poster) {
const img = document.createElement('img');
img.src = it.poster;
img.alt = it.title || 'poster';
img.className = 'w-16 h-24 object-cover rounded';
imgWrap.appendChild(img);
} else {
const placeholder = document.createElement('div');
placeholder.className = 'w-16 h-24 bg-gray-200 dark:bg-gray-700 rounded flex items-center justify-center text-sm text-gray-500';
placeholder.textContent = 'No\nImage';
imgWrap.appendChild(placeholder);
}
const body = document.createElement('div');
body.className = 'flex-1';
const titleRow = document.createElement('div');
titleRow.className = 'flex items-center justify-between';
const titleEl = document.createElement('div');
titleEl.className = 'font-medium';
titleEl.innerHTML = `${escapeHtml(it.title)} <span class="text-sm text-gray-500 dark:text-gray-400">(${escapeHtml(it.type)})</span>`;
titleRow.appendChild(titleEl);
const addBtn = document.createElement('button');
addBtn.className = 'ml-3 text-sm px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700';
addBtn.textContent = 'Add';
addBtn.onclick = async (e) => {
e.stopPropagation();
const payload = {
title: it.title,
type: it.type === 'tv' ? 'series' : 'movie',
notes: it.overview || '',
poster: it.poster || null
};
try {
const res = await fetch('/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
await fetchList();
searchResults.classList.add('hidden');
searchInput.value = '';
} else {
alert('Failed to add item');
}
} catch (err) {
alert('Failed to add item');
}
};
titleRow.appendChild(addBtn);
const overview = document.createElement('div');
overview.className = 'text-sm text-gray-600 dark:text-gray-300 mt-1';
overview.textContent = it.overview ? (it.overview.length > 220 ? it.overview.slice(0, 220) + '…' : it.overview) : '';
const genres = document.createElement('div');
genres.className = 'text-xs text-gray-500 dark:text-gray-400 mt-1';
genres.textContent = (it.genres || []).join(', ');
body.appendChild(titleRow);
body.appendChild(overview);
body.appendChild(genres);
el.appendChild(imgWrap);
el.appendChild(body);
el.addEventListener('click', () => {
titleInput.value = it.title;
typeInput.value = it.type === 'tv' ? 'series' : 'movie';
notesInput.value = it.overview || '';
posterInput.value = it.poster || '';
searchResults.classList.add('hidden');
});
searchResults.appendChild(el);
});
searchResults.classList.remove('hidden');
}
const debouncedSearch = debounce((v) => doSearch(v), 300);
searchInput.addEventListener('input', (e) => debouncedSearch(e.target.value.trim()));
document.addEventListener('click', (e) => {
if (!searchResults.contains(e.target) && e.target !== searchInput) {
searchResults.classList.add('hidden');
}
});
// --- util ---
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, function(m) {
return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m];
});
}
// initial load
fetchList();