From 5469d3d9d7daf5c2f54de84f87a0786b51ad1d55 Mon Sep 17 00:00:00 2001 From: Lumerel Deploy Date: Tue, 24 Mar 2026 22:35:58 +0000 Subject: [PATCH] Deploy from Lumerel --- .docker/99-migrate.sh | 54 ++ .dockerignore | 3 + Dockerfile | 23 + api.php | 189 +++++ app.js | 472 +++++++++++ db.php | 14 + index.php | 169 ++++ migrate.php | 25 + migrations/001_create_watchlist_table.php | 13 + style.css | 938 ++++++++++++++++++++++ 10 files changed, 1900 insertions(+) create mode 100644 .docker/99-migrate.sh create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 api.php create mode 100644 app.js create mode 100644 db.php create mode 100644 index.php create mode 100644 migrate.php create mode 100644 migrations/001_create_watchlist_table.php create mode 100644 style.css diff --git a/.docker/99-migrate.sh b/.docker/99-migrate.sh new file mode 100644 index 0000000..66b83d4 --- /dev/null +++ b/.docker/99-migrate.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +# IMPORTANT: This script is sourced (not executed) by the webdevops entrypoint. +# Never use "exit" — it would kill the parent entrypoint and prevent supervisord from starting. +# Use "return" instead to leave this script without affecting the parent. + +echo ">>> Waiting for database connection..." +echo ">>> DB_HOST=${DB_HOST:-NOT SET}" +echo ">>> DB_PORT=${DB_PORT:-NOT SET}" +echo ">>> DB_DATABASE=${DB_DATABASE:-NOT SET}" +echo ">>> DB_USERNAME=${DB_USERNAME:-NOT SET}" +echo ">>> DB_PASSWORD is $([ -n "$DB_PASSWORD" ] && echo 'set' || echo 'NOT SET')" + +if [ -z "$DB_HOST" ] || [ -z "$DB_USERNAME" ]; then + echo ">>> ERROR: DB_HOST or DB_USERNAME not set. Skipping migrations." + return 0 2>/dev/null || true +fi + +_migrate_max_retries=30 +_migrate_count=0 +_migrate_done=false + +while [ $_migrate_count -lt $_migrate_max_retries ]; do + if php -r ' + try { + $host = getenv("DB_HOST"); + $port = getenv("DB_PORT") ?: "3306"; + $user = getenv("DB_USERNAME"); + $pass = getenv("DB_PASSWORD"); + new PDO("mysql:host=$host;port=$port", $user, $pass, [PDO::ATTR_TIMEOUT => 3]); + exit(0); + } catch (Throwable $e) { + fwrite(STDERR, "PDO error: " . $e->getMessage() . "\n"); + exit(1); + } + ' 2>&1; then + echo ">>> Database is reachable. Running migrations..." + if php /app/migrate.php; then + echo ">>> Migrations completed successfully." + else + echo ">>> WARNING: migrate.php exited with code $?" + fi + _migrate_done=true + break + fi + + _migrate_count=$((_migrate_count + 1)) + echo ">>> Waiting for database... attempt $_migrate_count/$_migrate_max_retries" + sleep 2 +done + +if [ "$_migrate_done" = false ]; then + echo ">>> WARNING: Could not connect to database after $_migrate_max_retries attempts. Skipping migrations." +fi \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..17896fe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git +Dockerfile +.dockerignore diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e39253c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM webdevops/php-nginx:8.3-alpine +ENV WEB_DOCUMENT_ROOT=/app +ARG CACHE_BUST=1774391758 +COPY . /app +RUN echo "index index.php index.html index.htm;" > /opt/docker/etc/nginx/vhost.common.d/01-index.conf \ + && echo "add_header Cache-Control 'no-cache, no-store, must-revalidate';" > /opt/docker/etc/nginx/vhost.common.d/02-no-cache.conf +RUN set -e; if [ -f /app/composer.json ]; then \ + echo ">>> composer.json found, installing dependencies..."; \ + curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer; \ + cd /app && composer install --no-dev --no-interaction --optimize-autoloader; \ + ls -la /app/vendor/autoload.php; \ + else \ + echo ">>> No composer.json found, skipping composer install"; \ + fi +RUN set -e; if [ -f /app/package.json ]; then \ + echo ">>> package.json found, installing node dependencies..."; \ + apk add --no-cache nodejs npm; \ + cd /app && npm install --production; \ + else \ + echo ">>> No package.json found, skipping npm install"; \ + fi +COPY --chmod=755 .docker/99-migrate.sh /opt/docker/provision/entrypoint.d/99-migrate.sh +RUN chown -R application:application /app \ No newline at end of file diff --git a/api.php b/api.php new file mode 100644 index 0000000..9438523 --- /dev/null +++ b/api.php @@ -0,0 +1,189 @@ +prepare($sql); + $stmt->execute($params); + $items = $stmt->fetchAll(); + + $stats = $pdo->query(" + SELECT + COUNT(*) AS total, + SUM(watched) AS watched, + COUNT(*) - SUM(watched) AS unwatched + FROM watchlist + ")->fetch(); + + $services = $pdo->query(" + SELECT streaming_service, COUNT(*) AS cnt + FROM watchlist + WHERE streaming_service IS NOT NULL AND streaming_service != '' + GROUP BY streaming_service + ORDER BY cnt DESC + ")->fetchAll(); + + echo json_encode([ + 'success' => true, + 'items' => $items, + 'stats' => $stats, + 'services' => $services, + ]); + break; + + case 'add': + $title = trim($input['title'] ?? ''); + $type = $input['type'] ?? 'movie'; + $service = trim($input['streaming_service'] ?? ''); + $genre = trim($input['genre'] ?? ''); + $notes = trim($input['notes'] ?? ''); + + if ($title === '') { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Title is required']); + exit; + } + + $stmt = $pdo->prepare(" + INSERT INTO watchlist (title, type, streaming_service, genre, notes) + VALUES (?, ?, ?, ?, ?) + "); + $stmt->execute([$title, $type, $service ?: null, $genre ?: null, $notes ?: null]); + $id = $pdo->lastInsertId(); + + $item = $pdo->prepare("SELECT * FROM watchlist WHERE id = ?"); + $item->execute([$id]); + + echo json_encode(['success' => true, 'item' => $item->fetch()]); + break; + + case 'toggle': + $id = (int)($input['id'] ?? 0); + if (!$id) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'ID required']); + exit; + } + + $current = $pdo->prepare("SELECT watched FROM watchlist WHERE id = ?"); + $current->execute([$id]); + $row = $current->fetch(); + + if (!$row) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Not found']); + exit; + } + + $newWatched = $row['watched'] ? 0 : 1; + $watchedAt = $newWatched ? date('Y-m-d H:i:s') : null; + + $stmt = $pdo->prepare("UPDATE watchlist SET watched = ?, watched_at = ? WHERE id = ?"); + $stmt->execute([$newWatched, $watchedAt, $id]); + + $item = $pdo->prepare("SELECT * FROM watchlist WHERE id = ?"); + $item->execute([$id]); + + echo json_encode(['success' => true, 'item' => $item->fetch()]); + break; + + case 'update': + $id = (int)($input['id'] ?? 0); + $title = trim($input['title'] ?? ''); + $type = $input['type'] ?? 'movie'; + $service = trim($input['streaming_service'] ?? ''); + $genre = trim($input['genre'] ?? ''); + $notes = trim($input['notes'] ?? ''); + + if (!$id || $title === '') { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'ID and title required']); + exit; + } + + $stmt = $pdo->prepare(" + UPDATE watchlist SET title = ?, type = ?, streaming_service = ?, genre = ?, notes = ? + WHERE id = ? + "); + $stmt->execute([$title, $type, $service ?: null, $genre ?: null, $notes ?: null, $id]); + + $item = $pdo->prepare("SELECT * FROM watchlist WHERE id = ?"); + $item->execute([$id]); + + echo json_encode(['success' => true, 'item' => $item->fetch()]); + break; + + case 'delete': + $id = (int)($input['id'] ?? 0); + if (!$id) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'ID required']); + exit; + } + + $stmt = $pdo->prepare("DELETE FROM watchlist WHERE id = ?"); + $stmt->execute([$id]); + + echo json_encode(['success' => true]); + break; + + default: + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Unknown action']); + } + +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); +} diff --git a/app.js b/app.js new file mode 100644 index 0000000..b04463e --- /dev/null +++ b/app.js @@ -0,0 +1,472 @@ +/* ========================================================= + 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 = ` +
+

Nothing here yet

+

Add your first movie or show to get started.

+ + `; + 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 = ` +
+
+
+ + ${isMovie ? 'Movie' : 'Show'} + + ${item.streaming_service ? `${escHtml(item.streaming_service)}` : ''} +
+
+ ${item.genre ? `${escHtml(item.genre)}` : ''} +
+

${escHtml(item.title)}

+ ${item.notes ? `

${escHtml(item.notes)}

` : ''} +

${dateStr}

+
+ + `; + + // 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 = `${icon}${escHtml(msg)}`; + 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, '''); +} + +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(); diff --git a/db.php b/db.php new file mode 100644 index 0000000..68a8498 --- /dev/null +++ b/db.php @@ -0,0 +1,14 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); diff --git a/index.php b/index.php new file mode 100644 index 0000000..4519eec --- /dev/null +++ b/index.php @@ -0,0 +1,169 @@ + + + + + + Watchlist + + + + + + + + + + +
+
+
+
+
+ +
+ + +
+
+ +
+

Watchlist

+

Your personal streaming guide

+
+
+ +
+ + +
+
+ + Total +
+
+
+ + To Watch +
+
+
+ + Watched +
+
+
+
+
+
+ 0% complete +
+
+ + +
+
+ + + +
+
+ + + +
+ +
+ + +
+ + +
+
+
+ Loading your watchlist +
+
+ +
+ + + + + + + + +
+ + + + diff --git a/migrate.php b/migrate.php new file mode 100644 index 0000000..bc86a3b --- /dev/null +++ b/migrate.php @@ -0,0 +1,25 @@ +exec("CREATE TABLE IF NOT EXISTS migrations ( + id INT AUTO_INCREMENT PRIMARY KEY, + migration VARCHAR(255) NOT NULL UNIQUE, + ran_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +)"); + +$ran = $pdo->query("SELECT migration FROM migrations")->fetchAll(PDO::FETCH_COLUMN); + +$files = glob(__DIR__ . '/migrations/*.php'); +sort($files); + +foreach ($files as $file) { + $name = basename($file); + if (!in_array($name, $ran)) { + require $file; + $stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?)"); + $stmt->execute([$name]); + echo "Ran: {$name}\n"; + } +} + +echo "Migrations complete.\n"; diff --git a/migrations/001_create_watchlist_table.php b/migrations/001_create_watchlist_table.php new file mode 100644 index 0000000..bf42b8d --- /dev/null +++ b/migrations/001_create_watchlist_table.php @@ -0,0 +1,13 @@ +exec("CREATE TABLE IF NOT EXISTS watchlist ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + type ENUM('movie', 'show') NOT NULL DEFAULT 'movie', + streaming_service VARCHAR(100) DEFAULT NULL, + genre VARCHAR(100) DEFAULT NULL, + notes TEXT DEFAULT NULL, + watched TINYINT(1) NOT NULL DEFAULT 0, + watched_at TIMESTAMP NULL DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +)"); diff --git a/style.css b/style.css new file mode 100644 index 0000000..42c388f --- /dev/null +++ b/style.css @@ -0,0 +1,938 @@ +/* ========================================================= + WATCHLIST style.css + Design: modern, organic, peaceful, clean, expensive + ========================================================= */ + +/* Google Fonts loaded in HTML (DM Sans + DM Serif Display) */ + +/* Bootstrap 5 CDN (loaded via index.php ) */ + +/* CSS Custom Properties */ +:root { + --sage: #8aad8f; + --sage-light: #b5ccb8; + --sage-dark: #5d8264; + --cream: #f7f4ee; + --cream-deep: #ede8df; + --stone: #c4b9a8; + --stone-dark: #9e9080; + --bark: #5c4f3d; + --bark-light: #7a6a56; + --mist: #e8ede9; + --white: #ffffff; + --text-primary: #2e2a24; + --text-secondary: #6b6257; + --text-muted: #9e9080; + --danger: #c0614a; + --danger-soft: #f5e8e5; + + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 20px; + --radius-xl: 28px; + --radius-pill: 999px; + + --shadow-xs: 0 1px 3px rgba(60,50,35,.06); + --shadow-sm: 0 2px 8px rgba(60,50,35,.08), 0 1px 2px rgba(60,50,35,.04); + --shadow-md: 0 6px 24px rgba(60,50,35,.10), 0 2px 6px rgba(60,50,35,.06); + --shadow-lg: 0 16px 48px rgba(60,50,35,.14), 0 4px 12px rgba(60,50,35,.08); + --shadow-modal: 0 24px 80px rgba(40,32,20,.22), 0 6px 20px rgba(40,32,20,.10); + + --transition: 200ms cubic-bezier(.4,0,.2,1); + --transition-slow: 380ms cubic-bezier(.4,0,.2,1); +} + +/* Reset & Base */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { scroll-behavior: smooth; } + +body { + font-family: 'DM Sans', system-ui, sans-serif; + font-size: 15px; + line-height: 1.6; + color: var(--text-primary); + background: var(--cream); + min-height: 100vh; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; +} + +/* Ambient Background */ +.ambient { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + overflow: hidden; +} + +.ambient-orb { + position: absolute; + border-radius: 50%; + filter: blur(80px); + opacity: .55; + animation: drift 18s ease-in-out infinite; +} + +.orb-1 { + width: 600px; height: 600px; + background: radial-gradient(circle, #c8d9c5 0%, transparent 70%); + top: -200px; left: -150px; + animation-duration: 22s; +} + +.orb-2 { + width: 500px; height: 500px; + background: radial-gradient(circle, #d9cfc0 0%, transparent 70%); + top: 30%; right: -180px; + animation-duration: 28s; + animation-delay: -8s; +} + +.orb-3 { + width: 400px; height: 400px; + background: radial-gradient(circle, #bfcfbf 0%, transparent 70%); + bottom: -100px; left: 30%; + animation-duration: 20s; + animation-delay: -14s; +} + +@keyframes drift { + 0%, 100% { transform: translate(0, 0) scale(1); } + 33% { transform: translate(30px, -40px) scale(1.05); } + 66% { transform: translate(-20px, 20px) scale(.95); } +} + +/* App Shell */ +.app { + position: relative; + z-index: 1; + max-width: 1100px; + margin: 0 auto; + padding: 40px 24px 80px; +} + +/* Header */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 36px; + gap: 16px; + flex-wrap: wrap; +} + +.header-brand { + display: flex; + align-items: center; + gap: 16px; +} + +.header-icon { + font-size: 2.4rem; + line-height: 1; + filter: drop-shadow(0 2px 6px rgba(90,100,80,.25)); +} + +.header-title { + font-family: 'DM Serif Display', Georgia, serif; + font-size: 2rem; + font-weight: 400; + letter-spacing: -.02em; + color: var(--bark); + line-height: 1.1; +} + +.header-sub { + font-size: .8rem; + color: var(--text-muted); + letter-spacing: .04em; + text-transform: uppercase; + font-weight: 500; + margin: 0; +} + +/* Buttons */ +.btn-primary { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 11px 22px; + background: var(--sage-dark); + color: var(--white); + border: none; + border-radius: var(--radius-pill); + font-family: inherit; + font-size: .875rem; + font-weight: 600; + letter-spacing: .01em; + cursor: pointer; + transition: background var(--transition), transform var(--transition), box-shadow var(--transition); + box-shadow: 0 2px 12px rgba(93,130,100,.30); + white-space: nowrap; +} + +.btn-primary:hover { + background: var(--sage); + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(93,130,100,.35); +} + +.btn-primary:active { transform: translateY(0); } + +.btn-ghost { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + background: transparent; + color: var(--text-secondary); + border: 1.5px solid var(--stone); + border-radius: var(--radius-pill); + font-family: inherit; + font-size: .875rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition); +} + +.btn-ghost:hover { + background: var(--cream-deep); + border-color: var(--stone-dark); + color: var(--text-primary); +} + +.btn-danger { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + background: var(--danger); + color: var(--white); + border: none; + border-radius: var(--radius-pill); + font-family: inherit; + font-size: .875rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition); + box-shadow: 0 2px 10px rgba(192,97,74,.25); +} + +.btn-danger:hover { + background: #a8523d; + transform: translateY(-1px); + box-shadow: 0 4px 16px rgba(192,97,74,.35); +} + +/* Stats Bar */ +.stats { + display: flex; + align-items: center; + gap: 0; + background: var(--white); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: 20px 28px; + margin-bottom: 28px; + border: 1px solid rgba(196,185,168,.35); +} + +.stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + flex: 1; +} + +.stat-value { + font-family: 'DM Serif Display', serif; + font-size: 2rem; + font-weight: 400; + color: var(--bark); + line-height: 1; + letter-spacing: -.02em; + min-width: 32px; + text-align: center; +} + +.stat-label { + font-size: .72rem; + font-weight: 600; + letter-spacing: .06em; + text-transform: uppercase; + color: var(--text-muted); +} + +.stat-sep { + width: 1px; + height: 36px; + background: var(--cream-deep); + flex-shrink: 0; + margin: 0 4px; +} + +.stat-progress { + gap: 6px; + min-width: 120px; +} + +.progress-track { + width: 100%; + height: 6px; + background: var(--cream-deep); + border-radius: var(--radius-pill); + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--sage-dark), var(--sage)); + border-radius: var(--radius-pill); + transition: width .6s cubic-bezier(.4,0,.2,1); + width: 0%; +} + +/* Controls */ +.controls { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.pill-group { + display: flex; + align-items: center; + background: var(--white); + border-radius: var(--radius-pill); + padding: 4px; + gap: 2px; + box-shadow: var(--shadow-xs); + border: 1px solid rgba(196,185,168,.30); +} + +.pill, .type-pill { + padding: 7px 16px; + border: none; + border-radius: var(--radius-pill); + background: transparent; + font-family: inherit; + font-size: .8rem; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; +} + +.pill:hover, .type-pill:hover { + background: var(--mist); + color: var(--text-primary); +} + +.pill.active, .type-pill.active { + background: var(--sage-dark); + color: var(--white); + box-shadow: 0 2px 8px rgba(93,130,100,.25); +} + +/* Search */ +.search-box { + position: relative; + flex: 1; + min-width: 180px; + max-width: 320px; +} + +.search-icon { + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + color: var(--stone-dark); + pointer-events: none; +} + +.search-input { + width: 100%; + padding: 10px 14px 10px 40px; + background: var(--white); + border: 1.5px solid rgba(196,185,168,.40); + border-radius: var(--radius-pill); + font-family: inherit; + font-size: .875rem; + color: var(--text-primary); + outline: none; + transition: border-color var(--transition), box-shadow var(--transition); + box-shadow: var(--shadow-xs); +} + +.search-input::placeholder { color: var(--stone); } + +.search-input:focus { + border-color: var(--sage); + box-shadow: 0 0 0 3px rgba(138,173,143,.18); +} + +/* Service Filter Row */ +.service-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 28px; + min-height: 0; + transition: min-height var(--transition-slow); +} + +.service-row:empty { margin-bottom: 0; } + +.service-chip { + padding: 5px 14px; + border: 1.5px solid var(--stone); + border-radius: var(--radius-pill); + background: var(--white); + font-family: inherit; + font-size: .78rem; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition); +} + +.service-chip:hover { + border-color: var(--sage); + color: var(--sage-dark); + background: var(--mist); +} + +.service-chip.active { + background: var(--sage-dark); + border-color: var(--sage-dark); + color: var(--white); +} + +/* Grid */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 18px; +} + +/* Card */ +.card { + background: var(--white); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + border: 1px solid rgba(196,185,168,.25); + overflow: hidden; + transition: transform var(--transition), box-shadow var(--transition); + display: flex; + flex-direction: column; + cursor: default; +} + +.card:hover { + transform: translateY(-3px); + box-shadow: var(--shadow-md); +} + +.card.watched { + opacity: .72; +} + +.card.watched .card-title { + text-decoration: line-through; + text-decoration-color: var(--stone); +} + +/* Card colour accent strip */ +.card-accent { + height: 4px; + background: linear-gradient(90deg, var(--sage-light), var(--sage-dark)); + flex-shrink: 0; +} + +.card.watched .card-accent { + background: linear-gradient(90deg, var(--stone), var(--stone-dark)); +} + +.card-body { + padding: 18px 18px 14px; + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; +} + +.card-meta { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: var(--radius-pill); + font-size: .7rem; + font-weight: 600; + letter-spacing: .04em; + text-transform: uppercase; +} + +.badge-movie { + background: #edf0f7; + color: #5060a0; +} + +.badge-show { + background: #f0ede8; + color: #8a6a30; +} + +.badge-service { + background: var(--mist); + color: var(--sage-dark); + font-weight: 500; + text-transform: none; + letter-spacing: 0; + font-size: .72rem; +} + +.badge-genre { + background: var(--cream-deep); + color: var(--bark-light); + font-weight: 500; + text-transform: none; + letter-spacing: 0; + font-size: .72rem; +} + +.card-title { + font-family: 'DM Serif Display', serif; + font-size: 1.05rem; + font-weight: 400; + color: var(--text-primary); + line-height: 1.3; + letter-spacing: -.01em; +} + +.card-notes { + font-size: .8rem; + color: var(--text-muted); + font-style: italic; + line-height: 1.5; +} + +.card-date { + font-size: .7rem; + color: var(--stone); + margin-top: auto; + padding-top: 6px; +} + +/* Card Footer */ +.card-footer { + padding: 10px 18px 14px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border-top: 1px solid rgba(196,185,168,.15); +} + +.btn-watch { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: var(--radius-pill); + border: 1.5px solid var(--sage-light); + background: transparent; + font-family: inherit; + font-size: .78rem; + font-weight: 600; + color: var(--sage-dark); + cursor: pointer; + transition: all var(--transition); +} + +.btn-watch:hover { + background: var(--sage-dark); + border-color: var(--sage-dark); + color: var(--white); + box-shadow: 0 2px 10px rgba(93,130,100,.25); +} + +.card.watched .btn-watch { + border-color: var(--stone); + color: var(--stone-dark); +} + +.card.watched .btn-watch:hover { + background: var(--stone-dark); + border-color: var(--stone-dark); + color: var(--white); +} + +.card-actions { + display: flex; + gap: 4px; +} + +.btn-icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + border-radius: var(--radius-sm); + color: var(--stone-dark); + cursor: pointer; + transition: all var(--transition); +} + +.btn-icon:hover { + background: var(--cream-deep); + color: var(--text-primary); +} + +.btn-icon.btn-delete:hover { + background: var(--danger-soft); + color: var(--danger); +} + +/* Loading */ +.loading { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 20px; + gap: 16px; + color: var(--text-muted); + font-size: .875rem; +} + +.loader { + width: 36px; + height: 36px; + border: 3px solid var(--cream-deep); + border-top-color: var(--sage-dark); + border-radius: 50%; + animation: spin .8s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +/* Empty State */ +.empty-state { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 20px; + gap: 12px; + text-align: center; +} + +.empty-icon { + font-size: 3.5rem; + filter: grayscale(.3); + margin-bottom: 4px; +} + +.empty-title { + font-family: 'DM Serif Display', serif; + font-size: 1.4rem; + font-weight: 400; + color: var(--bark); + letter-spacing: -.01em; +} + +.empty-sub { + font-size: .875rem; + color: var(--text-muted); + max-width: 300px; + line-height: 1.6; + margin-bottom: 8px; +} + +/* Modal */ + +/* Override Bootstrap's .modal { display: none } — we control visibility via .modal-overlay.hidden */ +.modal-overlay .modal { + display: block !important; +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(40, 32, 20, .45); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + animation: fadeIn 180ms ease; +} + +.modal-overlay.hidden { display: none; } + +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + +.modal { + background: var(--white); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-modal); + width: 100%; + max-width: 480px; + max-height: 90vh; + overflow-y: auto; + animation: slideUp 220ms cubic-bezier(.34,1.3,.64,1); + border: 1px solid rgba(196,185,168,.20); +} + +.modal-sm { max-width: 380px; } + +@keyframes slideUp { + from { opacity: 0; transform: translateY(20px) scale(.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 24px 28px 0; +} + +.modal-title { + font-family: 'DM Serif Display', serif; + font-size: 1.35rem; + font-weight: 400; + color: var(--bark); + letter-spacing: -.01em; +} + +.modal-close { + width: 34px; + height: 34px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: var(--cream-deep); + border-radius: 50%; + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition); + flex-shrink: 0; +} + +.modal-close:hover { + background: var(--stone); + color: var(--white); +} + +/* Modal Form */ +.modal-form { + padding: 20px 28px 28px; + display: flex; + flex-direction: column; + gap: 18px; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.form-label { + font-size: .78rem; + font-weight: 600; + letter-spacing: .04em; + text-transform: uppercase; + color: var(--text-secondary); +} + +.req { color: var(--sage-dark); } + +.form-input { + width: 100%; + padding: 11px 14px; + background: var(--cream); + border: 1.5px solid rgba(196,185,168,.50); + border-radius: var(--radius-md); + font-family: inherit; + font-size: .9rem; + color: var(--text-primary); + outline: none; + transition: border-color var(--transition), box-shadow var(--transition), background var(--transition); +} + +.form-input::placeholder { color: var(--stone); } + +.form-input:focus { + border-color: var(--sage); + background: var(--white); + box-shadow: 0 0 0 3px rgba(138,173,143,.15); +} + +/* Type toggle inside form */ +.type-toggle { + display: flex; + gap: 10px; +} + +.type-option { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px; + border: 1.5px solid rgba(196,185,168,.50); + border-radius: var(--radius-md); + background: var(--cream); + font-family: inherit; + font-size: .85rem; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition); +} + +.type-option:hover { + border-color: var(--sage-light); + color: var(--text-primary); +} + +.type-option.active { + border-color: var(--sage-dark); + background: var(--mist); + color: var(--sage-dark); + font-weight: 600; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding-top: 4px; +} + +.delete-msg { + padding: 6px 28px 20px; + font-size: .9rem; + color: var(--text-secondary); + line-height: 1.6; +} + +.delete-msg strong { color: var(--text-primary); } + +/* Toast */ +.toast-container { + position: fixed; + bottom: 28px; + right: 28px; + z-index: 2000; + display: flex; + flex-direction: column; + gap: 10px; + pointer-events: none; +} + +.toast { + display: flex; + align-items: center; + gap: 10px; + padding: 13px 18px; + background: var(--bark); + color: var(--cream); + border-radius: var(--radius-md); + font-size: .85rem; + font-weight: 500; + box-shadow: var(--shadow-lg); + pointer-events: auto; + animation: toastIn 300ms cubic-bezier(.34,1.3,.64,1); + max-width: 320px; +} + +.toast.toast-success { background: var(--sage-dark); } +.toast.toast-error { background: var(--danger); } + +.toast-icon { font-size: 1rem; flex-shrink: 0; } + +@keyframes toastIn { + from { opacity: 0; transform: translateX(30px) scale(.95); } + to { opacity: 1; transform: translateX(0) scale(1); } +} + +@keyframes toastOut { + from { opacity: 1; transform: translateX(0); } + to { opacity: 0; transform: translateX(30px); } +} + +/* Scrollbar */ +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--stone); border-radius: 99px; } +::-webkit-scrollbar-thumb:hover { background: var(--stone-dark); } + +/* Responsive */ +@media (max-width: 640px) { + .app { padding: 24px 16px 60px; } + + .header-title { font-size: 1.6rem; } + + .stats { + flex-wrap: wrap; + padding: 16px 20px; + gap: 16px; + } + + .stat-sep { display: none; } + + .stat { flex: 1 1 40%; } + + .controls { gap: 8px; } + + .search-box { max-width: 100%; min-width: 100%; order: 3; } + + .form-row { grid-template-columns: 1fr; } + + .modal { border-radius: var(--radius-lg); } + + .modal-header { padding: 20px 20px 0; } + .modal-form { padding: 16px 20px 20px; } + + .grid { grid-template-columns: 1fr; } + + .toast-container { bottom: 16px; right: 16px; left: 16px; } + .toast { max-width: 100%; } +} + +@media (max-width: 480px) { + .type-toggle { flex-direction: column; } +}