commit 08ee7398f21eab2ffb185fab3806a12385da716e Author: Lumerel Deploy Date: Tue Mar 24 21:22:46 2026 +0000 Deploy from Lumerel 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..a9c7498 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM webdevops/php-nginx:8.3-alpine +ENV WEB_DOCUMENT_ROOT=/app +ARG CACHE_BUST=1774387366 +COPY . /app +RUN echo "index index.php index.html index.htm;" > /opt/docker/etc/nginx/vhost.common.d/01-index.conf \ + && echo "add_header Cache-Control 'no-cache, no-store, must-revalidate';" > /opt/docker/etc/nginx/vhost.common.d/02-no-cache.conf +RUN set -e; if [ -f /app/composer.json ]; then \ + echo ">>> composer.json found, installing dependencies..."; \ + curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer; \ + cd /app && composer install --no-dev --no-interaction --optimize-autoloader; \ + ls -la /app/vendor/autoload.php; \ + else \ + echo ">>> No composer.json found, skipping composer install"; \ + fi +RUN set -e; if [ -f /app/package.json ]; then \ + echo ">>> package.json found, installing node dependencies..."; \ + apk add --no-cache nodejs npm; \ + cd /app && npm install --production; \ + else \ + echo ">>> No package.json found, skipping npm install"; \ + fi +COPY --chmod=755 .docker/99-migrate.sh /opt/docker/provision/entrypoint.d/99-migrate.sh +RUN chown -R application:application /app \ No newline at end of file diff --git a/api.php b/api.php new file mode 100644 index 0000000..9438523 --- /dev/null +++ b/api.php @@ -0,0 +1,189 @@ +prepare($sql); + $stmt->execute($params); + $items = $stmt->fetchAll(); + + $stats = $pdo->query(" + SELECT + COUNT(*) AS total, + SUM(watched) AS watched, + COUNT(*) - SUM(watched) AS unwatched + FROM watchlist + ")->fetch(); + + $services = $pdo->query(" + SELECT streaming_service, COUNT(*) AS cnt + FROM watchlist + WHERE streaming_service IS NOT NULL AND streaming_service != '' + GROUP BY streaming_service + ORDER BY cnt DESC + ")->fetchAll(); + + echo json_encode([ + 'success' => true, + 'items' => $items, + 'stats' => $stats, + 'services' => $services, + ]); + break; + + case 'add': + $title = trim($input['title'] ?? ''); + $type = $input['type'] ?? 'movie'; + $service = trim($input['streaming_service'] ?? ''); + $genre = trim($input['genre'] ?? ''); + $notes = trim($input['notes'] ?? ''); + + if ($title === '') { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Title is required']); + exit; + } + + $stmt = $pdo->prepare(" + INSERT INTO watchlist (title, type, streaming_service, genre, notes) + VALUES (?, ?, ?, ?, ?) + "); + $stmt->execute([$title, $type, $service ?: null, $genre ?: null, $notes ?: null]); + $id = $pdo->lastInsertId(); + + $item = $pdo->prepare("SELECT * FROM watchlist WHERE id = ?"); + $item->execute([$id]); + + echo json_encode(['success' => true, 'item' => $item->fetch()]); + break; + + case 'toggle': + $id = (int)($input['id'] ?? 0); + if (!$id) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'ID required']); + exit; + } + + $current = $pdo->prepare("SELECT watched FROM watchlist WHERE id = ?"); + $current->execute([$id]); + $row = $current->fetch(); + + if (!$row) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Not found']); + exit; + } + + $newWatched = $row['watched'] ? 0 : 1; + $watchedAt = $newWatched ? date('Y-m-d H:i:s') : null; + + $stmt = $pdo->prepare("UPDATE watchlist SET watched = ?, watched_at = ? WHERE id = ?"); + $stmt->execute([$newWatched, $watchedAt, $id]); + + $item = $pdo->prepare("SELECT * FROM watchlist WHERE id = ?"); + $item->execute([$id]); + + echo json_encode(['success' => true, 'item' => $item->fetch()]); + break; + + case 'update': + $id = (int)($input['id'] ?? 0); + $title = trim($input['title'] ?? ''); + $type = $input['type'] ?? 'movie'; + $service = trim($input['streaming_service'] ?? ''); + $genre = trim($input['genre'] ?? ''); + $notes = trim($input['notes'] ?? ''); + + if (!$id || $title === '') { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'ID and title required']); + exit; + } + + $stmt = $pdo->prepare(" + UPDATE watchlist SET title = ?, type = ?, streaming_service = ?, genre = ?, notes = ? + WHERE id = ? + "); + $stmt->execute([$title, $type, $service ?: null, $genre ?: null, $notes ?: null, $id]); + + $item = $pdo->prepare("SELECT * FROM watchlist WHERE id = ?"); + $item->execute([$id]); + + echo json_encode(['success' => true, 'item' => $item->fetch()]); + break; + + case 'delete': + $id = (int)($input['id'] ?? 0); + if (!$id) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'ID required']); + exit; + } + + $stmt = $pdo->prepare("DELETE FROM watchlist WHERE id = ?"); + $stmt->execute([$id]); + + echo json_encode(['success' => true]); + break; + + default: + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Unknown action']); + } + +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); +} diff --git a/app.js b/app.js new file mode 100644 index 0000000..6d7e7e5 --- /dev/null +++ b/app.js @@ -0,0 +1,470 @@ +/* ========================================================= + WATCHLIST app.js + ========================================================= */ + +/* State */ +const state = { + filter: 'all', + type: '', + service: '', + search: '', + items: [], + stats: { total: 0, watched: 0, unwatched: 0 }, + services: [], +}; + +/* DOM refs */ +const grid = document.getElementById('grid'); +const loadingState = document.getElementById('loadingState'); +const statTotal = document.getElementById('statTotal'); +const statUnwatched = document.getElementById('statUnwatched'); +const statWatched = document.getElementById('statWatched'); +const progressFill = document.getElementById('progressFill'); +const statPercent = document.getElementById('statPercent'); +const serviceRow = document.getElementById('serviceRow'); +const searchInput = document.getElementById('searchInput'); + +// Modal Add/Edit +const modalOverlay = document.getElementById('modalOverlay'); +const modal = document.getElementById('modal'); +const modalTitle = document.getElementById('modalTitle'); +const openAddModal = document.getElementById('openAddModal'); +const openAddModalEmpty = document.getElementById('openAddModalEmpty'); +const closeModal = document.getElementById('closeModal'); +const cancelModal = document.getElementById('cancelModal'); +const addForm = document.getElementById('addForm'); +const editId = document.getElementById('editId'); +const inputTitle = document.getElementById('inputTitle'); +const inputService = document.getElementById('inputService'); +const inputGenre = document.getElementById('inputGenre'); +const inputNotes = document.getElementById('inputNotes'); +const submitBtn = document.getElementById('submitBtn'); +const serviceList = document.getElementById('serviceList'); + +// Modal Delete +const deleteOverlay = document.getElementById('deleteOverlay'); +const closeDeleteModal = document.getElementById('closeDeleteModal'); +const cancelDelete = document.getElementById('cancelDelete'); +const confirmDelete = document.getElementById('confirmDelete'); +const deleteTitle = document.getElementById('deleteTitle'); + +// Filter pills +const filterGroup = document.getElementById('filterGroup'); +const typeGroup = document.getElementById('typeGroup'); + +// Toast +const toastContainer = document.getElementById('toastContainer'); + +/* Type toggle state */ +let selectedType = 'movie'; + +/* API helper */ +async function api(params = {}, body = null) { + const url = new URL('api.php', location.href); + Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); + + const opts = body + ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } + : { method: 'GET' }; + + const res = await fetch(url, opts); + return res.json(); +} + +/* Fetch & render */ +async function loadItems() { + showLoading(true); + + try { + const data = await api({ + action: 'list', + filter: state.filter, + type: state.type, + service: state.service, + search: state.search, + }); + + if (!data.success) throw new Error(data.error || 'Unknown error'); + + state.items = data.items || []; + state.stats = data.stats || { total: 0, watched: 0, unwatched: 0 }; + state.services = data.services || []; + + renderStats(); + renderServiceRow(); + renderGrid(); + populateServiceDatalist(); + + } catch (err) { + console.error(err); + toast('Failed to load watchlist', 'error'); + } finally { + showLoading(false); + } +} + +function showLoading(on) { + loadingState.style.display = on ? 'flex' : 'none'; +} + +/* Render Stats */ +function renderStats() { + const { total, watched, unwatched } = state.stats; + const t = parseInt(total) || 0; + const w = parseInt(watched) || 0; + const u = parseInt(unwatched)|| 0; + + statTotal.textContent = t; + statWatched.textContent = w; + statUnwatched.textContent = u; + + const pct = t > 0 ? Math.round((w / t) * 100) : 0; + progressFill.style.width = pct + '%'; + statPercent.textContent = pct + '% complete'; +} + +/* Render Service Row */ +function renderServiceRow() { + serviceRow.innerHTML = ''; + + if (!state.services.length) return; + + // "All" chip + const all = chip('All Services', '', state.service === ''); + serviceRow.appendChild(all); + + state.services.forEach(({ streaming_service }) => { + const c = chip(streaming_service, streaming_service, state.service === streaming_service); + serviceRow.appendChild(c); + }); +} + +function chip(label, value, active) { + const btn = document.createElement('button'); + btn.className = 'service-chip' + (active ? ' active' : ''); + btn.textContent = label; + btn.addEventListener('click', () => { + state.service = value; + loadItems(); + }); + return btn; +} + +/* Populate datalist for service input */ +function populateServiceDatalist() { + if (!serviceList) return; + serviceList.innerHTML = ''; + state.services.forEach(({ streaming_service }) => { + const opt = document.createElement('option'); + opt.value = streaming_service; + serviceList.appendChild(opt); + }); +} + +/* Render Grid */ +function renderGrid() { + // Remove old cards (keep loading div) + Array.from(grid.children).forEach(el => { + if (!el.id || el.id !== 'loadingState') el.remove(); + }); + + const emptyEl = document.getElementById('emptyState'); + if (emptyEl) emptyEl.remove(); + + if (!state.items.length) { + renderEmpty(); + return; + } + + state.items.forEach(item => { + grid.appendChild(buildCard(item)); + }); +} + +function renderEmpty() { + const div = document.createElement('div'); + div.className = 'empty-state'; + div.id = 'emptyState'; + div.innerHTML = ` +
+

Nothing here yet

+

Add your first movie or show to get started.

+ + `; + grid.appendChild(div); + div.querySelector('#openAddModalEmpty').addEventListener('click', openAdd); +} + +/* Build Card */ +function buildCard(item) { + const watched = item.watched == 1; + const isMovie = item.type === 'movie'; + + const card = document.createElement('div'); + card.className = 'card' + (watched ? ' watched' : ''); + card.dataset.id = item.id; + + // Format date + const dateStr = item.watched_at + ? 'Watched ' + formatDate(item.watched_at) + : 'Added ' + formatDate(item.created_at); + + card.innerHTML = ` +
+
+
+ + ${isMovie ? 'Movie' : 'Show'} + + ${item.streaming_service ? `${escHtml(item.streaming_service)}` : ''} + ${item.genre ? `${escHtml(item.genre)}` : ''} +
+

${escHtml(item.title)}

+ ${item.notes ? `

${escHtml(item.notes)}

` : ''} +

${dateStr}

+
+ + `; + + // Events + card.querySelector('.btn-watch').addEventListener('click', () => toggleWatched(item.id)); + card.querySelector('.btn-edit').addEventListener('click', () => openEdit(item)); + card.querySelector('.btn-delete').addEventListener('click', () => openDeleteConfirm(item.id, item.title)); + + return card; +} + +/* Toggle watched */ +async function toggleWatched(id) { + try { + const data = await api({}, { action: 'toggle', id }); + if (!data.success) throw new Error(data.error); + toast(data.item.watched ? '- Marked as watched' : 'Moved back to watchlist', 'success'); + loadItems(); + } catch (err) { + toast('Could not update item', 'error'); + } +} + +/* Add Modal */ +function openAdd() { + editId.value = ''; + addForm.reset(); + selectedType = 'movie'; + updateTypeToggle(); + modalTitle.textContent = 'Add Title'; + submitBtn.textContent = 'Add to Watchlist'; + showModal(true); + setTimeout(() => inputTitle.focus(), 50); +} + +function openEdit(item) { + editId.value = item.id; + inputTitle.value = item.title; + inputService.value = item.streaming_service || ''; + inputGenre.value = item.genre || ''; + inputNotes.value = item.notes || ''; + selectedType = item.type || 'movie'; + updateTypeToggle(); + modalTitle.textContent = 'Edit Title'; + submitBtn.textContent = 'Save Changes'; + showModal(true); + setTimeout(() => inputTitle.focus(), 50); +} + +function showModal(on) { + modalOverlay.classList.toggle('hidden', !on); + document.body.style.overflow = on ? 'hidden' : ''; +} + +/* Type toggle inside form */ +function updateTypeToggle() { + document.querySelectorAll('.type-option').forEach(btn => { + btn.classList.toggle('active', btn.dataset.typeVal === selectedType); + }); +} + +document.querySelectorAll('.type-option').forEach(btn => { + btn.addEventListener('click', () => { + selectedType = btn.dataset.typeVal; + updateTypeToggle(); + }); +}); + +/* Form submit */ +addForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + const title = inputTitle.value.trim(); + if (!title) { + inputTitle.focus(); + inputTitle.style.borderColor = 'var(--danger)'; + setTimeout(() => inputTitle.style.borderColor = '', 1200); + return; + } + + submitBtn.disabled = true; + submitBtn.textContent = editId.value ? 'Saving' : 'Adding'; + + try { + const payload = { + action: editId.value ? 'edit' : 'add', + id: editId.value ? parseInt(editId.value) : undefined, + title, + type: selectedType, + streaming_service: inputService.value.trim(), + genre: inputGenre.value.trim(), + notes: inputNotes.value.trim(), + }; + + const data = await api({}, payload); + if (!data.success) throw new Error(data.error); + + toast(editId.value ? '- Changes saved' : '- Added to watchlist', 'success'); + showModal(false); + loadItems(); + + } catch (err) { + toast(err.message || 'Something went wrong', 'error'); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = editId.value ? 'Save Changes' : 'Add to Watchlist'; + } +}); + +/* Delete */ +let pendingDeleteId = null; + +function openDeleteConfirm(id, title) { + pendingDeleteId = id; + deleteTitle.textContent = title; + deleteOverlay.classList.remove('hidden'); + document.body.style.overflow = 'hidden'; +} + +function closeDelete() { + pendingDeleteId = null; + deleteOverlay.classList.add('hidden'); + document.body.style.overflow = ''; +} + +confirmDelete.addEventListener('click', async () => { + if (!pendingDeleteId) return; + confirmDelete.disabled = true; + + try { + const data = await api({}, { action: 'delete', id: pendingDeleteId }); + if (!data.success) throw new Error(data.error); + toast('Removed from watchlist', 'success'); + closeDelete(); + loadItems(); + } catch (err) { + toast('Could not remove item', 'error'); + } finally { + confirmDelete.disabled = false; + } +}); + +/* Filter pills */ +filterGroup.addEventListener('click', (e) => { + const btn = e.target.closest('.pill'); + if (!btn) return; + filterGroup.querySelectorAll('.pill').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + state.filter = btn.dataset.filter; + loadItems(); +}); + +typeGroup.addEventListener('click', (e) => { + const btn = e.target.closest('.type-pill'); + if (!btn) return; + typeGroup.querySelectorAll('.type-pill').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + state.type = btn.dataset.type; + loadItems(); +}); + +/* Search (debounced) */ +let searchTimer; +searchInput.addEventListener('input', () => { + clearTimeout(searchTimer); + searchTimer = setTimeout(() => { + state.search = searchInput.value.trim(); + loadItems(); + }, 320); +}); + +/* Modal close handlers */ +openAddModal.addEventListener('click', openAdd); +closeModal.addEventListener('click', () => showModal(false)); +cancelModal.addEventListener('click', () => showModal(false)); +closeDeleteModal.addEventListener('click', closeDelete); +cancelDelete.addEventListener('click', closeDelete); + +// Close on overlay click +modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) showModal(false); }); +deleteOverlay.addEventListener('click', (e) => { if (e.target === deleteOverlay) closeDelete(); }); + +// Keyboard +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + if (!modalOverlay.classList.contains('hidden')) showModal(false); + if (!deleteOverlay.classList.contains('hidden')) closeDelete(); + } +}); + +/* Toast */ +function toast(msg, type = 'default') { + const el = document.createElement('div'); + el.className = 'toast' + (type === 'success' ? ' toast-success' : type === 'error' ? ' toast-error' : ''); + + const icon = type === 'success' ? '-' : type === 'error' ? '' : ''; + el.innerHTML = `${icon}${escHtml(msg)}`; + toastContainer.appendChild(el); + + setTimeout(() => { + el.style.animation = 'toastOut 280ms ease forwards'; + setTimeout(() => el.remove(), 280); + }, 3000); +} + +/* Helpers */ +function escHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function escAttr(str) { + return String(str).replace(/"/g, '"').replace(/'/g, '''); +} + +function formatDate(dateStr) { + if (!dateStr) return ''; + const d = new Date(dateStr); + if (isNaN(d)) return ''; + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +/* Init */ +loadItems(); diff --git a/db.php b/db.php new file mode 100644 index 0000000..68a8498 --- /dev/null +++ b/db.php @@ -0,0 +1,14 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); diff --git a/index.php b/index.php new file mode 100644 index 0000000..e1f04e7 --- /dev/null +++ b/index.php @@ -0,0 +1,171 @@ + + + + + + Watchlist + + + + + + + + + + +
+
+
+
+
+ +
+ + +
+
+ +
+

Watchlist

+

Your personal streaming guide

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