Deploy from Lumerel
This commit is contained in:
54
.docker/99-migrate.sh
Normal file
54
.docker/99-migrate.sh
Normal file
@@ -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
|
||||||
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.git
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
FROM webdevops/php-nginx:8.3-alpine
|
||||||
|
ENV WEB_DOCUMENT_ROOT=/app
|
||||||
|
ARG CACHE_BUST=1774391758
|
||||||
|
COPY . /app
|
||||||
|
RUN echo "index index.php index.html index.htm;" > /opt/docker/etc/nginx/vhost.common.d/01-index.conf \
|
||||||
|
&& echo "add_header Cache-Control 'no-cache, no-store, must-revalidate';" > /opt/docker/etc/nginx/vhost.common.d/02-no-cache.conf
|
||||||
|
RUN set -e; if [ -f /app/composer.json ]; then \
|
||||||
|
echo ">>> composer.json found, installing dependencies..."; \
|
||||||
|
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer; \
|
||||||
|
cd /app && composer install --no-dev --no-interaction --optimize-autoloader; \
|
||||||
|
ls -la /app/vendor/autoload.php; \
|
||||||
|
else \
|
||||||
|
echo ">>> No composer.json found, skipping composer install"; \
|
||||||
|
fi
|
||||||
|
RUN set -e; if [ -f /app/package.json ]; then \
|
||||||
|
echo ">>> package.json found, installing node dependencies..."; \
|
||||||
|
apk add --no-cache nodejs npm; \
|
||||||
|
cd /app && npm install --production; \
|
||||||
|
else \
|
||||||
|
echo ">>> No package.json found, skipping npm install"; \
|
||||||
|
fi
|
||||||
|
COPY --chmod=755 .docker/99-migrate.sh /opt/docker/provision/entrypoint.d/99-migrate.sh
|
||||||
|
RUN chown -R application:application /app
|
||||||
189
api.php
Normal file
189
api.php
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(204);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
$action = $_GET['action'] ?? $input['action'] ?? null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch ($action) {
|
||||||
|
|
||||||
|
case 'list':
|
||||||
|
$filter = $_GET['filter'] ?? 'all';
|
||||||
|
$service = $_GET['service'] ?? '';
|
||||||
|
$type = $_GET['type'] ?? '';
|
||||||
|
$search = $_GET['search'] ?? '';
|
||||||
|
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($filter === 'watched') {
|
||||||
|
$where[] = 'watched = 1';
|
||||||
|
} elseif ($filter === 'unwatched') {
|
||||||
|
$where[] = 'watched = 0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($service !== '') {
|
||||||
|
$where[] = 'streaming_service = ?';
|
||||||
|
$params[] = $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type !== '') {
|
||||||
|
$where[] = 'type = ?';
|
||||||
|
$params[] = $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($search !== '') {
|
||||||
|
$where[] = 'title LIKE ?';
|
||||||
|
$params[] = '%' . $search . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = 'SELECT * FROM watchlist';
|
||||||
|
if ($where) {
|
||||||
|
$sql .= ' WHERE ' . implode(' AND ', $where);
|
||||||
|
}
|
||||||
|
$sql .= ' ORDER BY watched ASC, created_at DESC';
|
||||||
|
|
||||||
|
$stmt = $pdo->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()]);
|
||||||
|
}
|
||||||
472
app.js
Normal file
472
app.js
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
/* =========================================================
|
||||||
|
WATCHLIST app.js
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
/* State */
|
||||||
|
const state = {
|
||||||
|
filter: 'all',
|
||||||
|
type: '',
|
||||||
|
service: '',
|
||||||
|
search: '',
|
||||||
|
items: [],
|
||||||
|
stats: { total: 0, watched: 0, unwatched: 0 },
|
||||||
|
services: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* DOM refs */
|
||||||
|
const grid = document.getElementById('grid');
|
||||||
|
const loadingState = document.getElementById('loadingState');
|
||||||
|
const statTotal = document.getElementById('statTotal');
|
||||||
|
const statUnwatched = document.getElementById('statUnwatched');
|
||||||
|
const statWatched = document.getElementById('statWatched');
|
||||||
|
const progressFill = document.getElementById('progressFill');
|
||||||
|
const statPercent = document.getElementById('statPercent');
|
||||||
|
const serviceRow = document.getElementById('serviceRow');
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
|
||||||
|
// Modal Add/Edit
|
||||||
|
const modalOverlay = document.getElementById('modalOverlay');
|
||||||
|
const modal = document.getElementById('modal');
|
||||||
|
const modalTitle = document.getElementById('modalTitle');
|
||||||
|
const openAddModal = document.getElementById('openAddModal');
|
||||||
|
const openAddModalEmpty = document.getElementById('openAddModalEmpty');
|
||||||
|
const closeModal = document.getElementById('closeModal');
|
||||||
|
const cancelModal = document.getElementById('cancelModal');
|
||||||
|
const addForm = document.getElementById('addForm');
|
||||||
|
const editId = document.getElementById('editId');
|
||||||
|
const inputTitle = document.getElementById('inputTitle');
|
||||||
|
const inputService = document.getElementById('inputService');
|
||||||
|
const inputGenre = document.getElementById('inputGenre');
|
||||||
|
const inputNotes = document.getElementById('inputNotes');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const serviceList = document.getElementById('serviceList');
|
||||||
|
|
||||||
|
// Modal Delete
|
||||||
|
const deleteOverlay = document.getElementById('deleteOverlay');
|
||||||
|
const closeDeleteModal = document.getElementById('closeDeleteModal');
|
||||||
|
const cancelDelete = document.getElementById('cancelDelete');
|
||||||
|
const confirmDelete = document.getElementById('confirmDelete');
|
||||||
|
const deleteTitle = document.getElementById('deleteTitle');
|
||||||
|
|
||||||
|
// Filter pills
|
||||||
|
const filterGroup = document.getElementById('filterGroup');
|
||||||
|
const typeGroup = document.getElementById('typeGroup');
|
||||||
|
|
||||||
|
// Toast
|
||||||
|
const toastContainer = document.getElementById('toastContainer');
|
||||||
|
|
||||||
|
/* Type toggle state */
|
||||||
|
let selectedType = 'movie';
|
||||||
|
|
||||||
|
/* API helper */
|
||||||
|
async function api(params = {}, body = null) {
|
||||||
|
const url = new URL('api.php', location.href);
|
||||||
|
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
|
||||||
|
|
||||||
|
const opts = body
|
||||||
|
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
|
||||||
|
: { method: 'GET' };
|
||||||
|
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fetch & render */
|
||||||
|
async function loadItems() {
|
||||||
|
showLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api({
|
||||||
|
action: 'list',
|
||||||
|
filter: state.filter,
|
||||||
|
type: state.type,
|
||||||
|
service: state.service,
|
||||||
|
search: state.search,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data.success) throw new Error(data.error || 'Unknown error');
|
||||||
|
|
||||||
|
state.items = data.items || [];
|
||||||
|
state.stats = data.stats || { total: 0, watched: 0, unwatched: 0 };
|
||||||
|
state.services = data.services || [];
|
||||||
|
|
||||||
|
renderStats();
|
||||||
|
renderServiceRow();
|
||||||
|
renderGrid();
|
||||||
|
populateServiceDatalist();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast('Failed to load watchlist', 'error');
|
||||||
|
} finally {
|
||||||
|
showLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoading(on) {
|
||||||
|
loadingState.style.display = on ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Render Stats */
|
||||||
|
function renderStats() {
|
||||||
|
const { total, watched, unwatched } = state.stats;
|
||||||
|
const t = parseInt(total) || 0;
|
||||||
|
const w = parseInt(watched) || 0;
|
||||||
|
const u = parseInt(unwatched)|| 0;
|
||||||
|
|
||||||
|
statTotal.textContent = t;
|
||||||
|
statWatched.textContent = w;
|
||||||
|
statUnwatched.textContent = u;
|
||||||
|
|
||||||
|
const pct = t > 0 ? Math.round((w / t) * 100) : 0;
|
||||||
|
progressFill.style.width = pct + '%';
|
||||||
|
statPercent.textContent = pct + '% complete';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Render Service Row */
|
||||||
|
function renderServiceRow() {
|
||||||
|
serviceRow.innerHTML = '';
|
||||||
|
|
||||||
|
if (!state.services.length) return;
|
||||||
|
|
||||||
|
// "All" chip
|
||||||
|
const all = chip('All Services', '', state.service === '');
|
||||||
|
serviceRow.appendChild(all);
|
||||||
|
|
||||||
|
state.services.forEach(({ streaming_service }) => {
|
||||||
|
const c = chip(streaming_service, streaming_service, state.service === streaming_service);
|
||||||
|
serviceRow.appendChild(c);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function chip(label, value, active) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'service-chip' + (active ? ' active' : '');
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
state.service = value;
|
||||||
|
loadItems();
|
||||||
|
});
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Populate datalist for service input */
|
||||||
|
function populateServiceDatalist() {
|
||||||
|
if (!serviceList) return;
|
||||||
|
serviceList.innerHTML = '';
|
||||||
|
state.services.forEach(({ streaming_service }) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = streaming_service;
|
||||||
|
serviceList.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Render Grid */
|
||||||
|
function renderGrid() {
|
||||||
|
// Remove old cards (keep loading div)
|
||||||
|
Array.from(grid.children).forEach(el => {
|
||||||
|
if (!el.id || el.id !== 'loadingState') el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyEl = document.getElementById('emptyState');
|
||||||
|
if (emptyEl) emptyEl.remove();
|
||||||
|
|
||||||
|
if (!state.items.length) {
|
||||||
|
renderEmpty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.items.forEach(item => {
|
||||||
|
grid.appendChild(buildCard(item));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmpty() {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'empty-state';
|
||||||
|
div.id = 'emptyState';
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="empty-icon"></div>
|
||||||
|
<h2 class="empty-title">Nothing here yet</h2>
|
||||||
|
<p class="empty-sub">Add your first movie or show to get started.</p>
|
||||||
|
<button class="btn-primary" id="openAddModalEmpty">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
Add Title
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<div class="card-accent"></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-meta">
|
||||||
|
<span class="badge ${isMovie ? 'badge-movie' : 'badge-show'}">
|
||||||
|
${isMovie ? 'Movie' : 'Show'}
|
||||||
|
</span>
|
||||||
|
${item.streaming_service ? `<span class="badge badge-service">${escHtml(item.streaming_service)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="card-meta">
|
||||||
|
${item.genre ? `<span class="badge badge-genre">${escHtml(item.genre)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<h3 class="card-title">${escHtml(item.title)}</h3>
|
||||||
|
${item.notes ? `<p class="card-notes">${escHtml(item.notes)}</p>` : ''}
|
||||||
|
<p class="card-date">${dateStr}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<button class="btn-watch" data-id="${item.id}" data-watched="${watched ? 1 : 0}">
|
||||||
|
${watched
|
||||||
|
? `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> Watched`
|
||||||
|
: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/></svg> Mark Watched`}
|
||||||
|
</button>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="btn-icon btn-edit" data-id="${item.id}" title="Edit">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon btn-delete" data-id="${item.id}" data-title="${escAttr(item.title)}" title="Remove">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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 = `<span class="toast-icon">${icon}</span><span>${escHtml(msg)}</span>`;
|
||||||
|
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, '"')
|
||||||
|
.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();
|
||||||
14
db.php
Normal file
14
db.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
$host = getenv('DB_HOST');
|
||||||
|
$port = getenv('DB_PORT') ?: '3306';
|
||||||
|
$db = getenv('DB_DATABASE');
|
||||||
|
$user = getenv('DB_USERNAME');
|
||||||
|
$pass = getenv('DB_PASSWORD');
|
||||||
|
|
||||||
|
$pdo = new PDO(
|
||||||
|
"mysql:host={$host};port={$port};dbname={$db};charset=utf8mb4",
|
||||||
|
$user,
|
||||||
|
$pass
|
||||||
|
);
|
||||||
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
169
index.php
Normal file
169
index.php
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Watchlist</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=DM+Serif+Display:ital@0;1&display=swap" rel="stylesheet" />
|
||||||
|
<!-- Bootstrap 5 -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Ambient background -->
|
||||||
|
<div class="ambient">
|
||||||
|
<div class="ambient-orb orb-1"></div>
|
||||||
|
<div class="ambient-orb orb-2"></div>
|
||||||
|
<div class="ambient-orb orb-3"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
|
||||||
|
<!-- HEADER -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-brand">
|
||||||
|
<span class="header-icon"></span>
|
||||||
|
<div class="header-text">
|
||||||
|
<h1 class="header-title">Watchlist</h1>
|
||||||
|
<p class="header-sub">Your personal streaming guide</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" id="openAddModal">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
Add Title
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- STATS -->
|
||||||
|
<section class="stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value" id="statTotal"></span>
|
||||||
|
<span class="stat-label">Total</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-sep"></div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value" id="statUnwatched"></span>
|
||||||
|
<span class="stat-label">To Watch</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-sep"></div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value" id="statWatched"></span>
|
||||||
|
<span class="stat-label">Watched</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-sep"></div>
|
||||||
|
<div class="stat stat-progress">
|
||||||
|
<div class="progress-track">
|
||||||
|
<div class="progress-fill" id="progressFill"></div>
|
||||||
|
</div>
|
||||||
|
<span class="stat-label" id="statPercent">0% complete</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CONTROLS -->
|
||||||
|
<section class="controls">
|
||||||
|
<div class="pill-group" id="filterGroup">
|
||||||
|
<button class="pill active" data-filter="all">All</button>
|
||||||
|
<button class="pill" data-filter="unwatched">To Watch</button>
|
||||||
|
<button class="pill" data-filter="watched">Watched</button>
|
||||||
|
</div>
|
||||||
|
<div class="pill-group" id="typeGroup">
|
||||||
|
<button class="type-pill active" data-type="">All</button>
|
||||||
|
<button class="type-pill" data-type="movie">Movies</button>
|
||||||
|
<button class="type-pill" data-type="show">Shows</button>
|
||||||
|
</div>
|
||||||
|
<div class="search-box">
|
||||||
|
<svg class="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
<input type="text" class="search-input" id="searchInput" placeholder="Search" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SERVICE FILTER PILLS -->
|
||||||
|
<div class="service-row" id="serviceRow"></div>
|
||||||
|
|
||||||
|
<!-- GRID -->
|
||||||
|
<main class="grid" id="grid">
|
||||||
|
<div class="loading" id="loadingState">
|
||||||
|
<div class="loader"></div>
|
||||||
|
<span>Loading your watchlist</span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div><!-- /.app -->
|
||||||
|
|
||||||
|
<!-- ADD / EDIT MODAL -->
|
||||||
|
<div class="modal-overlay hidden" id="modalOverlay">
|
||||||
|
<div class="modal" id="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title" id="modalTitle">Add Title</h2>
|
||||||
|
<button class="modal-close" id="closeModal">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="addForm" class="modal-form">
|
||||||
|
<input type="hidden" id="editId" />
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label" for="inputTitle">Title <span class="req">*</span></label>
|
||||||
|
<input type="text" class="form-input" id="inputTitle" placeholder="e.g. Dune: Part Two" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label">Type</label>
|
||||||
|
<div class="type-toggle">
|
||||||
|
<button type="button" class="type-option active" data-type-val="movie">Movie</button>
|
||||||
|
<button type="button" class="type-option" data-type-val="show">Show</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label" for="inputService">Streaming On</label>
|
||||||
|
<input type="text" class="form-input" id="inputService" placeholder="e.g. Netflix, HBO Max" autocomplete="off" list="serviceList" />
|
||||||
|
<datalist id="serviceList"></datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label" for="inputGenre">Genre</label>
|
||||||
|
<input type="text" class="form-input" id="inputGenre" placeholder="e.g. Sci-Fi, Drama" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label" for="inputNotes">Notes</label>
|
||||||
|
<input type="text" class="form-input" id="inputNotes" placeholder="Optional notes" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-ghost" id="cancelModal">Cancel</button>
|
||||||
|
<button type="submit" class="btn-primary" id="submitBtn">Add to Watchlist</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DELETE CONFIRM MODAL -->
|
||||||
|
<div class="modal-overlay hidden" id="deleteOverlay">
|
||||||
|
<div class="modal modal-sm" id="deleteModal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title">Remove Title?</h2>
|
||||||
|
<button class="modal-close" id="closeDeleteModal">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="delete-msg">Are you sure you want to remove <strong id="deleteTitle"></strong> from your watchlist?</p>
|
||||||
|
<div class="modal-actions" style="padding: 0 28px 24px;">
|
||||||
|
<button class="btn-ghost" id="cancelDelete">Cancel</button>
|
||||||
|
<button class="btn-danger" id="confirmDelete">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TOAST -->
|
||||||
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
|
|
||||||
|
<script src="app.js?v=2"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
migrate.php
Normal file
25
migrate.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
$pdo->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";
|
||||||
13
migrations/001_create_watchlist_table.php
Normal file
13
migrations/001_create_watchlist_table.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
$pdo->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
|
||||||
|
)");
|
||||||
938
style.css
Normal file
938
style.css
Normal file
@@ -0,0 +1,938 @@
|
|||||||
|
/* =========================================================
|
||||||
|
WATCHLIST style.css
|
||||||
|
Design: modern, organic, peaceful, clean, expensive
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
/* Google Fonts loaded in HTML (DM Sans + DM Serif Display) */
|
||||||
|
|
||||||
|
/* Bootstrap 5 CDN (loaded via index.php <head>) */
|
||||||
|
|
||||||
|
/* CSS Custom Properties */
|
||||||
|
:root {
|
||||||
|
--sage: #8aad8f;
|
||||||
|
--sage-light: #b5ccb8;
|
||||||
|
--sage-dark: #5d8264;
|
||||||
|
--cream: #f7f4ee;
|
||||||
|
--cream-deep: #ede8df;
|
||||||
|
--stone: #c4b9a8;
|
||||||
|
--stone-dark: #9e9080;
|
||||||
|
--bark: #5c4f3d;
|
||||||
|
--bark-light: #7a6a56;
|
||||||
|
--mist: #e8ede9;
|
||||||
|
--white: #ffffff;
|
||||||
|
--text-primary: #2e2a24;
|
||||||
|
--text-secondary: #6b6257;
|
||||||
|
--text-muted: #9e9080;
|
||||||
|
--danger: #c0614a;
|
||||||
|
--danger-soft: #f5e8e5;
|
||||||
|
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 14px;
|
||||||
|
--radius-lg: 20px;
|
||||||
|
--radius-xl: 28px;
|
||||||
|
--radius-pill: 999px;
|
||||||
|
|
||||||
|
--shadow-xs: 0 1px 3px rgba(60,50,35,.06);
|
||||||
|
--shadow-sm: 0 2px 8px rgba(60,50,35,.08), 0 1px 2px rgba(60,50,35,.04);
|
||||||
|
--shadow-md: 0 6px 24px rgba(60,50,35,.10), 0 2px 6px rgba(60,50,35,.06);
|
||||||
|
--shadow-lg: 0 16px 48px rgba(60,50,35,.14), 0 4px 12px rgba(60,50,35,.08);
|
||||||
|
--shadow-modal: 0 24px 80px rgba(40,32,20,.22), 0 6px 20px rgba(40,32,20,.10);
|
||||||
|
|
||||||
|
--transition: 200ms cubic-bezier(.4,0,.2,1);
|
||||||
|
--transition-slow: 380ms cubic-bezier(.4,0,.2,1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset & Base */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'DM Sans', system-ui, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--cream);
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ambient Background */
|
||||||
|
.ambient {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-orb {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(80px);
|
||||||
|
opacity: .55;
|
||||||
|
animation: drift 18s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb-1 {
|
||||||
|
width: 600px; height: 600px;
|
||||||
|
background: radial-gradient(circle, #c8d9c5 0%, transparent 70%);
|
||||||
|
top: -200px; left: -150px;
|
||||||
|
animation-duration: 22s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb-2 {
|
||||||
|
width: 500px; height: 500px;
|
||||||
|
background: radial-gradient(circle, #d9cfc0 0%, transparent 70%);
|
||||||
|
top: 30%; right: -180px;
|
||||||
|
animation-duration: 28s;
|
||||||
|
animation-delay: -8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb-3 {
|
||||||
|
width: 400px; height: 400px;
|
||||||
|
background: radial-gradient(circle, #bfcfbf 0%, transparent 70%);
|
||||||
|
bottom: -100px; left: 30%;
|
||||||
|
animation-duration: 20s;
|
||||||
|
animation-delay: -14s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drift {
|
||||||
|
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||||
|
33% { transform: translate(30px, -40px) scale(1.05); }
|
||||||
|
66% { transform: translate(-20px, 20px) scale(.95); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App Shell */
|
||||||
|
.app {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 24px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 36px;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 2px 6px rgba(90,100,80,.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-family: 'DM Serif Display', Georgia, serif;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
color: var(--bark);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-sub {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: .04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 11px 22px;
|
||||||
|
background: var(--sage-dark);
|
||||||
|
color: var(--white);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: .875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .01em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition), transform var(--transition), box-shadow var(--transition);
|
||||||
|
box-shadow: 0 2px 12px rgba(93,130,100,.30);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--sage);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 20px rgba(93,130,100,.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1.5px solid var(--stone);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: .875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: var(--cream-deep);
|
||||||
|
border-color: var(--stone-dark);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--danger);
|
||||||
|
color: var(--white);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: .875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
box-shadow: 0 2px 10px rgba(192,97,74,.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #a8523d;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 16px rgba(192,97,74,.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Bar */
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 20px 28px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
border: 1px solid rgba(196,185,168,.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-family: 'DM Serif Display', serif;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--bark);
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
min-width: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 36px;
|
||||||
|
background: var(--cream-deep);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-progress {
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--cream-deep);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--sage-dark), var(--sage));
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
transition: width .6s cubic-bezier(.4,0,.2,1);
|
||||||
|
width: 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls */
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
padding: 4px;
|
||||||
|
gap: 2px;
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
border: 1px solid rgba(196,185,168,.30);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill, .type-pill {
|
||||||
|
padding: 7px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill:hover, .type-pill:hover {
|
||||||
|
background: var(--mist);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.active, .type-pill.active {
|
||||||
|
background: var(--sage-dark);
|
||||||
|
color: var(--white);
|
||||||
|
box-shadow: 0 2px 8px rgba(93,130,100,.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search */
|
||||||
|
.search-box {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--stone-dark);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px 10px 40px;
|
||||||
|
background: var(--white);
|
||||||
|
border: 1.5px solid rgba(196,185,168,.40);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: .875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition), box-shadow var(--transition);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder { color: var(--stone); }
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: var(--sage);
|
||||||
|
box-shadow: 0 0 0 3px rgba(138,173,143,.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Service Filter Row */
|
||||||
|
.service-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
min-height: 0;
|
||||||
|
transition: min-height var(--transition-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-row:empty { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.service-chip {
|
||||||
|
padding: 5px 14px;
|
||||||
|
border: 1.5px solid var(--stone);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--white);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-chip:hover {
|
||||||
|
border-color: var(--sage);
|
||||||
|
color: var(--sage-dark);
|
||||||
|
background: var(--mist);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-chip.active {
|
||||||
|
background: var(--sage-dark);
|
||||||
|
border-color: var(--sage-dark);
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
.card {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid rgba(196,185,168,.25);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform var(--transition), box-shadow var(--transition);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.watched {
|
||||||
|
opacity: .72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.watched .card-title {
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-decoration-color: var(--stone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card colour accent strip */
|
||||||
|
.card-accent {
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(90deg, var(--sage-light), var(--sage-dark));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.watched .card-accent {
|
||||||
|
background: linear-gradient(90deg, var(--stone), var(--stone-dark));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 18px 18px 14px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-size: .7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-movie {
|
||||||
|
background: #edf0f7;
|
||||||
|
color: #5060a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-show {
|
||||||
|
background: #f0ede8;
|
||||||
|
color: #8a6a30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-service {
|
||||||
|
background: var(--mist);
|
||||||
|
color: var(--sage-dark);
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-size: .72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-genre {
|
||||||
|
background: var(--cream-deep);
|
||||||
|
color: var(--bark-light);
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-size: .72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-family: 'DM Serif Display', serif;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.3;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-notes {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-date {
|
||||||
|
font-size: .7rem;
|
||||||
|
color: var(--stone);
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Footer */
|
||||||
|
.card-footer {
|
||||||
|
padding: 10px 18px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
border-top: 1px solid rgba(196,185,168,.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-watch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 1.5px solid var(--sage-light);
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--sage-dark);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-watch:hover {
|
||||||
|
background: var(--sage-dark);
|
||||||
|
border-color: var(--sage-dark);
|
||||||
|
color: var(--white);
|
||||||
|
box-shadow: 0 2px 10px rgba(93,130,100,.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.watched .btn-watch {
|
||||||
|
border-color: var(--stone);
|
||||||
|
color: var(--stone-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.watched .btn-watch:hover {
|
||||||
|
background: var(--stone-dark);
|
||||||
|
border-color: var(--stone-dark);
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--stone-dark);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: var(--cream-deep);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon.btn-delete:hover {
|
||||||
|
background: var(--danger-soft);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.loading {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 80px 20px;
|
||||||
|
gap: 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 3px solid var(--cream-deep);
|
||||||
|
border-top-color: var(--sage-dark);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin .8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 80px 20px;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
filter: grayscale(.3);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-family: 'DM Serif Display', serif;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--bark);
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-sub {
|
||||||
|
font-size: .875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 300px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
|
||||||
|
/* Override Bootstrap's .modal { display: none } — we control visibility via .modal-overlay.hidden */
|
||||||
|
.modal-overlay .modal {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(40, 32, 20, .45);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
animation: fadeIn 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.hidden { display: none; }
|
||||||
|
|
||||||
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-modal);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: slideUp 220ms cubic-bezier(.34,1.3,.64,1);
|
||||||
|
border: 1px solid rgba(196,185,168,.20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-sm { max-width: 380px; }
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { opacity: 0; transform: translateY(20px) scale(.97); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px 28px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-family: 'DM Serif Display', serif;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--bark);
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
background: var(--cream-deep);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
background: var(--stone);
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Form */
|
||||||
|
.modal-form {
|
||||||
|
padding: 20px 28px 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.req { color: var(--sage-dark); }
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 14px;
|
||||||
|
background: var(--cream);
|
||||||
|
border: 1.5px solid rgba(196,185,168,.50);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: .9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition), box-shadow var(--transition), background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input::placeholder { color: var(--stone); }
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
border-color: var(--sage);
|
||||||
|
background: var(--white);
|
||||||
|
box-shadow: 0 0 0 3px rgba(138,173,143,.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type toggle inside form */
|
||||||
|
.type-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-option {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1.5px solid rgba(196,185,168,.50);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--cream);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: .85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-option:hover {
|
||||||
|
border-color: var(--sage-light);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-option.active {
|
||||||
|
border-color: var(--sage-dark);
|
||||||
|
background: var(--mist);
|
||||||
|
color: var(--sage-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-msg {
|
||||||
|
padding: 6px 28px 20px;
|
||||||
|
font-size: .9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-msg strong { color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 28px;
|
||||||
|
right: 28px;
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 13px 18px;
|
||||||
|
background: var(--bark);
|
||||||
|
color: var(--cream);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: .85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
pointer-events: auto;
|
||||||
|
animation: toastIn 300ms cubic-bezier(.34,1.3,.64,1);
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.toast-success { background: var(--sage-dark); }
|
||||||
|
.toast.toast-error { background: var(--danger); }
|
||||||
|
|
||||||
|
.toast-icon { font-size: 1rem; flex-shrink: 0; }
|
||||||
|
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { opacity: 0; transform: translateX(30px) scale(.95); }
|
||||||
|
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toastOut {
|
||||||
|
from { opacity: 1; transform: translateX(0); }
|
||||||
|
to { opacity: 0; transform: translateX(30px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--stone); border-radius: 99px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--stone-dark); }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.app { padding: 24px 16px 60px; }
|
||||||
|
|
||||||
|
.header-title { font-size: 1.6rem; }
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 16px 20px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-sep { display: none; }
|
||||||
|
|
||||||
|
.stat { flex: 1 1 40%; }
|
||||||
|
|
||||||
|
.controls { gap: 8px; }
|
||||||
|
|
||||||
|
.search-box { max-width: 100%; min-width: 100%; order: 3; }
|
||||||
|
|
||||||
|
.form-row { grid-template-columns: 1fr; }
|
||||||
|
|
||||||
|
.modal { border-radius: var(--radius-lg); }
|
||||||
|
|
||||||
|
.modal-header { padding: 20px 20px 0; }
|
||||||
|
.modal-form { padding: 16px 20px 20px; }
|
||||||
|
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
|
||||||
|
.toast-container { bottom: 16px; right: 16px; left: 16px; }
|
||||||
|
.toast { max-width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.type-toggle { flex-direction: column; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user