From 275b1865866fe6ef72bc17129438f597715f398c Mon Sep 17 00:00:00 2001 From: Lumerel Deploy Date: Fri, 27 Mar 2026 02:09:43 +0000 Subject: [PATCH] Deploy from Lumerel --- .docker/99-migrate.sh | 54 + .dockerignore | 3 + Dockerfile | 23 + api.php | 205 +++ app.js | 557 ++++++++ db.php | 14 + index.php | 214 +++ migrate.php | 25 + migrations/001_create_watchlist_table.php | 13 + migrations/002_add_for_whom_to_watchlist.php | 5 + style.css | 1322 ++++++++++++++++++ 11 files changed, 2435 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 migrations/002_add_for_whom_to_watchlist.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..b665da4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM webdevops/php-nginx:8.3-alpine +ENV WEB_DOCUMENT_ROOT=/app +ARG CACHE_BUST=1774577383 +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..353d36c --- /dev/null +++ b/api.php @@ -0,0 +1,205 @@ +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'; + $forWhom = $input['for_whom'] ?? 'all'; + $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; + } + + $validForWhom = ['all', 'nik', 'tod']; + if (!in_array($forWhom, $validForWhom)) { + $forWhom = 'all'; + } + + $stmt = $pdo->prepare(" + INSERT INTO watchlist (title, type, for_whom, streaming_service, genre, notes) + VALUES (?, ?, ?, ?, ?, ?) + "); + $stmt->execute([$title, $type, $forWhom, $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' => 'Item 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]); + + echo json_encode(['success' => true, 'watched' => $newWatched]); + break; + + case 'edit': + $id = (int)($input['id'] ?? 0); + $title = trim($input['title'] ?? ''); + $type = $input['type'] ?? 'movie'; + $forWhom = $input['for_whom'] ?? 'all'; + $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 are required']); + exit; + } + + $validForWhom = ['all', 'nik', 'tod']; + if (!in_array($forWhom, $validForWhom)) { + $forWhom = 'all'; + } + + $stmt = $pdo->prepare(" + UPDATE watchlist + SET title = ?, type = ?, for_whom = ?, streaming_service = ?, genre = ?, notes = ? + WHERE id = ? + "); + $stmt->execute([$title, $type, $forWhom, $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..3572858 --- /dev/null +++ b/app.js @@ -0,0 +1,557 @@ +/* ========================================================= + WATCHLIST app.js + ========================================================= */ + +/* State */ +const state = { + filter: 'unwatched', + type: '', + forWhom: '', + 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'); +const forGroup = document.getElementById('forGroup'); + +// Toast +const toastContainer = document.getElementById('toastContainer'); + +/* Type & For toggle state (modal) */ +let selectedType = 'movie'; +let selectedForWhom = 'all'; + +/* 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); + pushUrlState(); + + try { + const data = await api({ + action: 'list', + filter: state.filter, + type: state.type, + for_whom: state.forWhom, + 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; + + const all = chip('All Services', '', state.service === ''); + all.addEventListener('click', () => { state.service = ''; loadItems(); }); + serviceRow.appendChild(all); + + state.services.forEach(s => { + const c = chip(s.streaming_service, s.streaming_service, state.service === s.streaming_service); + c.addEventListener('click', () => { + state.service = state.service === s.streaming_service ? '' : s.streaming_service; + loadItems(); + }); + serviceRow.appendChild(c); + }); +} + +function chip(label, value, active) { + const btn = document.createElement('button'); + btn.className = 'service-chip' + (active ? ' active' : ''); + btn.textContent = label; + btn.dataset.value = value; + return btn; +} + +function populateServiceDatalist() { + serviceList.innerHTML = ''; + state.services.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.streaming_service; + serviceList.appendChild(opt); + }); +} + +/* Render Grid */ +function renderGrid() { + // Remove old cards (keep loading state) + Array.from(grid.children).forEach(child => { + if (child.id !== 'loadingState') child.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.innerHTML = ` +
🎬
+

Nothing here yet

+

Add your first title 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'; + + // For badge label + const forLabels = { all: 'All', nik: 'Nik', tod: 'Tod' }; + const forLabel = forLabels[item.for_whom] || 'All'; + const showForBadge = item.for_whom && item.for_whom !== 'all'; + + // Format date + const dateStr = item.watched_at + ? 'Watched ' + formatDate(item.watched_at) + : 'Added ' + formatDate(item.created_at); + + const card = document.createElement('div'); + card.className = 'card' + (watched ? ' watched' : ''); + card.dataset.id = item.id; + + card.innerHTML = ` +
+
+
+ ${isMovie ? 'Movie' : 'Show'} + ${showForBadge ? `${escHtml(forLabel)}` : ''} + ${item.streaming_service ? `${escHtml(item.streaming_service)}` : ''} +
+

${escHtml(item.title)}

+ ${item.genre ? `

${escHtml(item.genre)}

` : ''} + ${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.watched ? 'Marked as watched!' : 'Moved back to watchlist'); + loadItems(); + } catch (err) { + toast('Failed to update', 'error'); + } +} + +/* Open Add Modal */ +function openAdd() { + editId.value = ''; + addForm.reset(); + selectedType = 'movie'; + selectedForWhom = 'all'; + updateTypeToggle(); + updateForToggle(); + modalTitle.textContent = 'Add Title'; + submitBtn.textContent = 'Add to Watchlist'; + showModal(true); +} + +/* Open Edit Modal */ +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'; + selectedForWhom = item.for_whom || 'all'; + updateTypeToggle(); + updateForToggle(); + modalTitle.textContent = 'Edit Title'; + submitBtn.textContent = 'Save Changes'; + showModal(true); +} + +function showModal(on) { + modalOverlay.classList.toggle('hidden', !on); + if (on) setTimeout(() => inputTitle.focus(), 50); +} + +/* Type toggle (modal) */ +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(); + }); +}); + +/* For toggle (modal) */ +function updateForToggle() { + document.querySelectorAll('.for-option').forEach(btn => { + btn.classList.toggle('active', btn.dataset.forVal === selectedForWhom); + }); +} + +document.querySelectorAll('.for-option').forEach(btn => { + btn.addEventListener('click', () => { + selectedForWhom = btn.dataset.forVal; + updateForToggle(); + }); +}); + +/* Form submit */ +addForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + const title = inputTitle.value.trim(); + if (!title) { toast('Title is required', 'error'); return; } + + submitBtn.disabled = true; + submitBtn.textContent = editId.value ? 'Saving...' : 'Adding...'; + + const payload = { + action: editId.value ? 'edit' : 'add', + id: editId.value ? parseInt(editId.value) : undefined, + title, + type: selectedType, + for_whom: selectedForWhom, + streaming_service: inputService.value.trim(), + genre: inputGenre.value.trim(), + notes: inputNotes.value.trim(), + }; + + try { + const data = await api({}, payload); + if (!data.success) throw new Error(data.error); + showModal(false); + toast(editId.value ? 'Title updated!' : 'Added to watchlist!'); + loadItems(); + } catch (err) { + toast(err.message || 'Something went wrong', 'error'); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = editId.value ? 'Save Changes' : 'Add to Watchlist'; + } +}); + +/* Delete confirm */ +let pendingDeleteId = null; + +function openDeleteConfirm(id, title) { + pendingDeleteId = id; + deleteTitle.textContent = title; + deleteOverlay.classList.remove('hidden'); +} + +function closeDelete() { + pendingDeleteId = null; + deleteOverlay.classList.add('hidden'); +} + +confirmDelete.addEventListener('click', async () => { + if (!pendingDeleteId) return; + try { + const data = await api({}, { action: 'delete', id: pendingDeleteId }); + if (!data.success) throw new Error(data.error); + closeDelete(); + toast('Title removed'); + loadItems(); + } catch (err) { + toast('Failed to delete', 'error'); + } +}); + +closeDeleteModal.addEventListener('click', closeDelete); +cancelDelete.addEventListener('click', closeDelete); + +/* Modal close */ +closeModal.addEventListener('click', () => showModal(false)); +cancelModal.addEventListener('click', () => showModal(false)); +modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) showModal(false); }); + +/* Header Add button */ +openAddModal.addEventListener('click', openAdd); + +/* 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(); +}); + +/* Type filter pills */ +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(); +}); + +/* For filter pills */ +forGroup.addEventListener('click', (e) => { + const btn = e.target.closest('.for-pill'); + if (!btn) return; + forGroup.querySelectorAll('.for-pill').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + state.forWhom = btn.dataset.for; + loadItems(); +}); + +/* Search */ +let searchTimer; +searchInput.addEventListener('input', () => { + clearTimeout(searchTimer); + searchTimer = setTimeout(() => { + state.search = searchInput.value.trim(); + loadItems(); + }, 300); +}); + +/* Toast */ +function toast(msg, type = 'default') { + const el = document.createElement('div'); + el.className = 'toast toast-' + type; + el.textContent = msg; + toastContainer.appendChild(el); + requestAnimationFrame(() => el.classList.add('show')); + setTimeout(() => { + el.classList.remove('show'); + setTimeout(() => el.remove(), 300); + }, 3000); +} + +/* Helpers */ +function escHtml(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function escAttr(str) { + return String(str ?? '').replace(/"/g, '"'); +} + +function formatDate(dateStr) { + if (!dateStr) return ''; + const d = new Date(dateStr); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +/* URL State Management */ +function readUrlParams() { + const params = new URLSearchParams(window.location.search); + + if (params.has('filter')) state.filter = params.get('filter'); + if (params.has('type')) state.type = params.get('type'); + if (params.has('for')) state.forWhom = params.get('for'); + if (params.has('service')) state.service = params.get('service'); + if (params.has('search')) state.search = params.get('search'); + + // Sync pill UI to state + filterGroup.querySelectorAll('.pill').forEach(b => { + b.classList.toggle('active', b.dataset.filter === state.filter); + }); + typeGroup.querySelectorAll('.type-pill').forEach(b => { + b.classList.toggle('active', b.dataset.type === state.type); + }); + forGroup.querySelectorAll('.for-pill').forEach(b => { + b.classList.toggle('active', b.dataset.for === state.forWhom); + }); + if (state.search) searchInput.value = state.search; +} + +function pushUrlState() { + const params = new URLSearchParams(); + + if (state.filter && state.filter !== 'unwatched') params.set('filter', state.filter); + if (state.type) params.set('type', state.type); + if (state.forWhom) params.set('for', state.forWhom); + if (state.service) params.set('service', state.service); + if (state.search) params.set('search', state.search); + + const qs = params.toString(); + const url = window.location.pathname + (qs ? '?' + qs : ''); + history.replaceState(null, '', url); +} + +/* Handle browser back/forward */ +window.addEventListener('popstate', () => { + readUrlParams(); + loadItems(); +}); + +/* Init */ +readUrlParams(); +loadItems(); + +/* ========================================================= + THEME TOGGLE + ========================================================= */ + +function getCookie(name) { + const match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/([.*+?^=!:${}()|[\]/\\])/g, '\\$1') + '=([^;]*)')); + return match ? decodeURIComponent(match[1]) : null; +} + +function setCookie(name, value, days) { + const expires = new Date(Date.now() + days * 864e5).toUTCString(); + document.cookie = name + '=' + encodeURIComponent(value) + '; expires=' + expires + '; path=/; SameSite=Lax'; +} + +function applyTheme(dark) { + if (dark) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } +} + +const themeToggle = document.getElementById('themeToggle'); +if (themeToggle) { + themeToggle.addEventListener('click', () => { + const isDark = document.documentElement.classList.contains('dark'); + const newTheme = isDark ? 'light' : 'dark'; + applyTheme(newTheme === 'dark'); + setCookie('theme', newTheme, 365); + }); +} + +// Apply saved theme on load (default: light) +applyTheme(getCookie('theme') === 'dark'); 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..49b5d66 --- /dev/null +++ b/index.php @@ -0,0 +1,214 @@ + + + + + + Watchlist + + + + + + + + + + + +
+
+
+
+
+ +
+ + +
+
+ +
+

Watchlist

+

Your personal streaming guide

+
+
+
+ + + +
+
+ + +
+
+ + Total +
+
+
+ + To Watch +
+
+
+ + Watched +
+
+
+
+
+
+ 0% complete +
+
+ + +
+
+ + + +
+
+ + + +
+
+ + + +
+ +
+ + +
+ + + + + +
+ +
+ + + + + + + + +
+ + + + 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/migrations/002_add_for_whom_to_watchlist.php b/migrations/002_add_for_whom_to_watchlist.php new file mode 100644 index 0000000..b1209f4 --- /dev/null +++ b/migrations/002_add_for_whom_to_watchlist.php @@ -0,0 +1,5 @@ +query("SHOW COLUMNS FROM watchlist LIKE 'for_whom'")->fetchAll(); +if (empty($cols)) { + $pdo->exec("ALTER TABLE watchlist ADD COLUMN for_whom ENUM('all', 'nik', 'tod') NOT NULL DEFAULT 'all' AFTER type"); +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..4af9757 --- /dev/null +++ b/style.css @@ -0,0 +1,1322 @@ +/* ========================================================= + 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, .for-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, .for-pill:hover { + background: var(--mist); + color: var(--text-primary); +} + +.pill.active, .type-pill.active, .for-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.1rem; + font-weight: 400; + color: var(--text-primary); + line-height: 1.3; + letter-spacing: -.01em; +} + +.card-genre { + font-size: .78rem; + color: var(--text-secondary); + font-weight: 500; + line-height: 1.4; +} + +.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: 4px; +} + +/* 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 defaults — we control visibility via .modal-overlay.hidden */ +.modal-overlay .modal { + display: block !important; + position: relative !important; + inset: auto !important; + width: 100% !important; + height: auto !important; + overflow: visible !important; + z-index: auto !important; + padding: 0 !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: flex-start; + justify-content: center; + padding: 20px; + overflow-y: auto; + 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; + margin: auto 0; + 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 & For option toggles in modal form */ +.type-option, +.for-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, +.for-option:hover { + border-color: var(--sage-light); + color: var(--text-primary); +} + +.type-option.active, +.for-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); } + +/* ========================================================= + THEME TOGGLE BUTTON + ========================================================= */ + +.header-right { + display: flex; + align-items: center; + gap: 10px; +} + +.theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border-radius: var(--radius-pill); + border: 1.5px solid var(--stone); + background: var(--white); + color: var(--bark); + cursor: pointer; + transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition), box-shadow var(--transition); + box-shadow: var(--shadow-xs); + flex-shrink: 0; +} + +.theme-toggle:hover { + background: var(--cream-deep); + border-color: var(--stone-dark); + transform: scale(1.08); + box-shadow: var(--shadow-sm); +} + +/* In light mode: show moon, hide sun */ +.theme-toggle .icon-sun { display: none; } +.theme-toggle .icon-moon { display: block; } + +/* In dark mode: show sun, hide moon */ +html.dark .theme-toggle .icon-sun { display: block; } +html.dark .theme-toggle .icon-moon { display: none; } + +/* ========================================================= + DARK MODE — html.dark overrides + ========================================================= */ + +html.dark { + --cream: #141414; + --cream-deep: #1e1e1e; + --mist: #1a2420; + --white: #1e1e1e; + --stone: #3a3530; + --stone-dark: #5a5248; + --bark: #c8bfb0; + --bark-light: #a89880; + --text-primary: #e8e2d8; + --text-secondary: #a89880; + --text-muted: #6e6358; + --danger-soft: #2e1a17; + + /* Adjust shadows for dark bg */ + --shadow-xs: 0 1px 3px rgba(0,0,0,.25); + --shadow-sm: 0 2px 8px rgba(0,0,0,.30), 0 1px 2px rgba(0,0,0,.20); + --shadow-md: 0 6px 24px rgba(0,0,0,.35), 0 2px 6px rgba(0,0,0,.20); + --shadow-lg: 0 16px 48px rgba(0,0,0,.45), 0 4px 12px rgba(0,0,0,.25); + --shadow-modal: 0 24px 80px rgba(0,0,0,.60), 0 6px 20px rgba(0,0,0,.35); +} + +/* Dark body background */ +html.dark body { + background: #141414; + color: var(--text-primary); +} + +/* Dark ambient orbs — cooler, muted tones */ +html.dark .orb-1 { + background: radial-gradient(circle, #1e3028 0%, transparent 70%); + opacity: .5; +} +html.dark .orb-2 { + background: radial-gradient(circle, #2a2418 0%, transparent 70%); + opacity: .45; +} +html.dark .orb-3 { + background: radial-gradient(circle, #182820 0%, transparent 70%); + opacity: .4; +} + +/* Dark stats bar */ +html.dark .stats { + background: #1e1e1e; + border-color: #2e2a24; +} + +/* Dark progress bar track */ +html.dark .progress-track { + background: #2e2a24; +} + +/* Dark filter pills container */ +html.dark .filter-bar { + background: #1e1e1e; + border-color: #2e2a24; +} + +/* Dark pills */ +html.dark .pill, +html.dark .type-pill, +html.dark .for-pill { + background: #252220; + border-color: #3a3530; + color: var(--text-secondary); +} + +html.dark .pill:hover, +html.dark .type-pill:hover, +html.dark .for-pill:hover { + background: #2e2a24; + border-color: #4a4540; + color: var(--text-primary); +} + +/* Dark search input */ +html.dark .search-wrap input { + background: #1e1e1e; + border-color: #3a3530; + color: var(--text-primary); +} + +html.dark .search-wrap input::placeholder { + color: var(--text-muted); +} + +html.dark .search-wrap input:focus { + border-color: var(--sage); + background: #222; +} + +/* Dark service chips */ +html.dark .service-chip { + background: #1e1e1e; + border-color: #3a3530; + color: var(--text-secondary); +} + +html.dark .service-chip:hover { + border-color: var(--sage); + color: var(--sage-light); + background: #1a2420; +} + +html.dark .service-chip.active { + background: var(--sage-dark); + border-color: var(--sage-dark); + color: var(--white); +} + +/* Dark cards */ +html.dark .card { + background: #1e1e1e; + border-color: #2e2a24; +} + +html.dark .card:hover { + border-color: #3a3530; + background: #222; +} + +html.dark .card.watched { + background: #191917; + border-color: #2a2822; +} + +html.dark .card-body { + background: transparent; +} + +html.dark .card-title { + color: var(--text-primary); +} + +html.dark .card-meta { + color: var(--text-muted); +} + +html.dark .card-notes { + color: var(--text-secondary); + background: #252220; + border-color: #3a3530; +} + +/* Dark badges */ +html.dark .badge-service { + background: #252220; + color: var(--sage-light); +} + +html.dark .badge-type { + background: #252220; + color: var(--text-secondary); +} + +html.dark .badge-for { + background: #252220; + color: var(--text-secondary); +} + +/* Dark card action buttons */ +html.dark .btn-icon { + color: var(--text-muted); +} + +html.dark .btn-icon:hover { + background: #2e2a24; + color: var(--text-primary); +} + +html.dark .btn-watch { + border-color: #3a3530; + color: var(--sage-light); +} + +html.dark .btn-watch:hover { + background: var(--sage-dark); + border-color: var(--sage-dark); + color: #fff; +} + +html.dark .card.watched .btn-watch { + border-color: #3a3530; + color: var(--text-muted); +} + +/* Dark theme toggle button */ +html.dark .theme-toggle { + background: #252220; + border-color: #3a3530; + color: var(--bark); +} + +html.dark .theme-toggle:hover { + background: #2e2a24; + border-color: #4a4540; +} + +/* Dark ghost button */ +html.dark .btn-ghost { + border-color: #3a3530; + color: var(--text-secondary); +} + +html.dark .btn-ghost:hover { + background: #252220; + border-color: #4a4540; + color: var(--text-primary); +} + +/* Dark modal overlay */ +html.dark .modal-overlay { + background: rgba(0, 0, 0, 0.75); +} + +/* Dark modal */ +html.dark .modal-content, +html.dark .modal-inner, +html.dark .modal > .modal-dialog > .modal-content { + background: #1e1e1e; + border-color: #2e2a24; +} + +html.dark .modal-header { + border-bottom-color: #2e2a24; +} + +html.dark .modal-footer { + border-top-color: #2e2a24; +} + +html.dark .modal-title { + color: var(--text-primary); +} + +/* Dark form elements */ +html.dark .form-label { + color: var(--text-secondary); +} + +html.dark .form-input, +html.dark .form-select, +html.dark textarea.form-input { + background: #252220; + border-color: #3a3530; + color: var(--text-primary); +} + +html.dark .form-input:focus, +html.dark .form-select:focus, +html.dark textarea.form-input:focus { + border-color: var(--sage); + background: #2a2724; + box-shadow: 0 0 0 3px rgba(138,173,143,.15); +} + +html.dark .form-input::placeholder, +html.dark textarea.form-input::placeholder { + color: var(--text-muted); +} + +/* Dark type/for option buttons in modal */ +html.dark .type-option, +html.dark .for-option { + background: #252220; + border-color: #3a3530; + color: var(--text-secondary); +} + +html.dark .type-option:hover, +html.dark .for-option:hover { + background: #2e2a24; + color: var(--text-primary); +} + +html.dark .type-option.active, +html.dark .for-option.active { + border-color: var(--sage); + background: #1a2420; + color: var(--sage-light); +} + +/* Dark delete modal */ +html.dark .delete-modal { + background: #1e1e1e; + border-color: #2e2a24; +} + +html.dark .delete-title { + color: var(--text-primary); +} + +html.dark .delete-message { + color: var(--text-secondary); +} + +/* Dark empty state */ +html.dark .empty-state { + color: var(--text-muted); +} + +/* Dark loading spinner */ +html.dark .spinner { + border-color: #2e2a24; + border-top-color: var(--sage); +} + +/* Dark scrollbar */ +html.dark ::-webkit-scrollbar-thumb { + background: #3a3530; +} +html.dark ::-webkit-scrollbar-thumb:hover { + background: #4a4540; +} + +/* 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; } +} + +p.card-genre, p.card-notes { + margin-bottom: 0 !important; +} \ No newline at end of file