Deploy from Lumerel

This commit is contained in:
Lumerel Deploy
2026-03-22 22:36:40 +00:00
commit 720a35af34
8 changed files with 499 additions and 0 deletions

54
.docker/99-migrate.sh Normal file
View 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
View File

@@ -0,0 +1,3 @@
.git
Dockerfile
.dockerignore

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM webdevops/php-nginx:8.3-alpine
ENV WEB_DOCUMENT_ROOT=/app
ARG CACHE_BUST=1774219000
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
View 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()]);
}

14
db.php Normal file
View 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);

178
index.php Normal file
View File

@@ -0,0 +1,178 @@
<!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" />
<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>
<p>Loading…</p>
</div>
</main>
<!-- EMPTY STATE -->
<div class="empty hidden" id="emptyState">
<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>
</div>
</div>
<!-- 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-row">
<div class="form-field">
<label class="form-label">Type</label>
<div class="toggle-group">
<button type="button" class="toggle-btn active" data-val="movie">🎬 Movie</button>
<button type="button" class="toggle-btn" data-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>
<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">
<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"></script>
</body>
</html>

25
migrate.php Normal file
View 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";

View 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
)");