initial commit
This commit is contained in:
34
.dockerignore
Normal file
34
.dockerignore
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Include any files or directories that you don't want to be copied to your
|
||||||
|
# container here (e.g., local build artifacts, temporary files, etc.).
|
||||||
|
#
|
||||||
|
# For more help, visit the .dockerignore file reference guide at
|
||||||
|
# https://docs.docker.com/go/build-context-dockerignore/
|
||||||
|
|
||||||
|
**/.classpath
|
||||||
|
**/.dockerignore
|
||||||
|
**/.env
|
||||||
|
**/.git
|
||||||
|
**/.gitignore
|
||||||
|
**/.project
|
||||||
|
**/.settings
|
||||||
|
**/.toolstarget
|
||||||
|
**/.vs
|
||||||
|
**/.vscode
|
||||||
|
**/.next
|
||||||
|
**/.cache
|
||||||
|
**/*.*proj.user
|
||||||
|
**/*.dbmdl
|
||||||
|
**/*.jfm
|
||||||
|
**/charts
|
||||||
|
**/docker-compose*
|
||||||
|
**/compose.y*ml
|
||||||
|
**/Dockerfile*
|
||||||
|
**/node_modules
|
||||||
|
**/npm-debug.log
|
||||||
|
**/obj
|
||||||
|
**/secrets.dev.yaml
|
||||||
|
**/values.dev.yaml
|
||||||
|
**/build
|
||||||
|
**/dist
|
||||||
|
LICENSE
|
||||||
|
README.md
|
||||||
9
.env
Normal file
9
.env
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Copy to .env and fill values
|
||||||
|
DB_HOST=maria.casademm.de
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=whattowatch
|
||||||
|
DB_PASSWORD=9Noj0wftjMogC6
|
||||||
|
DB_NAME=whattowatch
|
||||||
|
PORT=3630
|
||||||
|
TMDB_API_KEY=0f73656f97dec043725ec67557b9c8aa
|
||||||
|
TMDB_API_TOKEN=eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiIwZjczNjU2Zjk3ZGVjMDQzNzI1ZWM2NzU1N2I5YzhhYSIsIm5iZiI6MTc2OTUwNDI0Ny44LCJzdWIiOiI2OTc4N2RmN2RlOWJmYjYyYTFhMzhmMmEiLCJzY29wZXMiOlsiYXBpX3JlYWQiXSwidmVyc2lvbiI6MX0.ohOV0St0yDtZquD2uZJyQzVjAcloT4HxmvZUPCVL6Ok
|
||||||
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Copy to .env and fill values
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASSWORD=yourpassword
|
||||||
|
DB_NAME=whattowatch
|
||||||
|
PORT=3000
|
||||||
38
Dockerfile
Normal file
38
Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# Comments are provided throughout this file to help you get started.
|
||||||
|
# If you need more help, visit the Dockerfile reference guide at
|
||||||
|
# https://docs.docker.com/go/dockerfile-reference/
|
||||||
|
|
||||||
|
# Want to help us make this template better? Share your feedback here: https://forms.gle/ybq9Krt8jtBL3iCk7
|
||||||
|
|
||||||
|
ARG NODE_VERSION=22.14.0
|
||||||
|
|
||||||
|
FROM node:${NODE_VERSION}-alpine
|
||||||
|
|
||||||
|
# Use production node environment by default.
|
||||||
|
ENV NODE_ENV production
|
||||||
|
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# Download dependencies as a separate step to take advantage of Docker's caching.
|
||||||
|
# Leverage a cache mount to /root/.npm to speed up subsequent builds.
|
||||||
|
# Leverage a bind mounts to package.json and package-lock.json to avoid having to copy them into
|
||||||
|
# into this layer.
|
||||||
|
RUN --mount=type=bind,source=package.json,target=package.json \
|
||||||
|
--mount=type=bind,source=package-lock.json,target=package-lock.json \
|
||||||
|
--mount=type=cache,target=/root/.npm \
|
||||||
|
npm ci --omit=dev
|
||||||
|
|
||||||
|
# Run the application as a non-root user.
|
||||||
|
USER node
|
||||||
|
|
||||||
|
# Copy the rest of the source files into the image.
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose the port that the application listens on.
|
||||||
|
EXPOSE 3630
|
||||||
|
|
||||||
|
# Run the application.
|
||||||
|
CMD npm start
|
||||||
22
README.Docker.md
Normal file
22
README.Docker.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
### Building and running your application
|
||||||
|
|
||||||
|
When you're ready, start your application by running:
|
||||||
|
`docker compose up --build`.
|
||||||
|
|
||||||
|
Your application will be available at http://localhost:3630.
|
||||||
|
|
||||||
|
### Deploying your application to the cloud
|
||||||
|
|
||||||
|
First, build your image, e.g.: `docker build -t myapp .`.
|
||||||
|
If your cloud uses a different CPU architecture than your development
|
||||||
|
machine (e.g., you are on a Mac M1 and your cloud provider is amd64),
|
||||||
|
you'll want to build the image for that platform, e.g.:
|
||||||
|
`docker build --platform=linux/amd64 -t myapp .`.
|
||||||
|
|
||||||
|
Then, push it to your registry, e.g. `docker push myregistry.com/myapp`.
|
||||||
|
|
||||||
|
Consult Docker's [getting started](https://docs.docker.com/go/get-started-sharing/)
|
||||||
|
docs for more detail on building and pushing.
|
||||||
|
|
||||||
|
### References
|
||||||
|
* [Docker's Node.js guide](https://docs.docker.com/language/nodejs/)
|
||||||
12
README.md
Normal file
12
README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# WhatToWatch
|
||||||
|
|
||||||
|
Minimal watchlist app (Node.js + Express + MariaDB) with a small frontend.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install Node.js (v16+) and MariaDB.
|
||||||
|
2. Create the database and table:
|
||||||
|
|
||||||
|
Using the MySQL/MariaDB client:
|
||||||
|
```bash
|
||||||
|
mysql -u root -p < migrations/init.sql
|
||||||
51
compose.yaml
Normal file
51
compose.yaml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Comments are provided throughout this file to help you get started.
|
||||||
|
# If you need more help, visit the Docker Compose reference guide at
|
||||||
|
# https://docs.docker.com/go/compose-spec-reference/
|
||||||
|
|
||||||
|
# Here the instructions define your application as a service called "server".
|
||||||
|
# This service is built from the Dockerfile in the current directory.
|
||||||
|
# You can add other services your application may depend on here, such as a
|
||||||
|
# database or a cache. For examples, see the Awesome Compose repository:
|
||||||
|
# https://github.com/docker/awesome-compose
|
||||||
|
services:
|
||||||
|
server:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
ports:
|
||||||
|
- 3630:3630
|
||||||
|
|
||||||
|
# The commented out section below is an example of how to define a PostgreSQL
|
||||||
|
# database that your application can use. `depends_on` tells Docker Compose to
|
||||||
|
# start the database before your application. The `db-data` volume persists the
|
||||||
|
# database data between container restarts. The `db-password` secret is used
|
||||||
|
# to set the database password. You must create `db/password.txt` and add
|
||||||
|
# a password of your choosing to it before running `docker-compose up`.
|
||||||
|
# depends_on:
|
||||||
|
# db:
|
||||||
|
# condition: service_healthy
|
||||||
|
# db:
|
||||||
|
# image: postgres
|
||||||
|
# restart: always
|
||||||
|
# user: postgres
|
||||||
|
# secrets:
|
||||||
|
# - db-password
|
||||||
|
# volumes:
|
||||||
|
# - db-data:/var/lib/postgresql/data
|
||||||
|
# environment:
|
||||||
|
# - POSTGRES_DB=example
|
||||||
|
# - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
|
||||||
|
# expose:
|
||||||
|
# - 5432
|
||||||
|
# healthcheck:
|
||||||
|
# test: [ "CMD", "pg_isready" ]
|
||||||
|
# interval: 10s
|
||||||
|
# timeout: 5s
|
||||||
|
# retries: 5
|
||||||
|
# volumes:
|
||||||
|
# db-data:
|
||||||
|
# secrets:
|
||||||
|
# db-password:
|
||||||
|
# file: db/password.txt
|
||||||
|
|
||||||
15
db.js
Normal file
15
db.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: process.env.DB_PORT ? Number(process.env.DB_PORT) : 3306,
|
||||||
|
user: process.env.DB_USER || 'root',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
database: process.env.DB_NAME || 'whattowatch',
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = pool;
|
||||||
12
migrations/init.sql
Normal file
12
migrations/init.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- Create database and watchlist table
|
||||||
|
CREATE DATABASE IF NOT EXISTS whattowatch;
|
||||||
|
USE whattowatch;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS watchlist (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
title VARCHAR(512) NOT NULL,
|
||||||
|
type VARCHAR(50) DEFAULT 'movie',
|
||||||
|
watched TINYINT(1) DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
1443
package-lock.json
generated
Normal file
1443
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "whattowatch",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Simple watchlist app with MariaDB and a minimal frontend",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"mysql2": "^3.3.3",
|
||||||
|
"dotenv": "^16.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^2.0.22"
|
||||||
|
}
|
||||||
|
}
|
||||||
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>
|
||||||
103
server.js
Normal file
103
server.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const pool = require('./db');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// Get all watchlist items
|
||||||
|
app.get('/api/watchlist', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query('SELECT * FROM watchlist ORDER BY created_at DESC');
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a new watchlist item (now accepts poster)
|
||||||
|
app.post('/api/watchlist', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { title, type = 'movie', notes = '', poster = null } = req.body;
|
||||||
|
if (!title || !title.trim()) return res.status(400).json({ error: 'Title required' });
|
||||||
|
const [result] = await pool.query(
|
||||||
|
'INSERT INTO watchlist (title, type, notes, poster) VALUES (?, ?, ?, ?)',
|
||||||
|
[title.trim(), type, notes, poster]
|
||||||
|
);
|
||||||
|
const [rows] = await pool.query('SELECT * FROM watchlist WHERE id = ?', [result.insertId]);
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete an item
|
||||||
|
app.delete('/api/watchlist/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
await pool.query('DELETE FROM watchlist WHERE id = ?', [id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle watched state
|
||||||
|
app.put('/api/watchlist/:id/toggle', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const [rows] = await pool.query('SELECT watched FROM watchlist WHERE id = ?', [id]);
|
||||||
|
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||||
|
const newState = rows[0].watched ? 0 : 1;
|
||||||
|
await pool.query('UPDATE watchlist SET watched = ? WHERE id = ?', [newState, id]);
|
||||||
|
res.json({ id: Number(id), watched: !!newState });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Suggest a random unwatched (or any) item
|
||||||
|
app.get('/api/suggest', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const onlyUnwatched = req.query.unwatched === '1' || req.query.unwatched === 'true';
|
||||||
|
let rows;
|
||||||
|
if (onlyUnwatched) {
|
||||||
|
[rows] = await pool.query('SELECT * FROM watchlist WHERE watched = 0');
|
||||||
|
} else {
|
||||||
|
[rows] = await pool.query('SELECT * FROM watchlist');
|
||||||
|
}
|
||||||
|
if (!rows.length) return res.json({ message: 'No items available' });
|
||||||
|
const choice = rows[Math.floor(Math.random() * rows.length)];
|
||||||
|
res.json(choice);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tmdb = require('./tmdb');
|
||||||
|
|
||||||
|
app.get('/api/search', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const q = req.query.q || req.query.query;
|
||||||
|
const type = req.query.type; // optional: movie or tv
|
||||||
|
if (!q || !q.trim()) return res.status(400).json({ error: 'query required' });
|
||||||
|
const results = await tmdb.search(q.trim(), type);
|
||||||
|
res.json(results);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('TMDb search error:', err?.response?.data || err.message || err);
|
||||||
|
res.status(500).json({ error: 'search failed', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
44
tmdb.js
Normal file
44
tmdb.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const API_KEY = process.env.TMDB_API_KEY;
|
||||||
|
const BASE = 'https://api.themoviedb.org/3';
|
||||||
|
let genreMap = null;
|
||||||
|
|
||||||
|
async function loadGenres() {
|
||||||
|
if (genreMap) return genreMap;
|
||||||
|
const [movieRes, tvRes] = await Promise.all([
|
||||||
|
axios.get(`${BASE}/genre/movie/list`, { params: { api_key: API_KEY } }),
|
||||||
|
axios.get(`${BASE}/genre/tv/list`, { params: { api_key: API_KEY } })
|
||||||
|
]);
|
||||||
|
genreMap = {};
|
||||||
|
(movieRes.data.genres || []).forEach(g => genreMap[g.id] = g.name);
|
||||||
|
(tvRes.data.genres || []).forEach(g => genreMap[g.id] = g.name);
|
||||||
|
return genreMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(query, mediaType) {
|
||||||
|
if (!API_KEY) throw new Error('TMDB_API_KEY not set in environment');
|
||||||
|
await loadGenres();
|
||||||
|
const params = { api_key: API_KEY, query, include_adult: false, page: 1 };
|
||||||
|
let url = `${BASE}/search/multi`;
|
||||||
|
if (mediaType === 'movie' || mediaType === 'tv') url = `${BASE}/search/${mediaType}`;
|
||||||
|
const res = await axios.get(url, { params });
|
||||||
|
const results = (res.data.results || []).slice(0, 12);
|
||||||
|
return results.map(r => {
|
||||||
|
const title = r.title || r.name || r.original_title || r.original_name || '';
|
||||||
|
const overview = r.overview || '';
|
||||||
|
const genreIds = r.genre_ids || (r.genres ? r.genres.map(g => g.id) : []);
|
||||||
|
const genres = genreIds.map(id => genreMap[id]).filter(Boolean);
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
title,
|
||||||
|
type: r.media_type || (r.title ? 'movie' : 'tv'),
|
||||||
|
overview,
|
||||||
|
genres,
|
||||||
|
poster: r.poster_path ? `https://image.tmdb.org/t/p/w342${r.poster_path}` : null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { search };
|
||||||
Reference in New Issue
Block a user