initial commit
This commit is contained in:
293
public/app.js
Normal file
293
public/app.js
Normal 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 ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m];
|
||||
});
|
||||
}
|
||||
|
||||
// initial load
|
||||
fetchList();
|
||||
78
public/index.html
Normal file
78
public/index.html
Normal file
@@ -0,0 +1,78 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>WhatToWatch — Watchlist</title>
|
||||
|
||||
<script>
|
||||
window.tailwind = {
|
||||
config: {
|
||||
darkMode: 'class',
|
||||
theme: { extend: {} }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen">
|
||||
<div class="max-w-3xl mx-auto p-6">
|
||||
<header class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-semibold">WhatToWatch</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input id="darkToggle" type="checkbox" class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700" />
|
||||
Dark mode
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="mb-6 bg-white dark:bg-gray-800 shadow rounded-lg p-4">
|
||||
<h2 class="text-lg font-medium mb-3">Add to Watchlist</h2>
|
||||
<form id="addForm" class="space-y-3">
|
||||
<input id="title" class="w-full rounded border px-3 py-2 bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700" placeholder="Title (movie or series)" required />
|
||||
<select id="type" class="w-full rounded border px-3 py-2 bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700">
|
||||
<option value="movie">Movie</option>
|
||||
<option value="series">Series</option>
|
||||
</select>
|
||||
<textarea id="notes" class="w-full rounded border px-3 py-2 bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700" placeholder="Notes (optional)"></textarea>
|
||||
<input id="poster" class="w-full rounded border px-3 py-2 bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700" placeholder="Poster image URL (optional)" />
|
||||
<div>
|
||||
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-lg font-medium">Your Watchlist</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="refresh" class="px-3 py-1 rounded border border-gray-200 dark:border-gray-700">Refresh</button>
|
||||
<button id="suggest" class="px-3 py-1 rounded bg-amber-400 hover:bg-amber-500 text-black">Suggest</button>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input id="onlyUnwatched" type="checkbox" class="h-4 w-4 rounded" />
|
||||
Only unwatched
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<input id="filter" type="search" placeholder="Filter watchlist..." class="w-full rounded border px-3 py-2 bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700 mb-3" autocomplete="off" />
|
||||
<label class="sr-only" for="search">Search movies & series</label>
|
||||
<div class="relative">
|
||||
<input id="search" type="search" placeholder="Search movies or series..." class="w-full rounded border px-3 py-2 bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700" autocomplete="off" />
|
||||
<div id="searchResults" class="absolute left-0 right-0 mt-1 z-20 hidden rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow max-h-80 overflow-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="suggestion" class="mb-4 p-3 rounded hidden bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700"></div>
|
||||
|
||||
<div id="list" class="space-y-3">
|
||||
<!-- items rendered here -->
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user