ChurchTube v3.1: Admin Logs, User Avatars, Timestamped Bookmarks, and Mobile Fixes

This commit is contained in:
Michael Howard 2026-04-29 22:19:01 -05:00
parent e7c9ea5386
commit 05175ac03b
20 changed files with 668 additions and 192 deletions

View File

@ -6,19 +6,23 @@ ChurchTube is a premium, self-hosted video platform designed specifically for ch
## ✨ Features ## ✨ Features
- **Premium UX**: Modern, responsive design with glassmorphism and smooth animations. - **Premium UX**: Modern, responsive design with forced **Dark Mode** and glassmorphism.
- **Dual Video Sources**: Upload videos directly or link them from external sources (NAS, Google Drive, Cloud). - **Dual Video Sources**: Upload videos directly or link them from external sources (NAS, Google Drive, Cloud).
- **Smart Google Drive Support**: Automatically converts Google Drive links for seamless embedding. - **Smart Google Drive Support**: Automatically converts Google Drive links for seamless embedding and mobile-friendly controls.
- **User Identity**:
- Custom **User Avatars** for a more personal community experience.
- Profile management for passwords and identity.
- **Timestamped Bookmarks**: Save the exact second of a sermon and jump back to it later from your profile.
- **Interactive Community**: - **Interactive Community**:
- AJAX-based commenting (no video reloads). - AJAX-based commenting (no video reloads).
- 5 reaction types (👍, ❤️, 🙏, 💡, 👏). - 5 reaction types (👍, ❤️, 🙏, 💡, 👏).
- Automated Profanity Filter with auto-reporting. - Automated Profanity Filter with auto-reporting.
- **Robust Moderation**: - Users can delete their own comments.
- **Administrative Accountability**:
- **System Logs**: Track logins, failed attempts, video plays, and comment history with IP address auditing.
- Role-Based Access Control (Admin, Moderator, Editor, User). - Role-Based Access Control (Admin, Moderator, Editor, User).
- Dedicated Admin/Moderator dashboard for reports and users.
- **Custom Branding**: Real-time control over site title, colors, logo, and footer. - **Custom Branding**: Real-time control over site title, colors, logo, and footer.
- **Search & Discovery**: Tag-based categorization, search, and intelligent recommendations. - **Search & Discovery**: Keyword search, intelligent recommendations, and pagination.
- **Analytics**: Engagement-based view counting (only counts when video is played).
## 🛠️ Technology Stack ## 🛠️ Technology Stack

View File

@ -85,6 +85,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$pdo->prepare("INSERT IGNORE INTO video_tags (video_id, tag_id) VALUES (?, ?)")->execute([$id, $tag_id]); $pdo->prepare("INSERT IGNORE INTO video_tags (video_id, tag_id) VALUES (?, ?)")->execute([$id, $tag_id]);
} }
} }
cleanupTags();
$success = "Video updated successfully!"; $success = "Video updated successfully!";
// Refresh video data // Refresh video data
$stmt = $pdo->prepare("SELECT * FROM videos WHERE id = ?"); $stmt = $pdo->prepare("SELECT * FROM videos WHERE id = ?");

View File

@ -15,6 +15,7 @@ if (isset($_GET['delete'])) {
} }
$pdo->prepare("DELETE FROM videos WHERE id = ?")->execute([$id]); $pdo->prepare("DELETE FROM videos WHERE id = ?")->execute([$id]);
cleanupTags();
header('Location: index.php?msg=deleted'); header('Location: index.php?msg=deleted');
exit; exit;
} }
@ -51,6 +52,10 @@ echo str_replace(['assets/', 'index.php', 'login.php', 'logout.php', 'admin/'],
<span style="position: absolute; top: -5px; right: -5px; background: #ff4081; width: 20px; height: 20px; border-radius: 50%; font-size: 0.7rem; display: flex; align-items: center; justify-content: center; color: white;">!</span> <span style="position: absolute; top: -5px; right: -5px; background: #ff4081; width: 20px; height: 20px; border-radius: 50%; font-size: 0.7rem; display: flex; align-items: center; justify-content: center; color: white;">!</span>
<?php endif; ?> <?php endif; ?>
</a> </a>
<a href="logs.php" class="btn" style="background: var(--bg-card); border: 1px solid var(--glass-border); flex-direction: column; padding: 20px;">
<i class="fas fa-list-ul" style="font-size: 1.5rem; margin-bottom: 10px;"></i>
System Logs
</a>
</div> </div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px;">

80
admin/logs.php Normal file
View File

@ -0,0 +1,80 @@
<?php
require_once '../includes/db.php';
require_once '../includes/auth.php';
requireAdmin();
$type_filter = $_GET['type'] ?? '';
$query = "SELECT l.*, u.username FROM logs l LEFT JOIN users u ON l.user_id = u.id";
$params = [];
if ($type_filter) {
$query .= " WHERE l.type = ?";
$params = [$type_filter];
}
$query .= " ORDER BY l.created_at DESC LIMIT 100";
$stmt = $pdo->prepare($query);
$stmt->execute($params);
$logs = $stmt->fetchAll();
ob_start();
require_once '../includes/header.php';
$header = ob_get_clean();
echo str_replace(['assets/', 'index.php', 'login.php', 'logout.php', 'admin/'], ['../assets/', '../index.php', '../login.php', '../logout.php', './'], $header);
?>
<div style="max-width: 1200px; margin: 40px auto; padding: 0 24px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px;">
<h1>System Logs</h1>
<div style="display: flex; gap: 12px;">
<select onchange="window.location.href='?type='+this.value" class="btn" style="background: var(--glass); color: var(--text-main); border: 1px solid var(--glass-border);">
<option value="">All Logs</option>
<option value="auth" <?= $type_filter === 'auth' ? 'selected' : '' ?>>Auth</option>
<option value="play" <?= $type_filter === 'play' ? 'selected' : '' ?>>Plays</option>
<option value="comment" <?= $type_filter === 'comment' ? 'selected' : '' ?>>Comments</option>
<option value="error" <?= $type_filter === 'error' ? 'selected' : '' ?>>Errors</option>
<option value="system" <?= $type_filter === 'system' ? 'selected' : '' ?>>System</option>
</select>
<a href="index.php" class="btn" style="background: var(--glass);">Back to Dashboard</a>
</div>
</div>
<div style="background: var(--bg-card); border-radius: 16px; border: 1px solid var(--glass-border); overflow: hidden;">
<table style="width: 100%; border-collapse: collapse; text-align: left;">
<thead>
<tr style="background: var(--glass); color: var(--text-muted); font-size: 0.85rem; text-transform: uppercase;">
<th style="padding: 16px;">Time</th>
<th style="padding: 16px;">Type</th>
<th style="padding: 16px;">User</th>
<th style="padding: 16px;">Message</th>
<th style="padding: 16px;">IP Address</th>
</tr>
</thead>
<tbody>
<?php foreach ($logs as $l): ?>
<tr style="border-bottom: 1px solid var(--glass-border);">
<td style="padding: 16px; font-size: 0.85rem; color: var(--text-muted);"><?= date('M d, H:i:s', strtotime($l['created_at'])) ?></td>
<td style="padding: 16px;">
<span style="padding: 4px 8px; border-radius: 4px; font-size: 0.75rem; background: <?=
$l['type'] === 'error' ? '#ff4081' : (
$l['type'] === 'auth' ? '#7c4dff' : (
$l['type'] === 'play' ? '#4caf50' : 'var(--glass)')) ?>;">
<?= strtoupper($l['type']) ?>
</span>
</td>
<td style="padding: 16px;"><?= htmlspecialchars($l['username'] ?? 'Guest') ?></td>
<td style="padding: 16px; font-size: 0.9rem;"><?= htmlspecialchars($l['message']) ?></td>
<td style="padding: 16px; font-size: 0.85rem; color: var(--text-muted);"><?= htmlspecialchars($l['ip_address']) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($logs)): ?>
<tr>
<td colspan="5" style="padding: 40px; text-align: center; color: var(--text-muted);">No logs found.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php require_once '../includes/footer.php'; ?>

View File

@ -4,20 +4,35 @@ require_once '../includes/auth.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
if (!isModerator()) { if (!isLoggedIn()) {
echo json_encode(['success' => false, 'error' => 'Moderator privileges required']); echo json_encode(['success' => false, 'error' => 'Login required']);
exit; exit;
} }
$comment_id = (int)($_POST['comment_id'] ?? 0); $comment_id = (int)($_POST['comment_id'] ?? 0);
if (!$comment_id) { if (!$comment_id) {
echo json_encode(['success' => false, 'error' => 'Invalid data']); echo json_encode(['success' => false, 'error' => 'Invalid data']);
exit; exit;
} }
try { try {
// Check ownership or moderator status
$stmt = $pdo->prepare("SELECT user_id FROM comments WHERE id = ?");
$stmt->execute([$comment_id]);
$comment = $stmt->fetch();
if (!$comment) {
echo json_encode(['success' => false, 'error' => 'Comment not found']);
exit;
}
if ($comment['user_id'] != $_SESSION['user_id'] && !isModerator()) {
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
$pdo->prepare("DELETE FROM comments WHERE id = ?")->execute([$comment_id]); $pdo->prepare("DELETE FROM comments WHERE id = ?")->execute([$comment_id]);
logEvent('comment', "Comment deleted: ID $comment_id by user " . $_SESSION['username']);
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
} catch (Exception $e) { } catch (Exception $e) {
echo json_encode(['success' => false, 'error' => 'DB error']); echo json_encode(['success' => false, 'error' => 'DB error']);

View File

@ -12,7 +12,7 @@ if (!$video_id) {
try { try {
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT c.*, u.username, SELECT c.*, u.username, u.avatar_url,
(SELECT COUNT(*) FROM reactions WHERE comment_id = c.id AND reaction_type = 'thumb') as thumbs, (SELECT COUNT(*) FROM reactions WHERE comment_id = c.id AND reaction_type = 'thumb') as thumbs,
(SELECT COUNT(*) FROM reactions WHERE comment_id = c.id AND reaction_type = 'heart') as hearts, (SELECT COUNT(*) FROM reactions WHERE comment_id = c.id AND reaction_type = 'heart') as hearts,
(SELECT COUNT(*) FROM reactions WHERE comment_id = c.id AND reaction_type = 'pray') as prays, (SELECT COUNT(*) FROM reactions WHERE comment_id = c.id AND reaction_type = 'pray') as prays,

View File

@ -13,6 +13,13 @@ if (!$video_id) {
try { try {
$stmt = $pdo->prepare("UPDATE videos SET views = views + 1 WHERE id = ?"); $stmt = $pdo->prepare("UPDATE videos SET views = views + 1 WHERE id = ?");
$stmt->execute([$video_id]); $stmt->execute([$video_id]);
// Log the play event
$v_stmt = $pdo->prepare("SELECT title FROM videos WHERE id = ?");
$v_stmt->execute([$video_id]);
$title = $v_stmt->fetchColumn();
logEvent('play', "Started watching: $title (ID: $video_id)");
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
} catch (Exception $e) { } catch (Exception $e) {
echo json_encode(['success' => false, 'error' => 'Database error']); echo json_encode(['success' => false, 'error' => 'Database error']);

View File

@ -33,6 +33,11 @@ foreach ($bad_words as $word) {
try { try {
$stmt = $pdo->prepare("INSERT INTO comments (video_id, user_id, comment_text, is_reported) VALUES (?, ?, ?, ?)"); $stmt = $pdo->prepare("INSERT INTO comments (video_id, user_id, comment_text, is_reported) VALUES (?, ?, ?, ?)");
if ($stmt->execute([$video_id, $_SESSION['user_id'], $filtered_text, $is_flagged ? 1 : 0])) { if ($stmt->execute([$video_id, $_SESSION['user_id'], $filtered_text, $is_flagged ? 1 : 0])) {
// Log the comment
$v_stmt = $pdo->prepare("SELECT title FROM videos WHERE id = ?");
$v_stmt->execute([$video_id]);
$title = $v_stmt->fetchColumn();
logEvent('comment', "Commented on $title: $filtered_text" . ($is_flagged ? " [FLAGGED]" : ""));
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
} else { } else {
echo json_encode(['success' => false, 'error' => 'Database error']); echo json_encode(['success' => false, 'error' => 'Database error']);

22
api/toggle_bookmark.php Normal file
View File

@ -0,0 +1,22 @@
<?php
require_once '../includes/db.php';
require_once '../includes/auth.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isLoggedIn()) {
$video_id = (int)$_POST['video_id'];
$timestamp = (float)($_POST['timestamp'] ?? 0);
$user_id = $_SESSION['user_id'];
$stmt = $pdo->prepare("SELECT id FROM bookmarks WHERE user_id = ? AND video_id = ?");
$stmt->execute([$user_id, $video_id]);
$bookmark = $stmt->fetch();
if ($bookmark) {
$pdo->prepare("DELETE FROM bookmarks WHERE id = ?")->execute([$bookmark['id']]);
echo json_encode(['success' => true, 'action' => 'removed']);
} else {
$pdo->prepare("INSERT INTO bookmarks (user_id, video_id, video_timestamp) VALUES (?, ?, ?)")->execute([$user_id, $video_id, $timestamp]);
echo json_encode(['success' => true, 'action' => 'added']);
}
}
?>

11
api/toggle_theme.php Normal file
View File

@ -0,0 +1,11 @@
<?php
require_once '../includes/db.php';
require_once '../includes/auth.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isLoggedIn()) {
$theme = $_POST['theme'] === 'light' ? 'light' : 'dark';
$stmt = $pdo->prepare("UPDATE users SET theme_preference = ? WHERE id = ?");
$stmt->execute([$theme, $_SESSION['user_id']]);
echo json_encode(['success' => true]);
}
?>

View File

@ -1,87 +0,0 @@
<?php
// ChurchTube Diagnostics Script
function check_status($condition, $success, $failure) {
if ($condition) {
echo "<li style='color: #4CAF50;'>[PASS] $success</li>";
} else {
echo "<li style='color: #f44336;'>[FAIL] $failure</li>";
return false;
}
return true;
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ChurchTube Diagnostics</title>
<link rel="stylesheet" href="assets/css/style.css">
<style>
.diag-box { background: #1e1e1e; padding: 30px; border-radius: 12px; margin-top: 50px; border: 1px solid #333; }
ul { list-style: none; padding: 0; }
li { padding: 12px; border-bottom: 1px solid #333; font-family: monospace; }
.advice { background: rgba(124, 77, 255, 0.1); padding: 15px; border-radius: 8px; margin-top: 20px; border-left: 4px solid var(--primary-color); }
</style>
</head>
<body>
<div class="centered-container" style="max-width: 800px;">
<h1 class="logo">ChurchTube Diagnostics</h1>
<div class="diag-box">
<h3>System Health Check</h3>
<ul>
<?php
// PHP Version
check_status(version_compare(PHP_VERSION, '7.4.0', '>='),
"PHP Version: " . PHP_VERSION,
"PHP Version: " . PHP_VERSION . " (Requires 7.4+)");
// Extensions
check_status(extension_loaded('pdo_mysql'), "PDO MySQL extension is loaded.", "PDO MySQL extension is MISSING.");
check_status(extension_loaded('curl'), "CURL extension is loaded.", "CURL extension is MISSING (required for external link validation).");
// Config File
$has_config = file_exists('includes/config.php');
if (check_status($has_config, "Configuration file exists.", "Configuration file (includes/config.php) is MISSING.")) {
require_once 'includes/config.php';
// DB Connection
try {
$pdo = new PDO("mysql:host=".DB_HOST.";dbname=".DB_NAME, DB_USER, DB_PASS);
check_status(true, "Database connection established successfully.", "");
} catch (Exception $e) {
check_status(false, "", "Database connection failed: " . $e->getMessage());
}
}
// Write Permissions
check_status(is_writable('uploads'), "Uploads directory is writable.", "Uploads directory is NOT writable. Run: chmod 777 uploads");
// PHP Settings
$upload_max = ini_get('upload_max_filesize');
$post_max = ini_get('post_max_size');
$is_low = (int)$upload_max < 100 || (int)$post_max < 100;
if ($is_low) {
echo "<li style='color: #ffab40;'>[WARN] Max Upload Size: $upload_max (Post Max: $post_max) - This is low for videos!</li>";
} else {
echo "<li style='color: #4CAF50;'>[PASS] Max Upload Size: $upload_max (Post Max: $post_max)</li>";
}
?>
</ul>
<div class="advice">
<h4>Pro-Tips & Fixes:</h4>
<ul style="border:none; margin-top: 10px;">
<li><strong>MySQL Issues?</strong> Run: <code>sudo systemctl status mysql</code>. If stopped, run <code>sudo systemctl start mysql</code>.</li>
<li><strong>Upload Limits?</strong> Edit <code>/etc/php/7.4/apache2/php.ini</code>. Look for <code>upload_max_filesize</code> and <code>post_max_size</code>. Set them to <code>500M</code> or more, then run <code>sudo systemctl restart apache2</code>.</li>
<li><strong>Permission Issues?</strong> Run: <code>sudo chmod -R 777 uploads/</code> to ensure the web server can save video files.</li>
</ul>
</div>
</div>
<div style="margin-top: 20px; text-align: center;">
<a href="index.php" class="btn btn-primary">Back to Site</a>
</div>
</div>
</body>
</html>

View File

@ -5,6 +5,16 @@ try {
$pdo = new PDO("mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4", DB_USER, DB_PASS); $pdo = new PDO("mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4", DB_USER, DB_PASS);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
require_once 'functions.php';
function cleanupTags() {
global $pdo;
try {
$pdo->exec("DELETE FROM tags WHERE id NOT IN (SELECT tag_id FROM video_tags)");
} catch (Exception $e) {
// Fail silently
}
}
} catch (PDOException $e) { } catch (PDOException $e) {
die("Database Connection Error: " . $e->getMessage()); die("Database Connection Error: " . $e->getMessage());
} }

22
includes/functions.php Normal file
View File

@ -0,0 +1,22 @@
<?php
function logEvent($type, $message) {
global $pdo;
try {
$user_id = $_SESSION['user_id'] ?? null;
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ip = trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
} elseif (isset($_SERVER['HTTP_X_REAL_IP'])) {
$ip = $_SERVER['HTTP_X_REAL_IP'];
} elseif (isset($_SERVER['HTTP_CLIENT_IP'])) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
}
$stmt = $pdo->prepare("INSERT INTO logs (user_id, type, message, ip_address) VALUES (?, ?, ?, ?)");
$stmt->execute([$user_id, $type, $message, $ip]);
} catch (Exception $e) {
// Fail silently
}
}
?>

View File

@ -19,23 +19,74 @@ $logo_url = get_setting('logo_url', '');
<style> <style>
:root { :root {
--primary-color: <?= $primary_color ?>; --primary-color: <?= $primary_color ?>;
--primary-rgb: <?= hexToRgb($primary_color) ?>;
--secondary-color: <?= $secondary_color ?>; --secondary-color: <?= $secondary_color ?>;
/* Dark Theme (Only) */
--bg-main: #0f0f0f;
--bg-card: #1a1a1a;
--glass: rgba(255, 255, 255, 0.05);
--glass-border: rgba(255, 255, 255, 0.1);
--text-main: #ffffff;
--text-muted: #aaaaaa;
} }
@media (max-width: 600px) { @media (max-width: 600px) {
header {
display: flex !important;
flex-wrap: wrap !important;
height: auto !important;
padding: 10px 16px !important;
gap: 10px !important;
position: relative !important;
justify-content: space-between !important;
}
.logo { .logo {
font-size: 1.2rem !important; font-size: 1.1rem !important;
max-width: 150px; margin: 0 !important;
flex: 1;
max-width: 60%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.search-bar { .user-actions {
display: none !important; width: auto !important;
order: 2 !important;
gap: 8px !important;
} }
.search-bar {
display: flex !important;
width: 100% !important;
height: 36px !important;
margin: 0 !important;
order: 3 !important;
padding: 0 12px !important;
}
.btn {
padding: 4px 10px !important;
font-size: 0.8rem !important;
}
.hide-mobile { display: none; }
} }
</style> </style>
</head> </head>
<body> <body class="theme-dark">
<?php
function hexToRgb($hex) {
$hex = str_replace("#", "", $hex);
if(strlen($hex) == 3) {
$r = hexdec(substr($hex,0,1).substr($hex,0,1));
$g = hexdec(substr($hex,1,1).substr($hex,1,1));
$b = hexdec(substr($hex,2,1).substr($hex,2,1));
} else {
$r = hexdec(substr($hex,0,2));
$g = hexdec(substr($hex,2,2));
$b = hexdec(substr($hex,4,2));
}
return "$r, $g, $b";
}
?>
<header> <header>
<a href="index.php" class="logo"> <a href="index.php" class="logo">
<?php if ($logo_url): ?> <?php if ($logo_url): ?>
@ -53,18 +104,32 @@ $logo_url = get_setting('logo_url', '');
</form> </form>
<div class="user-actions" style="display: flex; gap: 16px; align-items: center;"> <div class="user-actions" style="display: flex; gap: 16px; align-items: center;">
<?php if (isEditor()): ?> <?php if (isLoggedIn()):
<a href="admin/index.php" class="btn btn-primary" style="padding: 8px 16px; font-size: 0.9rem;"> $hdr_avatar = '';
<i class="fas fa-plus"></i> Admin try {
</a> $stmt = $pdo->prepare("SELECT avatar_url FROM users WHERE id = ?");
<?php endif; ?> $stmt->execute([$_SESSION['user_id']]);
$hdr_avatar = $stmt->fetchColumn();
<?php if (isLoggedIn()): ?> } catch (Exception $e) {
<span style="color: var(--text-muted);">Hi, <?= htmlspecialchars($_SESSION['username']) ?></span> // Avatar column might not exist yet if migration wasn't run
<a href="logout.php" title="Logout"><i class="fas fa-sign-out-alt"></i></a> }
?>
<a href="profile.php" style="width: 32px; height: 32px; border-radius: 50%; overflow: hidden; background: var(--primary-color); display: flex; align-items: center; justify-content: center; border: 2px solid var(--glass-border);">
<?php if ($hdr_avatar): ?>
<img src="<?= htmlspecialchars($hdr_avatar) ?>" style="width: 100%; height: 100%; object-fit: cover;">
<?php else: ?> <?php else: ?>
<a href="login.php" class="btn btn-primary" style="padding: 8px 16px; font-size: 0.9rem;">Login</a> <span style="color: white; font-size: 0.8rem;"><?= strtoupper(substr($_SESSION['username'], 0, 1)) ?></span>
<?php endif; ?>
</a>
<?php if (isEditor()): ?>
<a href="admin/index.php" class="btn" style="background: var(--glass);"><i class="fas fa-cog"></i> <span class="hide-mobile">Admin</span></a>
<?php endif; ?>
<a href="logout.php" class="btn btn-primary">Logout</a>
<?php else: ?>
<a href="login.php" class="btn" style="background: var(--glass);">Login</a>
<a href="register.php" class="btn btn-primary">Register</a>
<?php endif; ?> <?php endif; ?>
</div> </div>
</header> </header>
<main style="min-height: calc(100vh - 64px);"> <main>

View File

@ -1,13 +1,17 @@
<?php <?php
function get_setting($key, $default = '') { function get_setting($key, $default = '') {
global $pdo; global $pdo;
static $settings_cache = null;
if ($settings_cache === null) {
try { try {
$stmt = $pdo->prepare("SELECT setting_value FROM settings WHERE setting_key = ?"); $stmt = $pdo->query("SELECT setting_key, setting_value FROM settings");
$stmt->execute([$key]); $settings_cache = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
$res = $stmt->fetch();
return $res ? $res['setting_value'] : $default;
} catch (Exception $e) { } catch (Exception $e) {
return $default; $settings_cache = [];
} }
}
return isset($settings_cache[$key]) ? $settings_cache[$key] : $default;
} }
?> ?>

View File

@ -8,24 +8,43 @@ require_once 'includes/db.php';
require_once 'includes/settings_helper.php'; require_once 'includes/settings_helper.php';
require_once 'includes/header.php'; require_once 'includes/header.php';
$search = $_GET['q'] ?? ''; $page = isset($_GET['p']) ? (int)$_GET['p'] : 1;
$tag_filter = $_GET['tag'] ?? ''; $limit = 10;
$offset = ($page - 1) * $limit;
$query = "SELECT DISTINCT v.*, u.username as uploader FROM videos v $query = "SELECT DISTINCT v.*, u.username as uploader FROM videos v
JOIN users u ON v.uploader_id = u.id LEFT JOIN users u ON v.uploader_id = u.id
LEFT JOIN video_tags vt ON v.id = vt.video_id LEFT JOIN video_tags vt ON v.id = vt.video_id
LEFT JOIN tags t ON vt.tag_id = t.id"; LEFT JOIN tags t ON vt.tag_id = t.id";
$search = $_GET['q'] ?? '';
$tag_filter = $_GET['tag'] ?? '';
$params = []; $params = [];
$where_clauses = [];
if ($search) { if ($search) {
$query .= " WHERE (v.title LIKE ? OR v.description LIKE ? OR t.name LIKE ?)"; $where_clauses[] = "(v.title LIKE ? OR v.description LIKE ? OR t.name LIKE ?)";
$params = ["%$search%", "%$search%", "%$search%"]; $params = array_merge($params, ["%$search%", "%$search%", "%$search%"]);
} elseif ($tag_filter) { }
$query .= " WHERE t.name = ?"; if ($tag_filter) {
$params = [$tag_filter]; $where_clauses[] = "t.name = ?";
$params[] = $tag_filter;
} }
$query .= " ORDER BY v.release_date DESC, v.created_at DESC"; if (!empty($where_clauses)) {
$query .= " WHERE " . implode(" AND ", $where_clauses);
}
// Count total for pagination
$count_query = "SELECT COUNT(DISTINCT v.id) FROM videos v " .
"LEFT JOIN video_tags vt ON v.id = vt.video_id " .
"LEFT JOIN tags t ON vt.tag_id = t.id " .
(!empty($where_clauses) ? " WHERE " . implode(" AND ", $where_clauses) : "");
$total_stmt = $pdo->prepare($count_query);
$total_stmt->execute($params);
$total_count = $total_stmt->fetchColumn();
$total_pages = ceil($total_count / $limit);
$query .= " ORDER BY v.release_date DESC, v.created_at DESC LIMIT $limit OFFSET $offset";
$stmt = $pdo->prepare($query); $stmt = $pdo->prepare($query);
$stmt->execute($params); $stmt->execute($params);
$videos = $stmt->fetchAll(); $videos = $stmt->fetchAll();
@ -34,13 +53,6 @@ $videos = $stmt->fetchAll();
$popular_tags = $pdo->query("SELECT name FROM tags LIMIT 10")->fetchAll(PDO::FETCH_COLUMN); $popular_tags = $pdo->query("SELECT name FROM tags LIMIT 10")->fetchAll(PDO::FETCH_COLUMN);
?> ?>
<div style="padding: 12px 24px; display: flex; gap: 12px; overflow-x: auto; border-bottom: 1px solid var(--glass-border); margin-bottom: 20px;">
<a href="index.php" class="btn" style="background: <?= !$tag_filter ? 'var(--primary-color)' : 'var(--glass)' ?>; border-radius: 20px; font-size: 0.85rem; padding: 6px 16px;">All</a>
<?php foreach ($popular_tags as $ptag): ?>
<a href="index.php?tag=<?= urlencode($ptag) ?>" class="btn" style="background: <?= $tag_filter === $ptag ? 'var(--primary-color)' : 'var(--glass)' ?>; border-radius: 20px; font-size: 0.85rem; padding: 6px 16px;"><?= htmlspecialchars($ptag) ?></a>
<?php endforeach; ?>
</div>
<div class="video-grid"> <div class="video-grid">
<?php if (empty($videos)): ?> <?php if (empty($videos)): ?>
<div style="grid-column: 1/-1; text-align: center; padding: 100px; color: var(--text-muted);"> <div style="grid-column: 1/-1; text-align: center; padding: 100px; color: var(--text-muted);">
@ -54,12 +66,15 @@ $popular_tags = $pdo->query("SELECT name FROM tags LIMIT 10")->fetchAll(PDO::FET
<?php foreach ($videos as $video): ?> <?php foreach ($videos as $video): ?>
<a href="watch.php?id=<?= $video['id'] ?>" class="video-card"> <a href="watch.php?id=<?= $video['id'] ?>" class="video-card">
<div class="video-thumbnail" style="background-image: url('<?= $video['thumbnail_url'] ?: 'assets/images/default_thumb.png' ?>');"> <div class="video-thumbnail" style="background-image: url('<?= $video['thumbnail_url'] ?: 'assets/images/default_thumb.png' ?>');">
<!-- Placeholder thumbnail if none exists -->
</div> </div>
<div class="video-info"> <div class="video-info">
<h3 class="video-title"><?= htmlspecialchars($video['title']) ?></h3> <h3 class="video-title"><?= htmlspecialchars($video['title']) ?></h3>
<div style="font-size: 0.8rem; color: var(--text-muted); margin: 8px 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;">
<?= htmlspecialchars($video['description']) ?>
</div>
<div class="video-meta"> <div class="video-meta">
<span><?= htmlspecialchars($video['uploader']) ?></span> • <span><?= htmlspecialchars($video['uploader']) ?></span> •
<span><?= number_format($video['views']) ?> views</span> •
<span><?= date('M d, Y', strtotime($video['release_date'])) ?></span> <span><?= date('M d, Y', strtotime($video['release_date'])) ?></span>
</div> </div>
</div> </div>
@ -68,4 +83,20 @@ $popular_tags = $pdo->query("SELECT name FROM tags LIMIT 10")->fetchAll(PDO::FET
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php if ($total_pages > 1): ?>
<div style="display: flex; justify-content: center; gap: 12px; margin: 40px 0;">
<?php if ($page > 1): ?>
<a href="?p=<?= $page - 1 ?><?= $search ? "&q=".urlencode($search) : '' ?><?= $tag_filter ? "&tag=".urlencode($tag_filter) : '' ?>" class="btn" style="background: var(--glass);"><i class="fas fa-chevron-left"></i> Previous</a>
<?php endif; ?>
<?php for ($i = 1; $i <= $total_pages; $i++): ?>
<a href="?p=<?= $i ?><?= $search ? "&q=".urlencode($search) : '' ?><?= $tag_filter ? "&tag=".urlencode($tag_filter) : '' ?>" class="btn" style="background: <?= $i === $page ? 'var(--primary-color)' : 'var(--glass)' ?>; min-width: 40px; justify-content: center;"><?= $i ?></a>
<?php endfor; ?>
<?php if ($page < $total_pages): ?>
<a href="?p=<?= $page + 1 ?><?= $search ? "&q=".urlencode($search) : '' ?><?= $tag_filter ? "&tag=".urlencode($tag_filter) : '' ?>" class="btn" style="background: var(--glass);">Next <i class="fas fa-chevron-right"></i></a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php require_once 'includes/footer.php'; ?> <?php require_once 'includes/footer.php'; ?>

View File

@ -33,6 +33,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
password VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL,
email VARCHAR(100) NOT NULL UNIQUE, email VARCHAR(100) NOT NULL UNIQUE,
role ENUM('admin', 'moderator', 'editor', 'user') DEFAULT 'user', role ENUM('admin', 'moderator', 'editor', 'user') DEFAULT 'user',
avatar_url TEXT,
theme_preference ENUM('dark', 'light') DEFAULT 'dark',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS videos ( CREATE TABLE IF NOT EXISTS videos (
@ -78,6 +80,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE, FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS bookmarks (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
video_id INT NOT NULL,
video_timestamp FLOAT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS logs (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL,
type VARCHAR(50),
message TEXT,
ip_address VARCHAR(45),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
setting_key VARCHAR(50) PRIMARY KEY, setting_key VARCHAR(50) PRIMARY KEY,
setting_value TEXT setting_value TEXT

View File

@ -21,9 +21,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$_SESSION['user_id'] = $user['id']; $_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username']; $_SESSION['username'] = $user['username'];
$_SESSION['user_role'] = $user['role']; $_SESSION['user_role'] = $user['role'];
logEvent('auth', "User logged in: $username");
header('Location: index.php'); header('Location: index.php');
exit; exit;
} else { } else {
logEvent('auth', "FAILED login attempt for username: $username");
$error = "Invalid username or password."; $error = "Invalid username or password.";
} }
} }

218
profile.php Normal file
View File

@ -0,0 +1,218 @@
<?php
require_once 'includes/db.php';
require_once 'includes/auth.php';
if (!isLoggedIn()) {
header('Location: login.php');
exit;
}
$user_id = $_SESSION['user_id'];
$success = '';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_POST['change_password'])) {
$old_pass = $_POST['old_password'];
$new_pass = $_POST['new_password'];
$confirm_pass = $_POST['confirm_password'];
$stmt = $pdo->prepare("SELECT password FROM users WHERE id = ?");
$stmt->execute([$user_id]);
$user = $stmt->fetch();
if (password_verify($old_pass, $user['password'])) {
if ($new_pass === $confirm_pass) {
if (strlen($new_pass) >= 6) {
$hashed = password_hash($new_pass, PASSWORD_DEFAULT);
$pdo->prepare("UPDATE users SET password = ? WHERE id = ?")->execute([$hashed, $user_id]);
$success = "Password changed successfully!";
} else {
$error = "New password must be at least 6 characters.";
}
} else {
$error = "New passwords do not match.";
}
} else {
$error = "Incorrect current password.";
}
}
if (isset($_POST['update_avatar'])) {
if (isset($_FILES['avatar']) && $_FILES['avatar']['error'] === 0) {
$ext = strtolower(pathinfo($_FILES['avatar']['name'], PATHINFO_EXTENSION));
$allowed = ['jpg', 'jpeg', 'png', 'webp'];
if (in_array($ext, $allowed)) {
$filename = 'avatar_' . $user_id . '_' . time() . '.' . $ext;
if (move_uploaded_file($_FILES['avatar']['tmp_name'], 'uploads/' . $filename)) {
// Delete old avatar if exists
$stmt = $pdo->prepare("SELECT avatar_url FROM users WHERE id = ?");
$stmt->execute([$user_id]);
$old = $stmt->fetchColumn();
if ($old && strpos($old, 'uploads/') === 0) @unlink($old);
$avatar_url = 'uploads/' . $filename;
$pdo->prepare("UPDATE users SET avatar_url = ? WHERE id = ?")->execute([$avatar_url, $user_id]);
$success = "Avatar updated!";
}
} else {
$error = "Invalid image format.";
}
}
}
}
// Get user data
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$user_id]);
$user_data = $stmt->fetch();
$avatar = $user_data['avatar_url'] ?: '';
// Get bookmarks
$stmt = $pdo->prepare("SELECT v.* FROM videos v JOIN bookmarks b ON v.id = b.video_id WHERE b.user_id = ? ORDER BY b.created_at DESC");
$stmt->execute([$user_id]);
$bookmarks = $stmt->fetchAll();
require_once 'includes/header.php';
?>
<div style="max-width: 1000px; margin: 40px auto; padding: 0 24px; display: grid; grid-template-columns: 300px 1fr; gap: 40px;">
<!-- Sidebar -->
<div>
<div style="background: var(--bg-card); padding: 24px; border-radius: 16px; border: 1px solid var(--glass-border); position: sticky; top: 100px;">
<div style="text-align: center; margin-bottom: 24px;">
<div style="width: 100px; height: 100px; background: var(--primary-color); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 16px; overflow: hidden; border: 3px solid var(--glass-border);">
<?php if ($avatar): ?>
<img src="<?= htmlspecialchars($avatar) ?>" style="width: 100%; height: 100%; object-fit: cover;">
<?php else: ?>
<span style="font-size: 2.5rem; color: white;"><?= strtoupper(substr($_SESSION['username'], 0, 1)) ?></span>
<?php endif; ?>
</div>
<h3><?= htmlspecialchars($_SESSION['username']) ?></h3>
<p style="color: var(--text-muted); font-size: 0.9rem;">Member since <?= date('M Y', strtotime($user_data['created_at'])) ?></p>
</div>
<form method="POST" enctype="multipart/form-data" style="margin-bottom: 24px;">
<input type="hidden" name="update_avatar" value="1">
<label class="btn" style="background: var(--glass); font-size: 0.8rem; cursor: pointer; display: block; text-align: center;">
<i class="fas fa-camera"></i> Change Avatar
<input type="file" name="avatar" style="display: none;" onchange="this.form.submit()">
</label>
</form>
<div style="display: flex; flex-direction: column; gap: 8px;">
<a href="#bookmarks" class="btn" style="background: var(--glass); justify-content: flex-start;"><i class="fas fa-bookmark" style="width: 20px;"></i> My Bookmarks</a>
<a href="#security" class="btn" style="background: var(--glass); justify-content: flex-start;"><i class="fas fa-shield-alt" style="width: 20px;"></i> Security</a>
<a href="logout.php" class="btn" style="background: rgba(255,64,129,0.1); color: #ff4081; justify-content: flex-start;"><i class="fas fa-sign-out-alt" style="width: 20px;"></i> Logout</a>
</div>
</div>
</div>
<!-- Main Content -->
<div>
<!-- Bookmarks Section -->
<section id="bookmarks" style="margin-bottom: 60px;">
<h2 style="margin-bottom: 24px;">My Bookmarks</h2>
<?php
$stmt = $pdo->prepare("SELECT v.*, b.video_timestamp FROM videos v JOIN bookmarks b ON v.id = b.video_id WHERE b.user_id = ? ORDER BY b.created_at DESC");
$stmt->execute([$user_id]);
$bookmarks = $stmt->fetchAll();
function formatTime($seconds) {
if ($seconds <= 0) return "";
$mins = floor($seconds / 60);
$secs = floor($seconds % 60);
return sprintf("%d:%02d", $mins, $secs);
}
?>
<?php if (empty($bookmarks)): ?>
<div style="background: var(--bg-card); padding: 40px; border-radius: 16px; border: 1px solid var(--glass-border); text-align: center; color: var(--text-muted);">
<i class="fas fa-bookmark" style="font-size: 3rem; margin-bottom: 16px; display: block;"></i>
<p>You haven't bookmarked any sermons yet.</p>
</div>
<?php else: ?>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px;">
<?php foreach ($bookmarks as $v):
$time_str = formatTime($v['video_timestamp']);
?>
<div style="background: var(--bg-card); border-radius: 12px; border: 1px solid var(--glass-border); overflow: hidden; position: relative;">
<a href="watch.php?id=<?= $v['id'] ?><?= $v['video_timestamp'] > 0 ? '&t='.$v['video_timestamp'] : '' ?>" style="text-decoration: none; color: inherit;">
<div style="height: 150px; background-image: url('<?= $v['thumbnail_url'] ?: 'assets/images/default_thumb.png' ?>'); background-size: cover; background-position: center; position: relative;">
<?php if ($time_str): ?>
<div style="position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,0.8); color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.75rem;">
At <?= $time_str ?>
</div>
<?php endif; ?>
</div>
<div style="padding: 12px;">
<h4 style="margin-bottom: 4px; font-size: 0.95rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"><?= htmlspecialchars($v['title']) ?></h4>
<div style="font-size: 0.8rem; color: var(--text-muted);">
Saved on <?= date('M d, Y', strtotime($v['created_at'])) ?>
</div>
</div>
</a>
<button onclick="removeBookmark(<?= $v['id'] ?>)" style="position: absolute; top: 8px; right: 8px; background: rgba(255,64,129,0.9); color: white; border: none; width: 28px; height: 28px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 10px rgba(0,0,0,0.3);">
<i class="fas fa-times"></i>
</button>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<script>
async function removeBookmark(id) {
if (!confirm('Remove this bookmark?')) return;
const res = await fetch('api/toggle_bookmark.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `video_id=${id}`
});
const data = await res.json();
if (data.success) {
location.reload();
}
}
</script>
<!-- Security Section -->
<section id="security">
<h2 style="margin-bottom: 24px;">Security</h2>
<div style="background: var(--bg-card); padding: 32px; border-radius: 16px; border: 1px solid var(--glass-border);">
<h3>Change Password</h3>
<p style="color: var(--text-muted); margin-bottom: 24px; font-size: 0.9rem;">Keep your account secure by using a strong password.</p>
<?php if ($success): ?>
<div style="background: rgba(76,175,80,0.1); color: #4caf50; padding: 12px; border-radius: 8px; margin-bottom: 20px; border: 1px solid rgba(76,175,80,0.2);">
<?= $success ?>
</div>
<?php endif; ?>
<?php if ($error): ?>
<div style="background: rgba(255,64,129,0.1); color: #ff4081; padding: 12px; border-radius: 8px; margin-bottom: 20px; border: 1px solid rgba(255,64,129,0.2);">
<?= $error ?>
</div>
<?php endif; ?>
<form method="POST">
<input type="hidden" name="change_password" value="1">
<div class="form-group">
<label class="form-label">Current Password</label>
<input type="password" name="old_password" class="form-control" required>
</div>
<div class="form-group">
<label class="form-label">New Password</label>
<input type="password" name="new_password" class="form-control" required minlength="6">
</div>
<div class="form-group">
<label class="form-label">Confirm New Password</label>
<input type="password" name="confirm_password" class="form-control" required minlength="6">
</div>
<button type="submit" class="btn btn-primary" style="margin-top: 12px;">Update Password</button>
</form>
</div>
</section>
</div>
</div>
<?php require_once 'includes/footer.php'; ?>

141
watch.php
View File

@ -63,36 +63,24 @@ require_once 'includes/header.php';
<!-- Main Content --> <!-- Main Content -->
<div> <div>
<!-- Video Player Area --> <!-- Video Player Area -->
<div id="video-wrapper" style="background: #000; aspect-ratio: 16/9; border-radius: 12px; overflow: hidden; margin-bottom: 24px; box-shadow: 0 10px 40px rgba(0,0,0,0.6); position: relative; width: 100%;"> <div id="video-wrapper" style="background: #000; border-radius: 12px; overflow: hidden; margin-bottom: 24px; box-shadow: 0 10px 40px rgba(0,0,0,0.6); position: relative; width: 100%;">
<?php if ($video['source_type'] === 'upload'): ?> <?php if ($video['source_type'] === 'upload'): ?>
<div style="aspect-ratio: 16/9;">
<video id="main-video" controls onplay="incrementViews(<?= $video_id ?>)" style="width: 100%; height: 100%; object-fit: contain;" poster="<?= $video['thumbnail_url'] ?: 'assets/images/default_thumb.png' ?>"> <video id="main-video" controls onplay="incrementViews(<?= $video_id ?>)" style="width: 100%; height: 100%; object-fit: contain;" poster="<?= $video['thumbnail_url'] ?: 'assets/images/default_thumb.png' ?>">
<source src="<?= htmlspecialchars($video['video_url']) ?>" type="video/mp4"> <source src="<?= htmlspecialchars($video['video_url']) ?>" type="video/mp4">
</video> </video>
</div>
<?php else: ?> <?php else: ?>
<!-- Play Overlay for Linked Videos --> <!-- Play Overlay for Linked Videos -->
<div id="external-overlay" style="position: absolute; inset: 0; background-image: url('<?= $video['thumbnail_url'] ?: 'assets/images/default_thumb.png' ?>'); background-size: cover; background-position: center; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 5;" onclick="loadExternalVideo()"> <div id="external-overlay" style="aspect-ratio: 16/9; background-image: url('<?= $video['thumbnail_url'] ?: 'assets/images/default_thumb.png' ?>'); background-size: cover; background-position: center; display: flex; align-items: center; justify-content: center; cursor: pointer;" onclick="loadExternalVideo()">
<div style="background: rgba(var(--primary-rgb, 124, 77, 255), 0.9); width: 80px; height: 80px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 2rem; box-shadow: 0 0 30px rgba(0,0,0,0.5); transition: transform 0.2s;" onmouseover="this.style.transform='scale(1.1)'" onmouseout="this.style.transform='scale(1)'"> <div style="background: rgba(var(--primary-rgb, 124, 77, 255), 0.9); width: 80px; height: 80px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 2rem; box-shadow: 0 0 30px rgba(0,0,0,0.5);">
<i class="fas fa-play" style="margin-left: 5px;"></i> <i class="fas fa-play" style="margin-left: 5px;"></i>
</div> </div>
</div> </div>
<div id="iframe-container" style="width: 100%; height: 100%; display: none;"> <div id="iframe-container" style="display: none; position: relative; width: 100%; padding-bottom: 56.25%; height: 0; background: #000; min-height: 300px;">
<iframe id="external-iframe" width="100%" height="100%" src="" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen style="width: 100%; height: 100%; object-fit: contain;"></iframe> <iframe id="external-iframe" src="" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: none; overflow: hidden;"></iframe>
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Recommendation Overlay (for uploaded videos) -->
<div id="video-end-overlay" style="display: none; position: absolute; inset: 0; background: rgba(0,0,0,0.8); z-index: 10; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 20px;">
<h3 style="margin-bottom: 20px;">Up Next</h3>
<div style="display: flex; gap: 16px;">
<?php foreach (array_slice($recommendations, 0, 2) as $rec): ?>
<a href="watch.php?id=<?= $rec['id'] ?>" style="width: 200px;">
<div style="aspect-ratio: 16/9; background: #333; background-image: url('<?= $rec['thumbnail_url'] ?>'); background-size: cover; border-radius: 8px;"></div>
<div style="margin-top: 8px; font-size: 0.9rem;"><?= htmlspecialchars($rec['title']) ?></div>
</a>
<?php endforeach; ?>
</div>
<button onclick="document.getElementById('video-end-overlay').style.display='none'" class="btn" style="margin-top: 20px; background: var(--glass);">Replay</button>
</div>
</div> </div>
<!-- Video Info --> <!-- Video Info -->
@ -108,36 +96,40 @@ require_once 'includes/header.php';
<?= number_format($video['views']) ?> views • <?= date('M d, Y', strtotime($video['release_date'])) ?> <?= number_format($video['views']) ?> views • <?= date('M d, Y', strtotime($video['release_date'])) ?>
</div> </div>
<div style="display: flex; gap: 12px; position: relative;"> <div style="display: flex; gap: 12px; position: relative;">
<button class="btn" onclick="toggleShareMenu()" style="background: var(--primary-color); color: white;"><i class="fas fa-share"></i> Share</button> <button onclick="toggleShareMenu()" class="btn" style="background: var(--primary-color); padding: 8px 16px;">
<i class="fas fa-share"></i> Share
<!-- Share Menu Dropdown -->
<div id="share-menu" style="display: none; position: absolute; top: 100%; right: 0; background: var(--bg-card); border: 1px solid var(--glass-border); border-radius: 8px; padding: 12px; z-index: 100; min-width: 200px; margin-top: 8px; box-shadow: 0 10px 30px rgba(0,0,0,0.5);">
<div style="margin-bottom: 12px; font-weight: 600; font-size: 0.9rem;">Share this sermon</div>
<div style="display: grid; gap: 8px;">
<button onclick="copyCurrentLink()" class="btn" style="background: var(--glass); width: 100%; justify-content: flex-start; font-size: 0.85rem;">
<i class="fas fa-link" style="margin-right: 8px;"></i> Copy Link
</button> </button>
<a href="https://www.facebook.com/sharer/sharer.php?u=<?= urlencode((isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']) ?>" target="_blank" class="btn" style="background: #1877f2; width: 100%; justify-content: flex-start; font-size: 0.85rem; text-decoration: none; color: white;"> <?php if (isLoggedIn()):
<i class="fab fa-facebook" style="margin-right: 8px;"></i> Facebook $stmt = $pdo->prepare("SELECT 1 FROM bookmarks WHERE user_id = ? AND video_id = ?");
</a> $stmt->execute([$_SESSION['user_id'], $video_id]);
<a href="https://twitter.com/intent/tweet?url=<?= urlencode((isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']) ?>&text=Check out this sermon on ChurchTube!" target="_blank" class="btn" style="background: #000; width: 100%; justify-content: flex-start; font-size: 0.85rem; text-decoration: none; color: white;"> $is_bookmarked = $stmt->fetch();
<i class="fab fa-x-twitter" style="margin-right: 8px;"></i> Twitter ?>
</a> <button onclick="toggleBookmark(<?= $video_id ?>)" id="bookmark-btn" class="btn" style="background: rgba(255,255,255,0.15); color: #fff; padding: 8px 16px; border: 1px solid rgba(255,255,255,0.2);">
</div> <i class="<?= $is_bookmarked ? 'fas' : 'far' ?> fa-bookmark"></i> <?= $is_bookmarked ? 'Bookmarked' : 'Bookmark' ?>
</div> </button>
<?php endif; ?>
</div> </div>
</div> </div>
<div style="padding: 20px 0;"> <div style="padding: 20px 0;">
<div style="display: flex; gap: 16px; align-items: center; margin-bottom: 16px;"> <div style="display: flex; gap: 16px; align-items: center; margin-bottom: 16px;">
<div style="width: 48px; height: 48px; background: var(--primary-color); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold;"> <?php
<?= strtoupper(substr($video['uploader'], 0, 1)) ?> $u_stmt = $pdo->prepare("SELECT avatar_url FROM users WHERE id = ?");
$u_stmt->execute([$video['uploader_id']]);
$uploader_avatar = $u_stmt->fetchColumn();
?>
<div style="width: 48px; height: 48px; background: var(--primary-color); border-radius: 50%; display: flex; align-items: center; justify-content: center; overflow: hidden; border: 2px solid var(--glass-border);">
<?php if ($uploader_avatar): ?>
<img src="<?= htmlspecialchars($uploader_avatar) ?>" style="width: 100%; height: 100%; object-fit: cover;">
<?php else: ?>
<span style="font-weight: bold; color: white;"><?= strtoupper(substr($video['uploader'], 0, 1)) ?></span>
<?php endif; ?>
</div> </div>
<div> <div>
<div style="font-weight: 600;"><?= htmlspecialchars($video['uploader']) ?></div> <div style="font-weight: 600;"><?= htmlspecialchars($video['uploader']) ?></div>
</div> </div>
</div> </div>
<div style="background: var(--glass); padding: 16px; border-radius: 12px; white-space: pre-wrap;"><?= htmlspecialchars($video['description']) ?></div> <div style="background: var(--glass); padding: 16px; border-radius: 12px; white-space: pre-wrap; line-height: 1.6;"><?= htmlspecialchars($video['description']) ?></div>
</div> </div>
</div> </div>
@ -155,8 +147,12 @@ require_once 'includes/header.php';
<div id="comment-list" style="display: flex; flex-direction: column; gap: 24px;"> <div id="comment-list" style="display: flex; flex-direction: column; gap: 24px;">
<?php foreach ($comments as $c): ?> <?php foreach ($comments as $c): ?>
<div class="comment-item" style="display: flex; gap: 16px;"> <div class="comment-item" style="display: flex; gap: 16px;">
<div style="width: 40px; height: 40px; background: #333; border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-weight: bold;"> <div style="width: 40px; height: 40px; background: #333; border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; overflow: hidden; border: 1px solid var(--glass-border);">
<?= strtoupper(substr($c['username'], 0, 1)) ?> <?php if ($c['avatar_url']): ?>
<img src="<?= htmlspecialchars($c['avatar_url']) ?>" style="width: 100%; height: 100%; object-fit: cover;">
<?php else: ?>
<span style="font-weight: bold;"><?= strtoupper(substr($c['username'], 0, 1)) ?></span>
<?php endif; ?>
</div> </div>
<div style="flex-grow: 1;"> <div style="flex-grow: 1;">
<div style="font-weight: 600; font-size: 0.9rem;"> <div style="font-weight: 600; font-size: 0.9rem;">
@ -175,7 +171,7 @@ require_once 'includes/header.php';
<span onclick="report(<?= $c['id'] ?>)" style="cursor:pointer; margin-left: 10px; font-weight: 600; color: #ff4081;">REPORT</span> <span onclick="report(<?= $c['id'] ?>)" style="cursor:pointer; margin-left: 10px; font-weight: 600; color: #ff4081;">REPORT</span>
<?php if (isModerator()): ?> <?php if (isLoggedIn() && ($c['user_id'] == $_SESSION['user_id'] || isModerator())): ?>
<span onclick="deleteComment(<?= $c['id'] ?>)" style="cursor:pointer; margin-left: 10px; color: #ff4081;"><i class="fas fa-trash"></i></span> <span onclick="deleteComment(<?= $c['id'] ?>)" style="cursor:pointer; margin-left: 10px; color: #ff4081;"><i class="fas fa-trash"></i></span>
<?php endif; ?> <?php endif; ?>
</div> </div>
@ -208,6 +204,8 @@ require_once 'includes/header.php';
<script> <script>
const videoId = <?= $video_id ?>; const videoId = <?= $video_id ?>;
const currentUserId = <?= $_SESSION['user_id'] ?? 0 ?>;
const isLoggedIn = <?= isLoggedIn() ? 'true' : 'false' ?>;
const isModerator = <?= isModerator() ? 'true' : 'false' ?>; const isModerator = <?= isModerator() ? 'true' : 'false' ?>;
const videoElem = document.getElementById('main-video'); const videoElem = document.getElementById('main-video');
const commentForm = document.getElementById('comment-form'); const commentForm = document.getElementById('comment-form');
@ -225,7 +223,12 @@ function loadExternalVideo() {
const overlay = document.getElementById('external-overlay'); const overlay = document.getElementById('external-overlay');
const container = document.getElementById('iframe-container'); const container = document.getElementById('iframe-container');
const iframe = document.getElementById('external-iframe'); const iframe = document.getElementById('external-iframe');
const videoUrl = "<?= htmlspecialchars($video['video_url']) ?>"; let videoUrl = "<?= htmlspecialchars($video['video_url']) ?>";
// Auto-convert Google Drive links to preview mode for better mobile support
if (videoUrl.includes('drive.google.com') && videoUrl.includes('/view')) {
videoUrl = videoUrl.replace('/view', '/preview');
}
incrementViews(videoId); incrementViews(videoId);
@ -249,6 +252,35 @@ async function incrementViews(id) {
} }
} }
async function toggleBookmark(id) {
let timestamp = 0;
if (videoElem) {
timestamp = videoElem.currentTime;
}
const res = await fetch('api/toggle_bookmark.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `video_id=${id}&timestamp=${timestamp}`
});
const data = await res.json();
if (data.success) {
const btn = document.getElementById('bookmark-btn');
const isBookmarked = data.action === 'added';
btn.innerHTML = `<i class="${isBookmarked ? 'fas' : 'far'} fa-bookmark"></i> ${isBookmarked ? 'Bookmarked' : 'Bookmark'}`;
}
}
// Deep linking to timestamp
window.addEventListener('load', () => {
const urlParams = new URLSearchParams(window.location.search);
const t = urlParams.get('t');
if (t && videoElem) {
videoElem.currentTime = parseFloat(t);
videoElem.play().catch(() => {}); // Autoplay might be blocked
}
});
async function react(commentId, type) { async function react(commentId, type) {
const res = await fetch('api/react.php', { const res = await fetch('api/react.php', {
method: 'POST', method: 'POST',
@ -303,19 +335,32 @@ function renderComments(comments) {
commentList.innerHTML = ''; commentList.innerHTML = '';
comments.forEach(c => { comments.forEach(c => {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'comment-item'; div.id = `comment-${c.id}`;
div.style.display = 'flex'; div.style.display = 'flex';
div.style.gap = '16px'; div.style.gap = '16px';
div.style.marginBottom = '24px';
const avatarHtml = c.avatar_url
? `<img src="${c.avatar_url}" style="width: 100%; height: 100%; object-fit: cover;">`
: `<span style="font-weight: bold;">${c.username.charAt(0).toUpperCase()}</span>`;
div.innerHTML = ` div.innerHTML = `
<div style="width: 40px; height: 40px; background: #333; border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-weight: bold;"> <div style="width: 40px; height: 40px; background: #333; border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; overflow: hidden; border: 1px solid var(--glass-border);">
${c.username.charAt(0).toUpperCase()} ${avatarHtml}
</div> </div>
<div style="flex-grow: 1;"> <div style="flex-grow: 1;">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<div style="font-weight: 600; font-size: 0.9rem;"> <div style="font-weight: 600; font-size: 0.9rem;">
${escapeHtml(c.username)} ${escapeHtml(c.username)}
<span style="font-weight: 400; color: var(--text-muted); margin-left: 8px; font-size: 0.8rem;">${c.display_date}</span> <span style="font-weight: 400; color: var(--text-muted); margin-left: 8px; font-size: 0.8rem;">${c.display_date}</span>
</div> </div>
<div style="margin-top: 4px;">${escapeHtml(c.comment_text)}</div> <div style="display: flex; gap: 12px; align-items: center;">
${(isLoggedIn && (c.user_id == currentUserId || isModerator)) ?
`<button onclick="deleteComment(${c.id})" class="btn" style="background: none; padding: 4px; color: #ff4081;" title="Delete"><i class="fas fa-trash"></i></button>` : ''}
<button onclick="report(${c.id})" class="btn" style="background: none; padding: 4px; color: #ff4081; font-weight: 600; font-size: 0.75rem;">REPORT</button>
</div>
</div>
<div style="margin-top: 4px; line-height: 1.4;">${escapeHtml(c.comment_text)}</div>
<div style="display: flex; gap: 16px; margin-top: 10px; align-items: center; font-size: 0.85rem; color: var(--text-muted);"> <div style="display: flex; gap: 16px; margin-top: 10px; align-items: center; font-size: 0.85rem; color: var(--text-muted);">
<span onclick="react(${c.id}, 'thumb')" style="cursor:pointer;"><i class="fas fa-thumbs-up"></i> <span id="count-thumb-${c.id}">${c.thumbs}</span></span> <span onclick="react(${c.id}, 'thumb')" style="cursor:pointer;"><i class="fas fa-thumbs-up"></i> <span id="count-thumb-${c.id}">${c.thumbs}</span></span>
@ -323,10 +368,6 @@ function renderComments(comments) {
<span onclick="react(${c.id}, 'pray')" style="cursor:pointer;"><i class="fas fa-hands-praying"></i> <span id="count-pray-${c.id}">${c.prays}</span></span> <span onclick="react(${c.id}, 'pray')" style="cursor:pointer;"><i class="fas fa-hands-praying"></i> <span id="count-pray-${c.id}">${c.prays}</span></span>
<span onclick="react(${c.id}, 'insight')" style="cursor:pointer;"><i class="fas fa-lightbulb"></i> <span id="count-insight-${c.id}">${c.insights}</span></span> <span onclick="react(${c.id}, 'insight')" style="cursor:pointer;"><i class="fas fa-lightbulb"></i> <span id="count-insight-${c.id}">${c.insights}</span></span>
<span onclick="react(${c.id}, 'clap')" style="cursor:pointer;"><i class="fas fa-hands-clapping"></i> <span id="count-clap-${c.id}">${c.claps}</span></span> <span onclick="react(${c.id}, 'clap')" style="cursor:pointer;"><i class="fas fa-hands-clapping"></i> <span id="count-clap-${c.id}">${c.claps}</span></span>
<span onclick="report(${c.id})" style="cursor:pointer; margin-left: 10px; font-weight: 600; color: #ff4081;">REPORT</span>
${isModerator ? `<span onclick="deleteComment(${c.id})" style="cursor:pointer; margin-left: 10px; color: #ff4081;"><i class="fas fa-trash"></i></span>` : ''}
</div> </div>
</div> </div>
`; `;