From 490957e70cf28c0bc8f2687982ed27f722be5b62 Mon Sep 17 00:00:00 2001 From: Lumerel Deploy Date: Fri, 27 Mar 2026 02:22:16 +0000 Subject: [PATCH] Deploy from Lumerel --- .docker/99-migrate.sh | 54 + .dockerignore | 3 + Dockerfile | 23 + api.php | 205 ++++ app.js | 552 +++++++++ db.php | 14 + index.php | 192 +++ migrate.php | 25 + migrations/001_create_watchlist_table.php | 13 + migrations/002_add_for_whom_to_watchlist.php | 5 + style.css | 1135 ++++++++++++++++++ 11 files changed, 2221 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..dcbc62e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM webdevops/php-nginx:8.3-alpine +ENV WEB_DOCUMENT_ROOT=/app +ARG CACHE_BUST=1774578136 +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..8a6f206 --- /dev/null +++ b/app.js @@ -0,0 +1,552 @@ +/* ========================================================= + 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(); +}); + +/* Theme Toggle (dark/light mode with cookie persistence) */ +(function initTheme() { + const toggle = document.getElementById('themeToggle'); + const html = document.documentElement; + + function getCookie(name) { + const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); + return match ? match[2] : null; + } + + function setCookie(name, value, days) { + const d = new Date(); + d.setTime(d.getTime() + days * 86400000); + document.cookie = name + '=' + value + ';expires=' + d.toUTCString() + ';path=/;SameSite=Lax'; + } + + const saved = getCookie('theme'); + const theme = saved === 'dark' ? 'dark' : 'light'; + html.setAttribute('data-theme', theme); + + if (toggle) { + toggle.addEventListener('click', () => { + const current = html.getAttribute('data-theme'); + const next = current === 'dark' ? 'light' : 'dark'; + html.setAttribute('data-theme', next); + setCookie('theme', next, 365); + }); + } +})(); + +/* Init */ +readUrlParams(); +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..1355152 --- /dev/null +++ b/index.php @@ -0,0 +1,192 @@ + + + + + + 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/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..6d2c788 --- /dev/null +++ b/style.css @@ -0,0 +1,1135 @@ +/* ========================================================= + 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); } + +/* 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; +} + +/* ========================================================= + DARK MODE + ========================================================= */ + +/* Header actions wrapper */ +.header-actions { + display: flex; + align-items: center; + gap: 10px; +} + +/* Theme Toggle Button */ +.theme-toggle { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border: 1.5px solid rgba(196,185,168,.40); + border-radius: 50%; + background: var(--white); + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition); + box-shadow: var(--shadow-xs); + flex-shrink: 0; +} + +.theme-toggle:hover { + background: var(--cream-deep); + border-color: var(--stone-dark); + color: var(--text-primary); +} + +.theme-icon-dark { display: none; } +.theme-icon-light { display: block; } + +[data-theme="dark"] .theme-icon-dark { display: block; } +[data-theme="dark"] .theme-icon-light { display: none; } + +/* Dark theme overrides */ +[data-theme="dark"] { + --sage: #7a9d7f; + --sage-light: #5d8264; + --sage-dark: #6b9470; + --cream: #1a1a1e; + --cream-deep: #222226; + --stone: #4a4540; + --stone-dark: #6b6257; + --bark: #d4cdc0; + --bark-light: #a89c8c; + --mist: #2a2d2a; + --white: #242428; + --text-primary: #e8e4de; + --text-secondary: #a89c8c; + --text-muted: #6b6257; + --danger: #c0614a; + --danger-soft: #3a2420; + + --shadow-xs: 0 1px 3px rgba(0,0,0,.20); + --shadow-sm: 0 2px 8px rgba(0,0,0,.25), 0 1px 2px rgba(0,0,0,.15); + --shadow-md: 0 6px 24px rgba(0,0,0,.30), 0 2px 6px rgba(0,0,0,.20); + --shadow-lg: 0 16px 48px rgba(0,0,0,.40), 0 4px 12px rgba(0,0,0,.25); + --shadow-modal: 0 24px 80px rgba(0,0,0,.50), 0 6px 20px rgba(0,0,0,.30); +} + +[data-theme="dark"] body { + color-scheme: dark; +} + +[data-theme="dark"] .ambient-orb { + opacity: .15; +} + +[data-theme="dark"] .orb-1 { + background: radial-gradient(circle, #3a5a3a 0%, transparent 70%); +} + +[data-theme="dark"] .orb-2 { + background: radial-gradient(circle, #4a3a2a 0%, transparent 70%); +} + +[data-theme="dark"] .orb-3 { + background: radial-gradient(circle, #2a3a2a 0%, transparent 70%); +} + +[data-theme="dark"] .stats { + border-color: rgba(80,75,65,.40); +} + +[data-theme="dark"] .pill-group { + border-color: rgba(80,75,65,.40); +} + +[data-theme="dark"] .search-input { + border-color: rgba(80,75,65,.50); +} + +[data-theme="dark"] .search-input:focus { + border-color: var(--sage); + box-shadow: 0 0 0 3px rgba(106,148,112,.20); +} + +[data-theme="dark"] .card { + border-color: rgba(80,75,65,.30); +} + +[data-theme="dark"] .card-footer { + border-top-color: rgba(80,75,65,.25); +} + +[data-theme="dark"] .service-chip { + border-color: var(--stone); +} + +[data-theme="dark"] .modal { + border-color: rgba(80,75,65,.30); +} + +[data-theme="dark"] .modal-overlay { + background: rgba(0, 0, 0, .55); +} + +[data-theme="dark"] .modal-close { + background: var(--cream-deep); +} + +[data-theme="dark"] .form-input { + border-color: rgba(80,75,65,.50); +} + +[data-theme="dark"] .form-input:focus { + border-color: var(--sage); + background: #2a2a2e; + box-shadow: 0 0 0 3px rgba(106,148,112,.15); +} + +[data-theme="dark"] .badge-movie { + background: #2a2d3a; + color: #8090c0; +} + +[data-theme="dark"] .badge-show { + background: #352e22; + color: #c0a060; +} + +[data-theme="dark"] .theme-toggle { + border-color: rgba(80,75,65,.50); +} + +[data-theme="dark"] .theme-toggle:hover { + background: var(--cream-deep); + border-color: var(--sage); + color: var(--sage); +} + +[data-theme="dark"] .btn-ghost { + border-color: var(--stone); +} + +[data-theme="dark"] .btn-ghost:hover { + background: var(--cream-deep); + border-color: var(--stone-dark); +} + +[data-theme="dark"] ::-webkit-scrollbar-thumb { + background: #4a4540; +} + +[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { + background: #5a554e; +} \ No newline at end of file