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
- **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).
- **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**:
- AJAX-based commenting (no video reloads).
- 5 reaction types (👍, ❤️, 🙏, 💡, 👏).
- 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).
- Dedicated Admin/Moderator dashboard for reports and users.
- **Custom Branding**: Real-time control over site title, colors, logo, and footer.
- **Search & Discovery**: Tag-based categorization, search, and intelligent recommendations.
- **Analytics**: Engagement-based view counting (only counts when video is played).
- **Search & Discovery**: Keyword search, intelligent recommendations, and pagination.
## 🛠️ 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]);
}
}
cleanupTags();
$success = "Video updated successfully!";
// Refresh video data
$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]);
cleanupTags();
header('Location: index.php?msg=deleted');
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>
<?php endif; ?>
</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 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');
if (!isModerator()) {
echo json_encode(['success' => false, 'error' => 'Moderator privileges required']);
if (!isLoggedIn()) {
echo json_encode(['success' => false, 'error' => 'Login required']);
exit;
}
$comment_id = (int)($_POST['comment_id'] ?? 0);
if (!$comment_id) {
echo json_encode(['success' => false, 'error' => 'Invalid data']);
exit;
}
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]);
logEvent('comment', "Comment deleted: ID $comment_id by user " . $_SESSION['username']);
echo json_encode(['success' => true]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => 'DB error']);

View File

@ -12,7 +12,7 @@ if (!$video_id) {
try {
$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 = 'heart') as hearts,
(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 {
$stmt = $pdo->prepare("UPDATE videos SET views = views + 1 WHERE 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]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => 'Database error']);

View File

@ -33,6 +33,11 @@ foreach ($bad_words as $word) {
try {
$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])) {
// 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]);
} else {
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->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$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) {
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>
:root {
--primary-color: <?= $primary_color ?>;
--primary-rgb: <?= hexToRgb($primary_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) {
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 {
font-size: 1.2rem !important;
max-width: 150px;
font-size: 1.1rem !important;
margin: 0 !important;
flex: 1;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-bar {
display: none !important;
.user-actions {
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>
</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>
<a href="index.php" class="logo">
<?php if ($logo_url): ?>
@ -53,18 +104,32 @@ $logo_url = get_setting('logo_url', '');
</form>
<div class="user-actions" style="display: flex; gap: 16px; align-items: center;">
<?php if (isEditor()): ?>
<a href="admin/index.php" class="btn btn-primary" style="padding: 8px 16px; font-size: 0.9rem;">
<i class="fas fa-plus"></i> Admin
</a>
<?php endif; ?>
<?php if (isLoggedIn()): ?>
<span style="color: var(--text-muted);">Hi, <?= htmlspecialchars($_SESSION['username']) ?></span>
<a href="logout.php" title="Logout"><i class="fas fa-sign-out-alt"></i></a>
<?php if (isLoggedIn()):
$hdr_avatar = '';
try {
$stmt = $pdo->prepare("SELECT avatar_url FROM users WHERE id = ?");
$stmt->execute([$_SESSION['user_id']]);
$hdr_avatar = $stmt->fetchColumn();
} catch (Exception $e) {
// Avatar column might not exist yet if migration wasn't run
}
?>
<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: ?>
<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; ?>
</div>
</header>
<main style="min-height: calc(100vh - 64px);">
<main>

View File

@ -1,13 +1,17 @@
<?php
function get_setting($key, $default = '') {
global $pdo;
static $settings_cache = null;
if ($settings_cache === null) {
try {
$stmt = $pdo->prepare("SELECT setting_value FROM settings WHERE setting_key = ?");
$stmt->execute([$key]);
$res = $stmt->fetch();
return $res ? $res['setting_value'] : $default;
$stmt = $pdo->query("SELECT setting_key, setting_value FROM settings");
$settings_cache = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
} 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/header.php';
$search = $_GET['q'] ?? '';
$tag_filter = $_GET['tag'] ?? '';
$page = isset($_GET['p']) ? (int)$_GET['p'] : 1;
$limit = 10;
$offset = ($page - 1) * $limit;
$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 tags t ON vt.tag_id = t.id";
$search = $_GET['q'] ?? '';
$tag_filter = $_GET['tag'] ?? '';
$params = [];
$where_clauses = [];
if ($search) {
$query .= " WHERE (v.title LIKE ? OR v.description LIKE ? OR t.name LIKE ?)";
$params = ["%$search%", "%$search%", "%$search%"];
} elseif ($tag_filter) {
$query .= " WHERE t.name = ?";
$params = [$tag_filter];
$where_clauses[] = "(v.title LIKE ? OR v.description LIKE ? OR t.name LIKE ?)";
$params = array_merge($params, ["%$search%", "%$search%", "%$search%"]);
}
if ($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->execute($params);
$videos = $stmt->fetchAll();
@ -34,13 +53,6 @@ $videos = $stmt->fetchAll();
$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">
<?php if (empty($videos)): ?>
<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): ?>
<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' ?>');">
<!-- Placeholder thumbnail if none exists -->
</div>
<div class="video-info">
<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">
<span><?= htmlspecialchars($video['uploader']) ?></span> •
<span><?= number_format($video['views']) ?> views</span> •
<span><?= date('M d, Y', strtotime($video['release_date'])) ?></span>
</div>
</div>
@ -68,4 +83,20 @@ $popular_tags = $pdo->query("SELECT name FROM tags LIMIT 10")->fetchAll(PDO::FET
<?php endif; ?>
</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'; ?>

View File

@ -33,6 +33,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
password VARCHAR(255) NOT NULL,
email VARCHAR(100) NOT NULL UNIQUE,
role ENUM('admin', 'moderator', 'editor', 'user') DEFAULT 'user',
avatar_url TEXT,
theme_preference ENUM('dark', 'light') DEFAULT 'dark',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
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 (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 (
setting_key VARCHAR(50) PRIMARY KEY,
setting_value TEXT

View File

@ -21,9 +21,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['user_role'] = $user['role'];
logEvent('auth', "User logged in: $username");
header('Location: index.php');
exit;
} else {
logEvent('auth', "FAILED login attempt for username: $username");
$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 -->
<div>
<!-- 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'): ?>
<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' ?>">
<source src="<?= htmlspecialchars($video['video_url']) ?>" type="video/mp4">
</video>
</div>
<?php else: ?>
<!-- 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 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 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);">
<i class="fas fa-play" style="margin-left: 5px;"></i>
</div>
</div>
<div id="iframe-container" style="width: 100%; height: 100%; display: none;">
<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>
<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" 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>
<?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>
<!-- Video Info -->
@ -108,36 +96,40 @@ require_once 'includes/header.php';
<?= number_format($video['views']) ?> views • <?= date('M d, Y', strtotime($video['release_date'])) ?>
</div>
<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>
<!-- 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 onclick="toggleShareMenu()" class="btn" style="background: var(--primary-color); padding: 8px 16px;">
<i class="fas fa-share"></i> Share
</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;">
<i class="fab fa-facebook" style="margin-right: 8px;"></i> Facebook
</a>
<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;">
<i class="fab fa-x-twitter" style="margin-right: 8px;"></i> Twitter
</a>
</div>
</div>
<?php if (isLoggedIn()):
$stmt = $pdo->prepare("SELECT 1 FROM bookmarks WHERE user_id = ? AND video_id = ?");
$stmt->execute([$_SESSION['user_id'], $video_id]);
$is_bookmarked = $stmt->fetch();
?>
<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);">
<i class="<?= $is_bookmarked ? 'fas' : 'far' ?> fa-bookmark"></i> <?= $is_bookmarked ? 'Bookmarked' : 'Bookmark' ?>
</button>
<?php endif; ?>
</div>
</div>
<div style="padding: 20px 0;">
<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;">
<?= strtoupper(substr($video['uploader'], 0, 1)) ?>
<?php
$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 style="font-weight: 600;"><?= htmlspecialchars($video['uploader']) ?></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>
@ -155,8 +147,12 @@ require_once 'includes/header.php';
<div id="comment-list" style="display: flex; flex-direction: column; gap: 24px;">
<?php foreach ($comments as $c): ?>
<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;">
<?= strtoupper(substr($c['username'], 0, 1)) ?>
<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);">
<?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 style="flex-grow: 1;">
<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>
<?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>
<?php endif; ?>
</div>
@ -208,6 +204,8 @@ require_once 'includes/header.php';
<script>
const videoId = <?= $video_id ?>;
const currentUserId = <?= $_SESSION['user_id'] ?? 0 ?>;
const isLoggedIn = <?= isLoggedIn() ? 'true' : 'false' ?>;
const isModerator = <?= isModerator() ? 'true' : 'false' ?>;
const videoElem = document.getElementById('main-video');
const commentForm = document.getElementById('comment-form');
@ -225,7 +223,12 @@ function loadExternalVideo() {
const overlay = document.getElementById('external-overlay');
const container = document.getElementById('iframe-container');
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);
@ -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) {
const res = await fetch('api/react.php', {
method: 'POST',
@ -303,19 +335,32 @@ function renderComments(comments) {
commentList.innerHTML = '';
comments.forEach(c => {
const div = document.createElement('div');
div.className = 'comment-item';
div.id = `comment-${c.id}`;
div.style.display = 'flex';
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 style="width: 40px; height: 40px; background: #333; border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-weight: bold;">
${c.username.charAt(0).toUpperCase()}
<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);">
${avatarHtml}
</div>
<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;">
${escapeHtml(c.username)}
<span style="font-weight: 400; color: var(--text-muted); margin-left: 8px; font-size: 0.8rem;">${c.display_date}</span>
</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);">
<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}, '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="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>
`;