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=1774577383
|
||||
COPY . /app
|
||||
RUN echo "index index.php index.html index.htm;" > /opt/docker/etc/nginx/vhost.common.d/01-index.conf \
|
||||
&& echo "add_header Cache-Control 'no-cache, no-store, must-revalidate';" > /opt/docker/etc/nginx/vhost.common.d/02-no-cache.conf
|
||||
RUN set -e; if [ -f /app/composer.json ]; then \
|
||||
echo ">>> composer.json found, installing dependencies..."; \
|
||||
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer; \
|
||||
cd /app && composer install --no-dev --no-interaction --optimize-autoloader; \
|
||||
ls -la /app/vendor/autoload.php; \
|
||||
else \
|
||||
echo ">>> No composer.json found, skipping composer install"; \
|
||||
fi
|
||||
RUN set -e; if [ -f /app/package.json ]; then \
|
||||
echo ">>> package.json found, installing node dependencies..."; \
|
||||
apk add --no-cache nodejs npm; \
|
||||
cd /app && npm install --production; \
|
||||
else \
|
||||
echo ">>> No package.json found, skipping npm install"; \
|
||||
fi
|
||||
COPY --chmod=755 .docker/99-migrate.sh /opt/docker/provision/entrypoint.d/99-migrate.sh
|
||||
RUN chown -R application:application /app
|
||||
205
api.php
Normal file
205
api.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?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'] ?? '';
|
||||
$forWhom = $_GET['for_whom'] ?? '';
|
||||
$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 ($forWhom !== '') {
|
||||
$where[] = 'for_whom = ?';
|
||||
$params[] = $forWhom;
|
||||
}
|
||||
|
||||
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';
|
||||
$forWhom = $input['for_whom'] ?? 'all';
|
||||
$service = trim($input['streaming_service'] ?? '');
|
||||
$genre = trim($input['genre'] ?? '');
|
||||
$notes = trim($input['notes'] ?? '');
|
||||
|
||||
if ($title === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Title is required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$validForWhom = ['all', 'nik', 'tod'];
|
||||
if (!in_array($forWhom, $validForWhom)) {
|
||||
$forWhom = 'all';
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO watchlist (title, type, for_whom, streaming_service, genre, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([$title, $type, $forWhom, $service ?: null, $genre ?: null, $notes ?: null]);
|
||||
$id = $pdo->lastInsertId();
|
||||
|
||||
$item = $pdo->prepare("SELECT * FROM watchlist WHERE id = ?");
|
||||
$item->execute([$id]);
|
||||
|
||||
echo json_encode(['success' => true, 'item' => $item->fetch()]);
|
||||
break;
|
||||
|
||||
case 'toggle':
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
if (!$id) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$current = $pdo->prepare("SELECT watched FROM watchlist WHERE id = ?");
|
||||
$current->execute([$id]);
|
||||
$row = $current->fetch();
|
||||
|
||||
if (!$row) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'error' => 'Item not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$newWatched = $row['watched'] ? 0 : 1;
|
||||
$watchedAt = $newWatched ? date('Y-m-d H:i:s') : null;
|
||||
|
||||
$stmt = $pdo->prepare("UPDATE watchlist SET watched = ?, watched_at = ? WHERE id = ?");
|
||||
$stmt->execute([$newWatched, $watchedAt, $id]);
|
||||
|
||||
echo json_encode(['success' => true, 'watched' => $newWatched]);
|
||||
break;
|
||||
|
||||
case 'edit':
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
$title = trim($input['title'] ?? '');
|
||||
$type = $input['type'] ?? 'movie';
|
||||
$forWhom = $input['for_whom'] ?? 'all';
|
||||
$service = trim($input['streaming_service'] ?? '');
|
||||
$genre = trim($input['genre'] ?? '');
|
||||
$notes = trim($input['notes'] ?? '');
|
||||
|
||||
if (!$id || $title === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'ID and title are required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$validForWhom = ['all', 'nik', 'tod'];
|
||||
if (!in_array($forWhom, $validForWhom)) {
|
||||
$forWhom = 'all';
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE watchlist
|
||||
SET title = ?, type = ?, for_whom = ?, streaming_service = ?, genre = ?, notes = ?
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmt->execute([$title, $type, $forWhom, $service ?: null, $genre ?: null, $notes ?: null, $id]);
|
||||
|
||||
$item = $pdo->prepare("SELECT * FROM watchlist WHERE id = ?");
|
||||
$item->execute([$id]);
|
||||
|
||||
echo json_encode(['success' => true, 'item' => $item->fetch()]);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
if (!$id) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("DELETE FROM watchlist WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
|
||||
echo json_encode(['success' => true]);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Unknown action']);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
557
app.js
Normal file
557
app.js
Normal file
@@ -0,0 +1,557 @@
|
||||
/* =========================================================
|
||||
WATCHLIST app.js
|
||||
========================================================= */
|
||||
|
||||
/* State */
|
||||
const state = {
|
||||
filter: 'unwatched',
|
||||
type: '',
|
||||
forWhom: '',
|
||||
service: '',
|
||||
search: '',
|
||||
items: [],
|
||||
stats: { total: 0, watched: 0, unwatched: 0 },
|
||||
services: [],
|
||||
};
|
||||
|
||||
/* DOM refs */
|
||||
const grid = document.getElementById('grid');
|
||||
const loadingState = document.getElementById('loadingState');
|
||||
const statTotal = document.getElementById('statTotal');
|
||||
const statUnwatched = document.getElementById('statUnwatched');
|
||||
const statWatched = document.getElementById('statWatched');
|
||||
const progressFill = document.getElementById('progressFill');
|
||||
const statPercent = document.getElementById('statPercent');
|
||||
const serviceRow = document.getElementById('serviceRow');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
|
||||
// Modal Add/Edit
|
||||
const modalOverlay = document.getElementById('modalOverlay');
|
||||
const modal = document.getElementById('modal');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
const openAddModal = document.getElementById('openAddModal');
|
||||
const openAddModalEmpty = document.getElementById('openAddModalEmpty');
|
||||
const closeModal = document.getElementById('closeModal');
|
||||
const cancelModal = document.getElementById('cancelModal');
|
||||
const addForm = document.getElementById('addForm');
|
||||
const editId = document.getElementById('editId');
|
||||
const inputTitle = document.getElementById('inputTitle');
|
||||
const inputService = document.getElementById('inputService');
|
||||
const inputGenre = document.getElementById('inputGenre');
|
||||
const inputNotes = document.getElementById('inputNotes');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const serviceList = document.getElementById('serviceList');
|
||||
|
||||
// Modal Delete
|
||||
const deleteOverlay = document.getElementById('deleteOverlay');
|
||||
const closeDeleteModal = document.getElementById('closeDeleteModal');
|
||||
const cancelDelete = document.getElementById('cancelDelete');
|
||||
const confirmDelete = document.getElementById('confirmDelete');
|
||||
const deleteTitle = document.getElementById('deleteTitle');
|
||||
|
||||
// Filter pills
|
||||
const filterGroup = document.getElementById('filterGroup');
|
||||
const typeGroup = document.getElementById('typeGroup');
|
||||
const forGroup = document.getElementById('forGroup');
|
||||
|
||||
// Toast
|
||||
const toastContainer = document.getElementById('toastContainer');
|
||||
|
||||
/* Type & For toggle state (modal) */
|
||||
let selectedType = 'movie';
|
||||
let selectedForWhom = 'all';
|
||||
|
||||
/* API helper */
|
||||
async function api(params = {}, body = null) {
|
||||
const url = new URL('api.php', location.href);
|
||||
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
|
||||
|
||||
const opts = body
|
||||
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
|
||||
: { method: 'GET' };
|
||||
|
||||
const res = await fetch(url, opts);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/* Fetch & render */
|
||||
async function loadItems() {
|
||||
showLoading(true);
|
||||
pushUrlState();
|
||||
|
||||
try {
|
||||
const data = await api({
|
||||
action: 'list',
|
||||
filter: state.filter,
|
||||
type: state.type,
|
||||
for_whom: state.forWhom,
|
||||
service: state.service,
|
||||
search: state.search,
|
||||
});
|
||||
|
||||
if (!data.success) throw new Error(data.error || 'Unknown error');
|
||||
|
||||
state.items = data.items || [];
|
||||
state.stats = data.stats || { total: 0, watched: 0, unwatched: 0 };
|
||||
state.services = data.services || [];
|
||||
|
||||
renderStats();
|
||||
renderServiceRow();
|
||||
renderGrid();
|
||||
populateServiceDatalist();
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast('Failed to load watchlist', 'error');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function showLoading(on) {
|
||||
loadingState.style.display = on ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
/* Render Stats */
|
||||
function renderStats() {
|
||||
const { total, watched, unwatched } = state.stats;
|
||||
const t = parseInt(total) || 0;
|
||||
const w = parseInt(watched) || 0;
|
||||
const u = parseInt(unwatched) || 0;
|
||||
|
||||
statTotal.textContent = t;
|
||||
statWatched.textContent = w;
|
||||
statUnwatched.textContent = u;
|
||||
|
||||
const pct = t > 0 ? Math.round((w / t) * 100) : 0;
|
||||
progressFill.style.width = pct + '%';
|
||||
statPercent.textContent = pct + '% complete';
|
||||
}
|
||||
|
||||
/* Render Service Row */
|
||||
function renderServiceRow() {
|
||||
serviceRow.innerHTML = '';
|
||||
if (!state.services.length) return;
|
||||
|
||||
const all = chip('All Services', '', state.service === '');
|
||||
all.addEventListener('click', () => { state.service = ''; loadItems(); });
|
||||
serviceRow.appendChild(all);
|
||||
|
||||
state.services.forEach(s => {
|
||||
const c = chip(s.streaming_service, s.streaming_service, state.service === s.streaming_service);
|
||||
c.addEventListener('click', () => {
|
||||
state.service = state.service === s.streaming_service ? '' : s.streaming_service;
|
||||
loadItems();
|
||||
});
|
||||
serviceRow.appendChild(c);
|
||||
});
|
||||
}
|
||||
|
||||
function chip(label, value, active) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'service-chip' + (active ? ' active' : '');
|
||||
btn.textContent = label;
|
||||
btn.dataset.value = value;
|
||||
return btn;
|
||||
}
|
||||
|
||||
function populateServiceDatalist() {
|
||||
serviceList.innerHTML = '';
|
||||
state.services.forEach(s => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.streaming_service;
|
||||
serviceList.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
/* Render Grid */
|
||||
function renderGrid() {
|
||||
// Remove old cards (keep loading state)
|
||||
Array.from(grid.children).forEach(child => {
|
||||
if (child.id !== 'loadingState') child.remove();
|
||||
});
|
||||
|
||||
if (!state.items.length) {
|
||||
renderEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
state.items.forEach(item => {
|
||||
grid.appendChild(buildCard(item));
|
||||
});
|
||||
}
|
||||
|
||||
function renderEmpty() {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'empty-state';
|
||||
div.innerHTML = `
|
||||
<div class="empty-icon">🎬</div>
|
||||
<h3 class="empty-title">Nothing here yet</h3>
|
||||
<p class="empty-sub">Add your first title to get started.</p>
|
||||
<button class="btn-primary" id="openAddModalEmpty">
|
||||
<svg width="16" height="16" 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';
|
||||
|
||||
// For badge label
|
||||
const forLabels = { all: 'All', nik: 'Nik', tod: 'Tod' };
|
||||
const forLabel = forLabels[item.for_whom] || 'All';
|
||||
const showForBadge = item.for_whom && item.for_whom !== 'all';
|
||||
|
||||
// Format date
|
||||
const dateStr = item.watched_at
|
||||
? 'Watched ' + formatDate(item.watched_at)
|
||||
: 'Added ' + formatDate(item.created_at);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card' + (watched ? ' watched' : '');
|
||||
card.dataset.id = item.id;
|
||||
|
||||
card.innerHTML = `
|
||||
<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>
|
||||
${showForBadge ? `<span class="badge badge-genre">${escHtml(forLabel)}</span>` : ''}
|
||||
${item.streaming_service ? `<span class="badge badge-service">${escHtml(item.streaming_service)}</span>` : ''}
|
||||
</div>
|
||||
<h3 class="card-title">${escHtml(item.title)}</h3>
|
||||
${item.genre ? `<p class="card-genre">${escHtml(item.genre)}</p>` : ''}
|
||||
${item.notes ? `<p class="card-notes">${escHtml(item.notes)}</p>` : ''}
|
||||
<span class="card-date">${dateStr}</span>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button class="btn-watch ${watched ? 'btn-unwatch' : ''}" data-id="${escAttr(item.id)}">
|
||||
${watched
|
||||
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="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"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> Watch'
|
||||
}
|
||||
</button>
|
||||
<div class="card-actions">
|
||||
<button class="btn-icon btn-edit" title="Edit" data-id="${escAttr(item.id)}">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="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" title="Remove" data-id="${escAttr(item.id)}" data-title="${escAttr(item.title)}">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></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.watched ? 'Marked as watched!' : 'Moved back to watchlist');
|
||||
loadItems();
|
||||
} catch (err) {
|
||||
toast('Failed to update', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* Open Add Modal */
|
||||
function openAdd() {
|
||||
editId.value = '';
|
||||
addForm.reset();
|
||||
selectedType = 'movie';
|
||||
selectedForWhom = 'all';
|
||||
updateTypeToggle();
|
||||
updateForToggle();
|
||||
modalTitle.textContent = 'Add Title';
|
||||
submitBtn.textContent = 'Add to Watchlist';
|
||||
showModal(true);
|
||||
}
|
||||
|
||||
/* Open Edit Modal */
|
||||
function openEdit(item) {
|
||||
editId.value = item.id;
|
||||
inputTitle.value = item.title;
|
||||
inputService.value = item.streaming_service || '';
|
||||
inputGenre.value = item.genre || '';
|
||||
inputNotes.value = item.notes || '';
|
||||
selectedType = item.type || 'movie';
|
||||
selectedForWhom = item.for_whom || 'all';
|
||||
updateTypeToggle();
|
||||
updateForToggle();
|
||||
modalTitle.textContent = 'Edit Title';
|
||||
submitBtn.textContent = 'Save Changes';
|
||||
showModal(true);
|
||||
}
|
||||
|
||||
function showModal(on) {
|
||||
modalOverlay.classList.toggle('hidden', !on);
|
||||
if (on) setTimeout(() => inputTitle.focus(), 50);
|
||||
}
|
||||
|
||||
/* Type toggle (modal) */
|
||||
function updateTypeToggle() {
|
||||
document.querySelectorAll('.type-option').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.typeVal === selectedType);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.type-option').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
selectedType = btn.dataset.typeVal;
|
||||
updateTypeToggle();
|
||||
});
|
||||
});
|
||||
|
||||
/* For toggle (modal) */
|
||||
function updateForToggle() {
|
||||
document.querySelectorAll('.for-option').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.forVal === selectedForWhom);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.for-option').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
selectedForWhom = btn.dataset.forVal;
|
||||
updateForToggle();
|
||||
});
|
||||
});
|
||||
|
||||
/* Form submit */
|
||||
addForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const title = inputTitle.value.trim();
|
||||
if (!title) { toast('Title is required', 'error'); return; }
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = editId.value ? 'Saving...' : 'Adding...';
|
||||
|
||||
const payload = {
|
||||
action: editId.value ? 'edit' : 'add',
|
||||
id: editId.value ? parseInt(editId.value) : undefined,
|
||||
title,
|
||||
type: selectedType,
|
||||
for_whom: selectedForWhom,
|
||||
streaming_service: inputService.value.trim(),
|
||||
genre: inputGenre.value.trim(),
|
||||
notes: inputNotes.value.trim(),
|
||||
};
|
||||
|
||||
try {
|
||||
const data = await api({}, payload);
|
||||
if (!data.success) throw new Error(data.error);
|
||||
showModal(false);
|
||||
toast(editId.value ? 'Title updated!' : 'Added to watchlist!');
|
||||
loadItems();
|
||||
} catch (err) {
|
||||
toast(err.message || 'Something went wrong', 'error');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = editId.value ? 'Save Changes' : 'Add to Watchlist';
|
||||
}
|
||||
});
|
||||
|
||||
/* Delete confirm */
|
||||
let pendingDeleteId = null;
|
||||
|
||||
function openDeleteConfirm(id, title) {
|
||||
pendingDeleteId = id;
|
||||
deleteTitle.textContent = title;
|
||||
deleteOverlay.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeDelete() {
|
||||
pendingDeleteId = null;
|
||||
deleteOverlay.classList.add('hidden');
|
||||
}
|
||||
|
||||
confirmDelete.addEventListener('click', async () => {
|
||||
if (!pendingDeleteId) return;
|
||||
try {
|
||||
const data = await api({}, { action: 'delete', id: pendingDeleteId });
|
||||
if (!data.success) throw new Error(data.error);
|
||||
closeDelete();
|
||||
toast('Title removed');
|
||||
loadItems();
|
||||
} catch (err) {
|
||||
toast('Failed to delete', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
closeDeleteModal.addEventListener('click', closeDelete);
|
||||
cancelDelete.addEventListener('click', closeDelete);
|
||||
|
||||
/* Modal close */
|
||||
closeModal.addEventListener('click', () => showModal(false));
|
||||
cancelModal.addEventListener('click', () => showModal(false));
|
||||
modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) showModal(false); });
|
||||
|
||||
/* Header Add button */
|
||||
openAddModal.addEventListener('click', openAdd);
|
||||
|
||||
/* Filter pills */
|
||||
filterGroup.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.pill');
|
||||
if (!btn) return;
|
||||
filterGroup.querySelectorAll('.pill').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
state.filter = btn.dataset.filter;
|
||||
loadItems();
|
||||
});
|
||||
|
||||
/* Type filter pills */
|
||||
typeGroup.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.type-pill');
|
||||
if (!btn) return;
|
||||
typeGroup.querySelectorAll('.type-pill').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
state.type = btn.dataset.type;
|
||||
loadItems();
|
||||
});
|
||||
|
||||
/* For filter pills */
|
||||
forGroup.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.for-pill');
|
||||
if (!btn) return;
|
||||
forGroup.querySelectorAll('.for-pill').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
state.forWhom = btn.dataset.for;
|
||||
loadItems();
|
||||
});
|
||||
|
||||
/* Search */
|
||||
let searchTimer;
|
||||
searchInput.addEventListener('input', () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
state.search = searchInput.value.trim();
|
||||
loadItems();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
/* Toast */
|
||||
function toast(msg, type = 'default') {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast toast-' + type;
|
||||
el.textContent = msg;
|
||||
toastContainer.appendChild(el);
|
||||
requestAnimationFrame(() => el.classList.add('show'));
|
||||
setTimeout(() => {
|
||||
el.classList.remove('show');
|
||||
setTimeout(() => el.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
/* Helpers */
|
||||
function escHtml(str) {
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escAttr(str) {
|
||||
return String(str ?? '').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
/* URL State Management */
|
||||
function readUrlParams() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
if (params.has('filter')) state.filter = params.get('filter');
|
||||
if (params.has('type')) state.type = params.get('type');
|
||||
if (params.has('for')) state.forWhom = params.get('for');
|
||||
if (params.has('service')) state.service = params.get('service');
|
||||
if (params.has('search')) state.search = params.get('search');
|
||||
|
||||
// Sync pill UI to state
|
||||
filterGroup.querySelectorAll('.pill').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.filter === state.filter);
|
||||
});
|
||||
typeGroup.querySelectorAll('.type-pill').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.type === state.type);
|
||||
});
|
||||
forGroup.querySelectorAll('.for-pill').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.for === state.forWhom);
|
||||
});
|
||||
if (state.search) searchInput.value = state.search;
|
||||
}
|
||||
|
||||
function pushUrlState() {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (state.filter && state.filter !== 'unwatched') params.set('filter', state.filter);
|
||||
if (state.type) params.set('type', state.type);
|
||||
if (state.forWhom) params.set('for', state.forWhom);
|
||||
if (state.service) params.set('service', state.service);
|
||||
if (state.search) params.set('search', state.search);
|
||||
|
||||
const qs = params.toString();
|
||||
const url = window.location.pathname + (qs ? '?' + qs : '');
|
||||
history.replaceState(null, '', url);
|
||||
}
|
||||
|
||||
/* Handle browser back/forward */
|
||||
window.addEventListener('popstate', () => {
|
||||
readUrlParams();
|
||||
loadItems();
|
||||
});
|
||||
|
||||
/* Init */
|
||||
readUrlParams();
|
||||
loadItems();
|
||||
|
||||
/* =========================================================
|
||||
THEME TOGGLE
|
||||
========================================================= */
|
||||
|
||||
function getCookie(name) {
|
||||
const match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/([.*+?^=!:${}()|[\]/\\])/g, '\\$1') + '=([^;]*)'));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function setCookie(name, value, days) {
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||
document.cookie = name + '=' + encodeURIComponent(value) + '; expires=' + expires + '; path=/; SameSite=Lax';
|
||||
}
|
||||
|
||||
function applyTheme(dark) {
|
||||
if (dark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const newTheme = isDark ? 'light' : 'dark';
|
||||
applyTheme(newTheme === 'dark');
|
||||
setCookie('theme', newTheme, 365);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply saved theme on load (default: light)
|
||||
applyTheme(getCookie('theme') === 'dark');
|
||||
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);
|
||||
214
index.php
Normal file
214
index.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<!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?v=7" />
|
||||
<script>
|
||||
// Apply dark mode immediately before render to avoid flash
|
||||
(function() {
|
||||
function getCookie(name) {
|
||||
const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
if (getCookie('theme') === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</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>
|
||||
<div class="header-right">
|
||||
<!-- Dark mode toggle -->
|
||||
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark mode" title="Toggle dark mode">
|
||||
<!-- Sun icon (shown in dark mode) -->
|
||||
<svg class="icon-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
<!-- Moon icon (shown in light mode) -->
|
||||
<svg class="icon-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</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" data-filter="all">All</button>
|
||||
<button class="pill active" 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="pill-group" id="forGroup">
|
||||
<button class="for-pill active" data-for="">All</button>
|
||||
<button class="for-pill" data-for="nik">Nik</button>
|
||||
<button class="for-pill" data-for="tod">Tod</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 titlesâ¦" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SERVICE ROW -->
|
||||
<div class="service-row" id="serviceRow"></div>
|
||||
|
||||
<!-- LOADING -->
|
||||
<div class="loading-state" id="loadingState" style="display:none">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<!-- GRID -->
|
||||
<div class="grid" id="grid"></div>
|
||||
|
||||
</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" aria-label="Close">
|
||||
<svg width="18" height="18" 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 class="modal-form" id="addForm" autocomplete="off">
|
||||
<input type="hidden" id="editId" />
|
||||
|
||||
<!-- Type toggle -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">Type</label>
|
||||
<div class="type-options" id="typeOptions">
|
||||
<button type="button" class="type-option active" data-type="movie">ð¬ Movie</button>
|
||||
<button type="button" class="type-option" data-type="show">ðº Show</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- For whom toggle -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">For</label>
|
||||
<div class="for-options" id="forOptions">
|
||||
<button type="button" class="for-option" data-for="nik">Nik</button>
|
||||
<button type="button" class="for-option" data-for="tod">Tod</button>
|
||||
<button type="button" class="for-option active" data-for="all">Both</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="inputTitle">Title <span class="required">*</span></label>
|
||||
<input type="text" class="form-input" id="inputTitle" placeholder="e.g. Oppenheimer" required />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="inputService">Service</label>
|
||||
<input type="text" class="form-input" id="inputService" placeholder="e.g. Netflix" list="serviceList" />
|
||||
<datalist id="serviceList"></datalist>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="inputGenre">Genre</label>
|
||||
<input type="text" class="form-input" id="inputGenre" placeholder="e.g. Drama" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="inputNotes">Notes</label>
|
||||
<textarea class="form-input form-textarea" id="inputNotes" placeholder="Any thoughtsâ¦" rows="3"></textarea>
|
||||
</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" aria-label="Close">
|
||||
<svg width="18" height="18" 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>
|
||||
<div class="modal-form">
|
||||
<p class="delete-msg">Remove <strong id="deleteTitle"></strong> from your watchlist?</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-ghost" id="cancelDelete">Cancel</button>
|
||||
<button type="button" class="btn-danger" id="confirmDelete">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOAST CONTAINER -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<script src="app.js?v=6"></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
|
||||
)");
|
||||
5
migrations/002_add_for_whom_to_watchlist.php
Normal file
5
migrations/002_add_for_whom_to_watchlist.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
$cols = $pdo->query("SHOW COLUMNS FROM watchlist LIKE 'for_whom'")->fetchAll();
|
||||
if (empty($cols)) {
|
||||
$pdo->exec("ALTER TABLE watchlist ADD COLUMN for_whom ENUM('all', 'nik', 'tod') NOT NULL DEFAULT 'all' AFTER type");
|
||||
}
|
||||
Reference in New Issue
Block a user