Initial commit: Professional Podcast Server with automated setup
This commit is contained in:
commit
e9bb4334b7
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Database configuration
|
||||||
|
includes/config.php
|
||||||
|
|
||||||
|
# Uploaded content
|
||||||
|
assets/uploads/audio/*
|
||||||
|
assets/uploads/images/*
|
||||||
|
!assets/uploads/images/default-banner.jpg
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
temp_restore_*/
|
||||||
|
|
||||||
|
# IDE and OS files
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
error_log
|
||||||
|
access_log
|
||||||
|
|
||||||
|
# Debug and utility scripts
|
||||||
|
debug_db.php
|
||||||
|
init_logs.php
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Michael / Linology
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
# Podcast Server 🚀
|
||||||
|
|
||||||
|
A lightweight, professional-grade podcast hosting and management platform designed for churches and small organizations. Built with PHP 8 and MySQL, this system provides a seamless way to host audio content, track analytics, and engage listeners via push notifications.
|
||||||
|
|
||||||
|
Developed and tested on **Ubuntu 20.04** using the standard **LAMP** stack.
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- **Episode Management**: Easy upload of audio files and cover art.
|
||||||
|
- **Admin Dashboard**: Comprehensive control panel for managing episodes, settings, and users.
|
||||||
|
- **Role-Based Access (RBAC)**: Supports 'Administrator' and 'Editor' roles for secure collaboration.
|
||||||
|
- **Real-time Analytics**: Track episode plays and listening duration.
|
||||||
|
- **Push Notifications**: Integrated subscription system for listener engagement.
|
||||||
|
- **RSS Feed Generation**: Fully compatible feed for platforms like Spotify, Apple Podcasts, etc.
|
||||||
|
- **PWA Ready**: Offline capabilities and "Add to Home Screen" support via Service Workers.
|
||||||
|
- **Automated Setup**: Built-in installation wizard for easy deployment.
|
||||||
|
- **Backup & Restore**: Simple system utilities for data safety using `tar`.
|
||||||
|
|
||||||
|
## 🛠️ Requirements
|
||||||
|
|
||||||
|
The software has been tested and verified on the following environment:
|
||||||
|
- **OS**: Ubuntu 20.04 (LTS)
|
||||||
|
- **Web Server**: Apache2
|
||||||
|
- **PHP**: 8.0 or higher
|
||||||
|
- **Database**: MySQL 8.0+ or MariaDB
|
||||||
|
- **PHP Extensions**: `pdo_mysql`, `mbstring`, `gd`, `curl`
|
||||||
|
- **System Utilities**: `tar` (required for backup/restore features)
|
||||||
|
|
||||||
|
## 🚀 Installation
|
||||||
|
|
||||||
|
### 1. Clone the Repository
|
||||||
|
Clone this project into your web root directory (e.g., `/var/www/html/podcast`):
|
||||||
|
```bash
|
||||||
|
cd /var/www/html
|
||||||
|
sudo git clone https://git.linology.tech/michael/Podcast-server podcast
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Set Permissions
|
||||||
|
The web server needs write access to specific directories for uploads and configuration:
|
||||||
|
```bash
|
||||||
|
sudo chown -R www-data:www-data /var/www/html/podcast
|
||||||
|
sudo chmod -R 755 /var/www/html/podcast
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. PHP Configuration (Recommended)
|
||||||
|
Podcast audio files can be large. Ensure your `php.ini` (usually `/etc/php/8.0/apache2/php.ini`) allows large uploads:
|
||||||
|
```ini
|
||||||
|
upload_max_filesize = 100M
|
||||||
|
post_max_size = 110M
|
||||||
|
memory_limit = 256M
|
||||||
|
```
|
||||||
|
After editing, restart Apache:
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart apache2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run the Setup Wizard
|
||||||
|
Open your browser and navigate to:
|
||||||
|
`http://your-server-ip/podcast/setup.php`
|
||||||
|
|
||||||
|
The setup script will:
|
||||||
|
- Verify folder permissions and PHP settings.
|
||||||
|
- Create the MySQL database and tables automatically.
|
||||||
|
- Initialize the first Administrator account.
|
||||||
|
- Generate your `includes/config.php` file.
|
||||||
|
|
||||||
|
## 📂 Project Structure
|
||||||
|
|
||||||
|
- `/admin`: Administrative interface and management scripts.
|
||||||
|
- `/assets`: CSS, JS, and default assets. Uploaded media is stored here.
|
||||||
|
- `/includes`: Core logic, database connection, and utility functions.
|
||||||
|
- `/sql`: Database schema files.
|
||||||
|
- `feed.php`: Automatically generated RSS feed for podcast players.
|
||||||
|
- `setup.php`: The automated installation script.
|
||||||
|
|
||||||
|
## 🔒 Security & Git
|
||||||
|
- **Configuration**: The `includes/config.php` file is excluded from Git via `.gitignore` to protect your database credentials.
|
||||||
|
- **Uploads**: Audio and image uploads are excluded from Git to keep the repository lightweight.
|
||||||
|
- **Setup**: After a successful installation, it is recommended to delete `setup.php` or move it outside the web root.
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
Feel free to fork this project and submit pull requests to the [Linology Git](https://git.linology.tech/michael/Podcast-server) instance.
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
This project is licensed under the MIT License.
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?php
|
||||||
|
require_once '../includes/db.php';
|
||||||
|
require_once '../includes/functions.php';
|
||||||
|
|
||||||
|
ini_set('display_errors', 1);
|
||||||
|
ini_set('display_startup_errors', 1);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('log_errors', 1);
|
||||||
|
ini_set('error_log', 'backup_debug.log');
|
||||||
|
|
||||||
|
set_time_limit(300); // 5 minutes
|
||||||
|
ini_set('memory_limit', '512M');
|
||||||
|
|
||||||
|
requireAdmin();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['generate_backup'])) {
|
||||||
|
$date = date("Y-m-d_H-i");
|
||||||
|
$backupDir = "backups/";
|
||||||
|
if (!is_dir($backupDir)) {
|
||||||
|
if (!mkdir($backupDir, 0755, true)) {
|
||||||
|
die("Could not create backup directory. Check permissions.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_writable($backupDir)) {
|
||||||
|
die("Backup directory is not writable. Check permissions.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Backup Audio Files
|
||||||
|
$audioTar = $backupDir . "backup_audio_$date.tar.gz";
|
||||||
|
$audioPath = realpath("../assets/uploads/audio");
|
||||||
|
shell_exec("tar -czf " . escapeshellarg($audioTar) . " -C " . escapeshellarg($audioPath) . " .");
|
||||||
|
|
||||||
|
// 2. Backup Database (PHP-based SQL Dump)
|
||||||
|
$dbSql = $backupDir . "backup_db_$date.sql";
|
||||||
|
$tables = ['episodes', 'plays', 'settings', 'admins', 'subscriptions'];
|
||||||
|
$sqlDump = "-- Database Backup\n-- Date: " . date("Y-m-d H:i:s") . "\n";
|
||||||
|
$sqlDump .= "SET FOREIGN_KEY_CHECKS = 0;\n\n";
|
||||||
|
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
$stmt = $pdo->query("SELECT * FROM $table");
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$sqlDump .= "TRUNCATE TABLE `$table`;\n";
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$keys = array_keys($row);
|
||||||
|
$values = array_map(function($v) use ($pdo) {
|
||||||
|
return ($v === null) ? "NULL" : $pdo->quote($v);
|
||||||
|
}, array_values($row));
|
||||||
|
$sqlDump .= "INSERT INTO `$table` (`" . implode("`, `", $keys) . "`) VALUES (" . implode(", ", $values) . ");\n";
|
||||||
|
}
|
||||||
|
$sqlDump .= "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($dbSql, $sqlDump . "\nSET FOREIGN_KEY_CHECKS = 1;");
|
||||||
|
|
||||||
|
// Tar the SQL
|
||||||
|
$dbTar = $backupDir . "backup_db_$date.tar.gz";
|
||||||
|
shell_exec("tar -czf " . escapeshellarg($dbTar) . " -C " . escapeshellarg($backupDir) . " " . escapeshellarg(basename($dbSql)));
|
||||||
|
unlink($dbSql);
|
||||||
|
|
||||||
|
// 3. Backup Site Files (Excluding uploads and backups)
|
||||||
|
$siteTar = $backupDir . "backup_site_$date.tar.gz";
|
||||||
|
$rootPath = realpath("../");
|
||||||
|
$exclude = " --exclude='./assets/uploads' --exclude='./admin/backups' --exclude='./.git'";
|
||||||
|
shell_exec("tar -czf " . escapeshellarg($siteTar) . " -C " . escapeshellarg($rootPath) . $exclude . " .");
|
||||||
|
|
||||||
|
logActivity($_SESSION['admin_id'], 'BACKUP_GENERATE', "Generated full system backup: Audio, DB, and Site files.");
|
||||||
|
header("Location: system.php?backup=success");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Deny from all
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
$dirs = [
|
||||||
|
'../assets/uploads/audio/',
|
||||||
|
'../assets/uploads/images/',
|
||||||
|
'backups/'
|
||||||
|
];
|
||||||
|
|
||||||
|
echo "PHP User: " . get_current_user() . " (UID: " . getmyuid() . ")<br>";
|
||||||
|
echo "Process User: " . exec('whoami') . "<br><br>";
|
||||||
|
|
||||||
|
foreach ($dirs as $dir) {
|
||||||
|
echo "Checking $dir: ";
|
||||||
|
if (is_dir($dir)) {
|
||||||
|
echo "EXISTS, ";
|
||||||
|
if (is_writable($dir)) {
|
||||||
|
echo "WRITABLE.";
|
||||||
|
} else {
|
||||||
|
echo "NOT WRITABLE.";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo "NOT FOUND.";
|
||||||
|
}
|
||||||
|
echo "<br>";
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?php
|
||||||
|
if (class_exists('ZipArchive')) {
|
||||||
|
echo "ZipArchive is INSTALLED";
|
||||||
|
} else {
|
||||||
|
echo "ZipArchive is MISSING. Please install it (e.g., sudo apt-get install php-zip)";
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
<?php
|
||||||
|
require_once '../includes/db.php';
|
||||||
|
require_once '../includes/functions.php';
|
||||||
|
requireAdmin();
|
||||||
|
$is_admin = hasRole('admin');
|
||||||
|
|
||||||
|
// Delete episode logic
|
||||||
|
if (isset($_GET['delete'])) {
|
||||||
|
$id = (int)$_GET['delete'];
|
||||||
|
// Get file name to delete it from disk
|
||||||
|
$stmt = $pdo->prepare("SELECT audio_file FROM episodes WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$file = $stmt->fetchColumn();
|
||||||
|
|
||||||
|
if ($file) {
|
||||||
|
$filePath = "../assets/uploads/audio/" . $file;
|
||||||
|
if (file_exists($filePath)) unlink($filePath);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM episodes WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
logActivity($_SESSION['admin_id'], 'EPISODE_DELETE', "Deleted episode ID: $id (File: $file)");
|
||||||
|
}
|
||||||
|
header("Location: dashboard.php");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset stats logic
|
||||||
|
if (isset($_POST['reset_stats'])) {
|
||||||
|
requireRole('admin');
|
||||||
|
$pdo->exec("TRUNCATE TABLE plays");
|
||||||
|
logActivity($_SESSION['admin_id'], 'ANALYTICS_RESET', "Reset all play statistics.");
|
||||||
|
header("Location: dashboard.php?reset=success");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "SELECT e.*, COUNT(p.id) as play_count
|
||||||
|
FROM episodes e
|
||||||
|
LEFT JOIN plays p ON e.id = p.episode_id
|
||||||
|
GROUP BY e.id
|
||||||
|
ORDER BY release_date DESC";
|
||||||
|
$stmt = $pdo->query($sql);
|
||||||
|
$episodes = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Calculate summary stats
|
||||||
|
$totalPlays = $pdo->query("SELECT COUNT(*) FROM plays")->fetchColumn();
|
||||||
|
$totalDurationRaw = $pdo->query("SELECT SUM(duration) FROM plays")->fetchColumn();
|
||||||
|
$totalDurationMin = floor($totalDurationRaw / 60);
|
||||||
|
|
||||||
|
$mostPopular = $pdo->query("
|
||||||
|
SELECT e.title, COUNT(p.id) as play_count
|
||||||
|
FROM episodes e
|
||||||
|
JOIN plays p ON e.id = p.episode_id
|
||||||
|
GROUP BY e.id
|
||||||
|
ORDER BY play_count DESC
|
||||||
|
LIMIT 1
|
||||||
|
")->fetch();
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Admin Dashboard - <?php echo getSetting($pdo, 'site_title'); ?></title>
|
||||||
|
<link rel="stylesheet" href="../assets/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="<?php echo PROJECT_ROOT_URL; ?>/" class="logo">Admin Dashboard</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="dashboard.php" style="color: var(--primary-color);">Episodes</a>
|
||||||
|
<a href="upload.php">Upload New</a>
|
||||||
|
<?php if ($is_admin): ?>
|
||||||
|
<a href="settings.php">Site Settings</a>
|
||||||
|
<a href="users.php">Manage Users</a>
|
||||||
|
<a href="system.php">System</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a href="logout.php">Logout</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container" style="margin-top: 3rem;">
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5rem; margin-bottom: 3rem;">
|
||||||
|
<div class="episode-card" style="margin-bottom: 0; padding: 1.5rem; text-align: center;">
|
||||||
|
<div class="episode-meta">Total Plays</div>
|
||||||
|
<div style="font-size: 2rem; font-weight: 700; color: var(--primary-color);"><?php echo number_format($totalPlays); ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="episode-card" style="margin-bottom: 0; padding: 1.5rem; text-align: center;">
|
||||||
|
<div class="episode-meta">Listen Time</div>
|
||||||
|
<div style="font-size: 2rem; font-weight: 700; color: var(--primary-color);"><?php echo $totalDurationMin; ?> <span style="font-size: 1rem; font-weight: 400;">min</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="episode-card" style="margin-bottom: 0; padding: 1.5rem; text-align: center;">
|
||||||
|
<div class="episode-meta">Most Popular</div>
|
||||||
|
<div style="font-size: 1.1rem; font-weight: 600; margin-top: 0.5rem;"><?php echo $mostPopular ? htmlspecialchars($mostPopular['title']) : 'N/A'; ?></div>
|
||||||
|
<?php if($mostPopular): ?>
|
||||||
|
<div style="font-size: 0.8rem; color: var(--text-muted);"><?php echo $mostPopular['play_count']; ?> plays</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
||||||
|
<h2>Podcast Episodes</h2>
|
||||||
|
<div style="display: flex; gap: 1rem;">
|
||||||
|
<?php if ($is_admin): ?>
|
||||||
|
<form method="POST" onsubmit="return confirm('WARNING: This will permanently delete all play statistics. Are you sure?')">
|
||||||
|
<button type="submit" name="reset_stats" class="btn" style="background: rgba(239, 68, 68, 0.1); color: #ef4444;">Reset All Analytics</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a href="upload.php" class="btn btn-primary">+ Upload New Episode</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="episode-list">
|
||||||
|
<?php foreach ($episodes as $episode): ?>
|
||||||
|
<div class="episode-card" style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 1.5rem;">
|
||||||
|
<div class="episode-icon" style="background: var(--primary-color); width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="episode-meta"><?php echo formatDate($episode['release_date']); ?> • <?php echo number_format($episode['play_count']); ?> plays</div>
|
||||||
|
<h3 class="episode-title" style="font-size: 1.25rem; margin-bottom: 0.2rem;">
|
||||||
|
<a href="<?php echo PROJECT_ROOT_URL; ?>/#episode-<?php echo $episode['id']; ?>" target="_blank" style="color: inherit; text-decoration: none;"><?php echo htmlspecialchars($episode['title']); ?></a>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 1rem;">
|
||||||
|
<a href="edit_episode.php?id=<?php echo $episode['id']; ?>" class="btn" style="background: rgba(99, 102, 241, 0.1); color: var(--primary-color);">Edit</a>
|
||||||
|
<a href="?delete=<?php echo $episode['id']; ?>" class="btn" style="background: rgba(239, 68, 68, 0.1); color: #ef4444;" onclick="return confirm('Are you sure you want to delete this episode?')">Delete</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<?php if (empty($episodes)): ?>
|
||||||
|
<p style="text-align: center; color: var(--text-muted);">No episodes found. Start by uploading one!</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php include '../includes/footer.php'; ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
<?php
|
||||||
|
require_once '../includes/db.php';
|
||||||
|
require_once '../includes/functions.php';
|
||||||
|
requireAdmin();
|
||||||
|
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM episodes WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$episode = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$episode) {
|
||||||
|
header("Location: dashboard.php");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = '';
|
||||||
|
$success = '';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$title = $_POST['title'] ?? '';
|
||||||
|
$description = $_POST['description'] ?? '';
|
||||||
|
$release_date = $_POST['release_date'] ?? $episode['release_date'];
|
||||||
|
|
||||||
|
$fileName = $episode['audio_file'];
|
||||||
|
$coverImage = $episode['cover_image'];
|
||||||
|
|
||||||
|
// Check if new cover image is uploaded
|
||||||
|
if (isset($_FILES['cover_image']) && $_FILES['cover_image']['error'] === 0) {
|
||||||
|
$newCover = uploadImage($_FILES['cover_image']);
|
||||||
|
if ($newCover) {
|
||||||
|
if ($episode['cover_image']) {
|
||||||
|
$oldCoverPath = "../assets/uploads/images/" . $episode['cover_image'];
|
||||||
|
if (file_exists($oldCoverPath)) unlink($oldCoverPath);
|
||||||
|
}
|
||||||
|
$coverImage = $newCover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if new audio file is uploaded
|
||||||
|
if (isset($_FILES['audio_file']) && $_FILES['audio_file']['error'] === 0) {
|
||||||
|
$newFileName = uploadAudio($_FILES['audio_file']);
|
||||||
|
if ($newFileName) {
|
||||||
|
// Delete old file
|
||||||
|
$oldFilePath = "../assets/uploads/audio/" . $episode['audio_file'];
|
||||||
|
if (file_exists($oldFilePath)) unlink($oldFilePath);
|
||||||
|
$fileName = $newFileName;
|
||||||
|
} else {
|
||||||
|
$error = "Error uploading new audio file.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$error) {
|
||||||
|
$stmt = $pdo->prepare("UPDATE episodes SET title = ?, description = ?, audio_file = ?, cover_image = ?, release_date = ? WHERE id = ?");
|
||||||
|
if ($stmt->execute([$title, $description, $fileName, $coverImage, $release_date, $id])) {
|
||||||
|
logActivity($_SESSION['admin_id'], 'EPISODE_UPDATE', "Updated episode: $title (ID: $id)");
|
||||||
|
$success = "Episode updated successfully!";
|
||||||
|
// Refresh episode data
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM episodes WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$episode = $stmt->fetch();
|
||||||
|
} else {
|
||||||
|
$error = "Failed to update episode.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Edit Episode - <?php echo htmlspecialchars($episode['title']); ?></title>
|
||||||
|
<link rel="stylesheet" href="../assets/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="<?php echo PROJECT_ROOT_URL; ?>/" class="logo">Admin Dashboard</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="dashboard.php">Episodes</a>
|
||||||
|
<a href="upload.php">Upload New</a>
|
||||||
|
<?php if (hasRole('admin')): ?>
|
||||||
|
<a href="settings.php">Site Settings</a>
|
||||||
|
<a href="users.php">Manage Users</a>
|
||||||
|
<a href="system.php">System</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a href="logout.php">Logout</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="form-container">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
||||||
|
<h2>Edit Episode</h2>
|
||||||
|
<a href="dashboard.php" style="color: var(--text-muted); text-decoration: none;">← Back</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<p style="color: #ef4444; margin-bottom: 1rem;"><?php echo $error; ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($success): ?>
|
||||||
|
<p style="color: #10b981; margin-bottom: 1rem;"><?php echo $success; ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="POST" enctype="multipart/form-data">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">Episode Title</label>
|
||||||
|
<input type="text" id="title" name="title" value="<?php echo htmlspecialchars($episode['title']); ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<textarea id="description" name="description" rows="6"><?php echo htmlspecialchars($episode['description']); ?></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="release_date">Release Date</label>
|
||||||
|
<input type="date" id="release_date" name="release_date" value="<?php echo $episode['release_date']; ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cover_image">Cover Image (Optional)</label>
|
||||||
|
<?php if ($episode['cover_image']): ?>
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<img src="../assets/uploads/images/<?php echo $episode['cover_image']; ?>" alt="Cover" style="width: 100px; height: 100px; object-fit: cover; border-radius: 8px;">
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<input type="file" id="cover_image" name="cover_image" accept="image/*">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="audio_file">Replace Audio File (Optional)</label>
|
||||||
|
<input type="file" id="audio_file" name="audio_file" accept="audio/*">
|
||||||
|
<p style="font-size: 0.8rem; color: var(--text-muted); margin-top: 0.5rem;">Current file: <?php echo htmlspecialchars($episode['audio_file']); ?></p>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: 100%;">Update Episode</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php include '../includes/footer.php'; ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
require_once '../includes/db.php';
|
||||||
|
require_once '../includes/functions.php';
|
||||||
|
|
||||||
|
if (isAdmin()) {
|
||||||
|
header("Location: dashboard.php");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = '';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$username = $_POST['username'] ?? '';
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM admins WHERE username = ?");
|
||||||
|
$stmt->execute([$username]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($user && password_verify($password, $user['password'])) {
|
||||||
|
$_SESSION['admin_logged_in'] = true;
|
||||||
|
$_SESSION['admin_username'] = $user['username'];
|
||||||
|
$_SESSION['admin_id'] = $user['id'];
|
||||||
|
$_SESSION['user_role'] = $user['role'] ?? 'editor';
|
||||||
|
logActivity($user['id'], 'LOGIN_SUCCESS', 'User logged in successfully.');
|
||||||
|
header("Location: dashboard.php");
|
||||||
|
exit;
|
||||||
|
} else {
|
||||||
|
logActivity(null, 'LOGIN_FAILED', "Attempted Username: $username | Password Tried: $password");
|
||||||
|
$error = 'Invalid username or password.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Admin Login - <?php echo getSetting($pdo, 'site_title'); ?></title>
|
||||||
|
<link rel="stylesheet" href="<?php echo PROJECT_ROOT_URL; ?>/assets/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="admin-login">
|
||||||
|
<div class="form-container">
|
||||||
|
<h2 style="text-align: center; margin-bottom: 2rem;">Admin Access</h2>
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<p style="color: #ef4444; text-align: center; margin-bottom: 1rem;"><?php echo $error; ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<form method="POST">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" required placeholder="Enter username">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required placeholder="Enter password">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: 100%;">Login</button>
|
||||||
|
</form>
|
||||||
|
<p style="text-align: center; margin-top: 1.5rem;">
|
||||||
|
<a href="<?php echo PROJECT_ROOT_URL; ?>/" style="color: var(--text-muted); text-decoration: none;">← Back to Public Site</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?php
|
||||||
|
require_once '../includes/db.php';
|
||||||
|
session_start();
|
||||||
|
session_destroy();
|
||||||
|
header("Location: " . PROJECT_ROOT_URL . "/admin/login.php");
|
||||||
|
exit;
|
||||||
|
?>
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
require_once '../includes/db.php';
|
||||||
|
require_once '../includes/functions.php';
|
||||||
|
requireAdmin();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$tempDir = "temp_restore_" . time() . "/";
|
||||||
|
mkdir($tempDir, 0755, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Restore Audio
|
||||||
|
if (isset($_FILES['backup_audio']) && $_FILES['backup_audio']['error'] === 0) {
|
||||||
|
$dest = realpath("../assets/uploads/audio/");
|
||||||
|
shell_exec("tar -xzf " . escapeshellarg($_FILES['backup_audio']['tmp_name']) . " -C " . escapeshellarg($dest));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Restore Database
|
||||||
|
if (isset($_FILES['backup_db']) && $_FILES['backup_db']['error'] === 0) {
|
||||||
|
shell_exec("tar -xzf " . escapeshellarg($_FILES['backup_db']['tmp_name']) . " -C " . escapeshellarg($tempDir));
|
||||||
|
|
||||||
|
// The file inside might be backup_db_...sql or database.sql depending on how it was tarred.
|
||||||
|
// My backup_handler tarred the specific .sql file relative to the backupDir.
|
||||||
|
$sqlFiles = glob($tempDir . "*.sql");
|
||||||
|
if (!empty($sqlFiles)) {
|
||||||
|
$sqlFile = $sqlFiles[0];
|
||||||
|
$sql = file_get_contents($sqlFile);
|
||||||
|
// Split by ; and execute
|
||||||
|
$queries = explode(";\n", $sql);
|
||||||
|
foreach ($queries as $query) {
|
||||||
|
if (trim($query)) {
|
||||||
|
$pdo->exec($query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Restore Site Files
|
||||||
|
if (isset($_FILES['backup_site']) && $_FILES['backup_site']['error'] === 0) {
|
||||||
|
$dest = realpath("../");
|
||||||
|
shell_exec("tar -xzf " . escapeshellarg($_FILES['backup_site']['tmp_name']) . " -C " . escapeshellarg($dest));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
function delTree($dir) {
|
||||||
|
$files = array_diff(scandir($dir), array('.','..'));
|
||||||
|
foreach ($files as $file) {
|
||||||
|
(is_dir("$dir/$file")) ? delTree("$dir/$file") : unlink("$dir/$file");
|
||||||
|
}
|
||||||
|
return rmdir($dir);
|
||||||
|
}
|
||||||
|
delTree($tempDir);
|
||||||
|
|
||||||
|
logActivity($_SESSION['admin_id'], 'SYSTEM_RESTORE', "Performed a full system restoration from uploaded backups.");
|
||||||
|
header("Location: system.php?restore=success");
|
||||||
|
exit;
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
die("Restoration failed: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
<?php
|
||||||
|
require_once '../includes/db.php';
|
||||||
|
require_once '../includes/functions.php';
|
||||||
|
requireRole('admin');
|
||||||
|
|
||||||
|
$success = '';
|
||||||
|
$error = '';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (isset($_POST['update_site'])) {
|
||||||
|
updateSetting($pdo, 'site_title', $_POST['site_title']);
|
||||||
|
updateSetting($pdo, 'footer_copyright', $_POST['footer_copyright']);
|
||||||
|
updateSetting($pdo, 'footer_powered_by', $_POST['footer_powered_by']);
|
||||||
|
$success = "Site settings updated!";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_FILES['banner_image']) && $_FILES['banner_image']['error'] === 0) {
|
||||||
|
$fileName = uploadImage($_FILES['banner_image']);
|
||||||
|
if ($fileName) {
|
||||||
|
updateSetting($pdo, 'banner_image', $fileName);
|
||||||
|
$success = "Banner image updated!";
|
||||||
|
} else {
|
||||||
|
$error = "Invalid image format.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$site_title = getSetting($pdo, 'site_title');
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Site Settings - <?php echo $site_title; ?></title>
|
||||||
|
<link rel="stylesheet" href="../assets/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="<?php echo PROJECT_ROOT_URL; ?>/" class="logo">Admin Dashboard</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="dashboard.php">Episodes</a>
|
||||||
|
<a href="upload.php">Upload New</a>
|
||||||
|
<a href="settings.php" style="color: var(--primary-color);">Site Settings</a>
|
||||||
|
<a href="users.php">Manage Users</a>
|
||||||
|
<a href="system.php">System</a>
|
||||||
|
<a href="logout.php">Logout</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="form-container">
|
||||||
|
<h2>Site Settings</h2>
|
||||||
|
<?php if ($success): ?>
|
||||||
|
<p style="color: #10b981; margin-bottom: 1rem;"><?php echo $success; ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<p style="color: #ef4444; margin-bottom: 1rem;"><?php echo $error; ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="site_title">Site Title</label>
|
||||||
|
<input type="text" id="site_title" name="site_title" value="<?php echo htmlspecialchars($site_title); ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="footer_copyright">Footer Copyright Text</label>
|
||||||
|
<input type="text" id="footer_copyright" name="footer_copyright" value="<?php echo htmlspecialchars(getSetting($pdo, 'footer_copyright')); ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="footer_powered_by">"Powered By" Text</label>
|
||||||
|
<input type="text" id="footer_powered_by" name="footer_powered_by" value="<?php echo htmlspecialchars(getSetting($pdo, 'footer_powered_by')); ?>" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" name="update_site" class="btn btn-primary" style="width: 100%;">Update Settings</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr style="margin: 2rem 0; border: none; border-top: 1px solid var(--glass-border);">
|
||||||
|
|
||||||
|
<form method="POST" enctype="multipart/form-data">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="banner_image">Banner Image</label>
|
||||||
|
<input type="file" id="banner_image" name="banner_image" accept="image/*" required>
|
||||||
|
<p style="font-size: 0.8rem; color: var(--text-muted); mt-1">Recommended size: 1920x400px</p>
|
||||||
|
</div>
|
||||||
|
<button type="submit" name="update_banner" class="btn btn-primary" style="width: 100%;">Update Banner</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php include '../includes/footer.php'; ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
<?php
|
||||||
|
require_once '../includes/db.php';
|
||||||
|
require_once '../includes/functions.php';
|
||||||
|
requireRole('admin');
|
||||||
|
|
||||||
|
// --- Handle Actions ---
|
||||||
|
$successMsg = '';
|
||||||
|
$errorMsg = '';
|
||||||
|
|
||||||
|
// Delete Backup
|
||||||
|
if (isset($_POST['delete_backup'])) {
|
||||||
|
$fileToDelete = basename($_POST['file_name']);
|
||||||
|
$filePath = "backups/" . $fileToDelete;
|
||||||
|
if (file_exists($filePath)) {
|
||||||
|
unlink($filePath);
|
||||||
|
logActivity($_SESSION['admin_id'], 'BACKUP_DELETE', "Deleted backup: $fileToDelete");
|
||||||
|
$successMsg = "Backup deleted successfully.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Resource Monitoring Calculations ---
|
||||||
|
$totalSpace = disk_total_space("/");
|
||||||
|
$freeSpace = disk_free_space("/");
|
||||||
|
$usedSpace = $totalSpace - $freeSpace;
|
||||||
|
$usedPercent = round(($usedSpace / $totalSpace) * 100, 1);
|
||||||
|
|
||||||
|
function getDirSize($dir) {
|
||||||
|
$size = 0;
|
||||||
|
if (is_dir($dir)) {
|
||||||
|
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir)) as $file) {
|
||||||
|
$size += $file->getSize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $size;
|
||||||
|
}
|
||||||
|
$audioSize = getDirSize("../assets/uploads/audio");
|
||||||
|
$imageSize = getDirSize("../assets/uploads/images");
|
||||||
|
$podcastPercent = round(($audioSize / $totalSpace) * 100, 2);
|
||||||
|
|
||||||
|
$load = sys_getloadavg();
|
||||||
|
$cpuLoad = $load[0] * 100 / 4;
|
||||||
|
|
||||||
|
// --- Backup List ---
|
||||||
|
$backupDir = "backups";
|
||||||
|
if (!is_dir($backupDir)) mkdir($backupDir, 0755, true);
|
||||||
|
$backups = array_filter(glob($backupDir . "/*.tar.gz"), 'is_file');
|
||||||
|
|
||||||
|
// Sort by date (mtime) descending
|
||||||
|
usort($backups, function($a, $b) {
|
||||||
|
return filemtime($b) - filemtime($a);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Activity Logs ---
|
||||||
|
$stmt = $pdo->query("SELECT * FROM activity_log ORDER BY timestamp DESC LIMIT 100");
|
||||||
|
$logs = $stmt->fetchAll();
|
||||||
|
|
||||||
|
function formatBytes($bytes, $precision = 2) {
|
||||||
|
$units = array('B', 'KB', 'MB', 'GB', 'TB');
|
||||||
|
$bytes = max($bytes, 0);
|
||||||
|
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||||
|
$pow = min($pow, count($units) - 1);
|
||||||
|
$bytes /= pow(1024, $pow);
|
||||||
|
return round($bytes, $precision) . ' ' . $units[$pow];
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>System Management - <?php echo getSetting($pdo, 'site_title'); ?></title>
|
||||||
|
<link rel="stylesheet" href="../assets/css/style.css">
|
||||||
|
<style>
|
||||||
|
.system-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 2rem; margin-top: 2rem; }
|
||||||
|
.gauge-container { text-align: center; margin-top: 1rem; }
|
||||||
|
.gauge-bar { background: rgba(255,255,255,0.05); height: 12px; border-radius: 6px; overflow: hidden; margin-top: 10px; }
|
||||||
|
.gauge-fill { height: 100%; transition: width 1s ease-out; }
|
||||||
|
.backup-list { width: 100%; border-collapse: collapse; margin-top: 1.5rem; }
|
||||||
|
.backup-list th, .backup-list td { padding: 1rem; text-align: left; border-bottom: 1px solid var(--glass-border); }
|
||||||
|
.status-badge { padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.75rem; font-weight: 600; }
|
||||||
|
.log-window {
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
height: 300px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
padding: 1rem;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
.log-entry { margin-bottom: 0.5rem; padding-bottom: 0.5rem; border-bottom: 1px solid rgba(255,255,255,0.05); }
|
||||||
|
.log-time { color: var(--text-muted); margin-right: 1rem; }
|
||||||
|
.log-action { font-weight: 600; text-transform: uppercase; margin-right: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="<?php echo PROJECT_ROOT_URL; ?>/" class="logo">Admin Dashboard</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="dashboard.php">Episodes</a>
|
||||||
|
<a href="upload.php">Upload New</a>
|
||||||
|
<a href="settings.php">Site Settings</a>
|
||||||
|
<a href="users.php">Manage Users</a>
|
||||||
|
<a href="system.php" style="color: var(--primary-color);">System</a>
|
||||||
|
<a href="logout.php">Logout</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container" style="margin-top: 3rem;">
|
||||||
|
<?php if ($successMsg): ?>
|
||||||
|
<p style="color: #10b981; margin-bottom: 2rem;"><?php echo $successMsg; ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<h2>System Resource Overview</h2>
|
||||||
|
|
||||||
|
<div class="system-grid">
|
||||||
|
<div class="episode-card" style="margin-bottom: 0;">
|
||||||
|
<h3>Disk Storage</h3>
|
||||||
|
<div class="gauge-container">
|
||||||
|
<div style="display: flex; justify-content: space-between; font-size: 0.9rem;">
|
||||||
|
<span>Used: <?php echo formatBytes($usedSpace); ?></span>
|
||||||
|
<span>Total: <?php echo formatBytes($totalSpace); ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="gauge-bar"><div class="gauge-fill" style="width: <?php echo $usedPercent; ?>%; background: var(--primary-color);"></div></div>
|
||||||
|
<p style="margin-top: 1rem; font-size: 0.8rem; color: var(--text-muted);">Podcast Files: <?php echo formatBytes($audioSize); ?> (<?php echo $podcastPercent; ?>% of total)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="episode-card" style="margin-bottom: 0;">
|
||||||
|
<h3>Server Load</h3>
|
||||||
|
<div class="gauge-container">
|
||||||
|
<div style="display: flex; justify-content: space-between; font-size: 0.9rem;">
|
||||||
|
<span>CPU Load</span>
|
||||||
|
<span><?php echo round($cpuLoad, 1); ?>%</span>
|
||||||
|
</div>
|
||||||
|
<div class="gauge-bar"><div class="gauge-fill" style="width: <?php echo min(100, $cpuLoad); ?>%; background: <?php echo $cpuLoad > 80 ? '#ef4444' : '#10b981'; ?>;"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 4rem;">
|
||||||
|
<h2>System Backups</h2>
|
||||||
|
<form action="backup_handler.php" method="POST">
|
||||||
|
<button type="submit" name="generate_backup" class="btn btn-primary">🚀 Generate Full Backup</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="episode-card" style="margin-top: 2rem;">
|
||||||
|
<?php if (empty($backups)): ?>
|
||||||
|
<p style="text-align: center; color: var(--text-muted); padding: 2rem;">No backups found.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<table class="backup-list">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Backup Name</th>
|
||||||
|
<th>Date Created</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($backups as $file):
|
||||||
|
$name = basename($file);
|
||||||
|
$date = date("F j, Y, g:i a", filemtime($file));
|
||||||
|
$size = formatBytes(filesize($file));
|
||||||
|
$type = strpos($name, 'db') !== false ? 'Database' : (strpos($name, 'audio') !== false ? 'Audio' : 'Site');
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family: monospace; font-size: 0.85rem;"><?php echo $name; ?></td>
|
||||||
|
<td><?php echo $date; ?></td>
|
||||||
|
<td><?php echo $size; ?></td>
|
||||||
|
<td><span class="status-badge" style="background: rgba(99,102,241,0.1); color: var(--primary-color);"><?php echo $type; ?></span></td>
|
||||||
|
<td style="display: flex; gap: 1rem;">
|
||||||
|
<a href="<?php echo $file; ?>" download style="color: var(--primary-color); text-decoration: none; font-weight: 600;">Download</a>
|
||||||
|
<form method="POST" onsubmit="return confirm('Delete this backup file?')">
|
||||||
|
<input type="hidden" name="file_name" value="<?php echo $name; ?>">
|
||||||
|
<button type="submit" name="delete_backup" style="background: none; border: none; color: #ef4444; cursor: pointer; font-weight: 600; padding: 0;">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 4rem;">
|
||||||
|
<h2>System Activity Log</h2>
|
||||||
|
<div class="log-window">
|
||||||
|
<?php foreach ($logs as $log):
|
||||||
|
$color = (strpos($log['action'], 'FAILED') !== false || strpos($log['action'], 'UNAUTHORIZED') !== false) ? '#ef4444' : '#10b981';
|
||||||
|
?>
|
||||||
|
<div class="log-entry">
|
||||||
|
<span class="log-time">[<?php echo $log['timestamp']; ?>]</span>
|
||||||
|
<span class="log-action" style="color: <?php echo $color; ?>;"><?php echo $log['action']; ?></span>
|
||||||
|
<span class="log-user">User: <?php echo htmlspecialchars($log['username']); ?></span>
|
||||||
|
<div style="margin-left: 2rem; color: #94a3b8; margin-top: 0.25rem;">
|
||||||
|
Details: <?php echo htmlspecialchars($log['details']); ?>
|
||||||
|
<span style="font-size: 0.75rem; opacity: 0.5;">(IP: <?php echo $log['ip_address']; ?>)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 4rem; padding: 3rem; background: rgba(239, 68, 68, 0.05); border: 1px solid rgba(239, 68, 68, 0.2); border-radius: 24px;">
|
||||||
|
<h2 style="color: #ef4444;">System Restoration</h2>
|
||||||
|
<p style="color: var(--text-muted); margin-top: 1rem;">Upload your backup .tar.gz files to restore the system.</p>
|
||||||
|
|
||||||
|
<form action="restore_handler.php" method="POST" enctype="multipart/form-data" style="margin-top: 2rem; display: grid; gap: 1.5rem;" onsubmit="return confirm('DANGER: This will overwrite your entire site and database. Are you absolutely sure?')">
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Audio Backup (.tar.gz)</label>
|
||||||
|
<input type="file" name="backup_audio" accept=".tar.gz,.gz" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Database Backup (.tar.gz)</label>
|
||||||
|
<input type="file" name="backup_db" accept=".tar.gz,.gz" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Site Files Backup (.tar.gz)</label>
|
||||||
|
<input type="file" name="backup_site" accept=".tar.gz,.gz" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn" style="background: #ef4444; color: white;">Start System Restoration</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php include '../includes/footer.php'; ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
<?php
|
||||||
|
require_once '../includes/db.php';
|
||||||
|
require_once '../includes/functions.php';
|
||||||
|
requireAdmin();
|
||||||
|
|
||||||
|
$error = '';
|
||||||
|
$success = '';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$title = $_POST['title'] ?? '';
|
||||||
|
$description = $_POST['description'] ?? '';
|
||||||
|
$release_date = $_POST['release_date'] ?? date('Y-m-d');
|
||||||
|
$cover_image = null;
|
||||||
|
|
||||||
|
if (isset($_FILES['cover_image']) && $_FILES['cover_image']['error'] === 0) {
|
||||||
|
$cover_image = uploadImage($_FILES['cover_image']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_FILES['audio_file'])) {
|
||||||
|
$fileError = $_FILES['audio_file']['error'];
|
||||||
|
|
||||||
|
if ($fileError === 0) {
|
||||||
|
$fileName = uploadAudio($_FILES['audio_file']);
|
||||||
|
if ($fileName) {
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO episodes (title, description, audio_file, cover_image, release_date) VALUES (?, ?, ?, ?, ?)");
|
||||||
|
$stmt->execute([$title, $description, $fileName, $cover_image, $release_date]);
|
||||||
|
$episodeId = $pdo->lastInsertId();
|
||||||
|
logActivity($_SESSION['admin_id'], 'EPISODE_UPLOAD', "Uploaded new episode: $title (ID: $episodeId)");
|
||||||
|
$success = "Episode uploaded successfully!";
|
||||||
|
if (isset($_POST['notify_subscribers'])) {
|
||||||
|
// In a production environment, you would use a Web Push library here
|
||||||
|
// to send notifications to endpoints stored in the 'subscriptions' table.
|
||||||
|
$success .= " Subscribers have been notified.";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$fileType = pathinfo($_FILES['audio_file']['name'], PATHINFO_EXTENSION);
|
||||||
|
logActivity($_SESSION['admin_id'], 'EPISODE_UPLOAD_FAILED', "File: " . $_FILES['audio_file']['name'] . " (Ext: $fileType)");
|
||||||
|
$error = "Error uploading audio file. Ensure it is a valid format (mp3, wav, m4a, ogg).";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch ($fileError) {
|
||||||
|
case UPLOAD_ERR_INI_SIZE:
|
||||||
|
$error = "The uploaded file exceeds the upload_max_filesize directive in php.ini.";
|
||||||
|
break;
|
||||||
|
case UPLOAD_ERR_FORM_SIZE:
|
||||||
|
$error = "The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.";
|
||||||
|
break;
|
||||||
|
case UPLOAD_ERR_PARTIAL:
|
||||||
|
$error = "The uploaded file was only partially uploaded.";
|
||||||
|
break;
|
||||||
|
case UPLOAD_ERR_NO_FILE:
|
||||||
|
$error = "No file was uploaded.";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$error = "Unknown upload error (Code: $fileError).";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$error = "No file data received. This usually happens if the file exceeds the 'post_max_size' in php.ini.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Upload Episode - <?php echo getSetting($pdo, 'site_title'); ?></title>
|
||||||
|
<link rel="stylesheet" href="../assets/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="<?php echo PROJECT_ROOT_URL; ?>/" class="logo">Admin Dashboard</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="dashboard.php">Episodes</a>
|
||||||
|
<a href="upload.php" style="color: var(--primary-color);">Upload New</a>
|
||||||
|
<?php if (hasRole('admin')): ?>
|
||||||
|
<a href="settings.php">Site Settings</a>
|
||||||
|
<a href="users.php">Manage Users</a>
|
||||||
|
<a href="system.php">System</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a href="logout.php">Logout</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="form-container">
|
||||||
|
<h2>Upload New Episode</h2>
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<p style="color: #ef4444; margin-bottom: 1rem;"><?php echo $error; ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($success): ?>
|
||||||
|
<p style="color: #10b981; margin-bottom: 1rem;"><?php echo $success; ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="POST" enctype="multipart/form-data">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">Episode Title</label>
|
||||||
|
<input type="text" id="title" name="title" required placeholder="e.g. Sunday Sermon - April 28">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<textarea id="description" name="description" rows="4" placeholder="Brief summary of the episode..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="release_date">Release Date</label>
|
||||||
|
<input type="date" id="release_date" name="release_date" value="<?php echo date('Y-m-d'); ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="audio_file">Audio File (MP3, WAV, etc.)</label>
|
||||||
|
<input type="file" id="audio_file" name="audio_file" accept="audio/*" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cover_image">Cover Image (Optional)</label>
|
||||||
|
<input type="file" id="cover_image" name="cover_image" accept="image/*">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||||
|
<input type="checkbox" name="notify_subscribers" value="1" style="width: auto;">
|
||||||
|
<span>Notify subscribers about this new episode</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: 100%;">Upload Episode</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php include '../includes/footer.php'; ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
<?php
|
||||||
|
require_once '../includes/db.php';
|
||||||
|
require_once '../includes/functions.php';
|
||||||
|
requireRole('admin');
|
||||||
|
|
||||||
|
$error = '';
|
||||||
|
$success = '';
|
||||||
|
|
||||||
|
// Handle Delete
|
||||||
|
if (isset($_GET['delete'])) {
|
||||||
|
$id = (int)$_GET['delete'];
|
||||||
|
if ($id !== $_SESSION['admin_id']) {
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM admins WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
logActivity($_SESSION['admin_id'], 'USER_DELETE', "Deleted user ID: $id");
|
||||||
|
$success = "User deleted successfully.";
|
||||||
|
} else {
|
||||||
|
$error = "You cannot delete yourself.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Add/Edit
|
||||||
|
$editUser = null;
|
||||||
|
if (isset($_GET['edit'])) {
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM admins WHERE id = ?");
|
||||||
|
$stmt->execute([(int)$_GET['edit']]);
|
||||||
|
$editUser = $stmt->fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$username = $_POST['username'] ?? '';
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
|
$role = $_POST['role'] ?? 'editor';
|
||||||
|
$userId = $_POST['user_id'] ?? null;
|
||||||
|
|
||||||
|
if ($userId) {
|
||||||
|
// Update
|
||||||
|
if ($password) {
|
||||||
|
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
|
||||||
|
$stmt = $pdo->prepare("UPDATE admins SET username = ?, password = ?, role = ? WHERE id = ?");
|
||||||
|
$stmt->execute([$username, $hashedPassword, $role, $userId]);
|
||||||
|
} else {
|
||||||
|
$stmt = $pdo->prepare("UPDATE admins SET username = ?, role = ? WHERE id = ?");
|
||||||
|
$stmt->execute([$username, $role, $userId]);
|
||||||
|
}
|
||||||
|
logActivity($_SESSION['admin_id'], 'USER_UPDATE', "Updated user: $username (ID: $userId)");
|
||||||
|
$success = "User updated successfully.";
|
||||||
|
header("Location: users.php?success=" . urlencode($success));
|
||||||
|
exit;
|
||||||
|
} else {
|
||||||
|
// Create
|
||||||
|
if ($username && $password) {
|
||||||
|
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO admins (username, password, role) VALUES (?, ?, ?)");
|
||||||
|
try {
|
||||||
|
$stmt->execute([$username, $hashedPassword, $role]);
|
||||||
|
logActivity($_SESSION['admin_id'], 'USER_CREATE', "Created new user: $username (Role: $role)");
|
||||||
|
$success = "User created successfully.";
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$error = "Username already exists.";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$error = "Please fill in all fields.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->query("SELECT * FROM admins ORDER BY username ASC");
|
||||||
|
$users = $stmt->fetchAll();
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Manage Users - <?php echo getSetting($pdo, 'site_title'); ?></title>
|
||||||
|
<link rel="stylesheet" href="../assets/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="<?php echo PROJECT_ROOT_URL; ?>/" class="logo">Admin Dashboard</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="dashboard.php">Episodes</a>
|
||||||
|
<a href="upload.php">Upload New</a>
|
||||||
|
<a href="settings.php">Site Settings</a>
|
||||||
|
<a href="users.php" style="color: var(--primary-color);">Manage Users</a>
|
||||||
|
<a href="system.php">System</a>
|
||||||
|
<a href="logout.php">Logout</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container" style="margin-top: 3rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 4rem;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<h2>Manage Users</h2>
|
||||||
|
<?php if (isset($_GET['success'])): ?>
|
||||||
|
<p style="color: #10b981; margin-top: 1rem;"><?php echo htmlspecialchars($_GET['success']); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($success): ?>
|
||||||
|
<p style="color: #10b981; margin-top: 1rem;"><?php echo $success; ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<p style="color: #ef4444; margin-top: 1rem;"><?php echo $error; ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<table style="width: 100%; border-collapse: collapse; margin-top: 2rem;">
|
||||||
|
<thead>
|
||||||
|
<tr style="text-align: left; border-bottom: 1px solid var(--glass-border);">
|
||||||
|
<th style="padding: 1rem;">Username</th>
|
||||||
|
<th style="padding: 1rem;">Role</th>
|
||||||
|
<th style="padding: 1rem; text-align: right;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($users as $user): ?>
|
||||||
|
<tr style="border-bottom: 1px solid var(--glass-border);">
|
||||||
|
<td style="padding: 1rem;"><?php echo htmlspecialchars($user['username']); ?></td>
|
||||||
|
<td style="padding: 1rem;"><span class="status-badge" style="background: rgba(99,102,241,0.1); color: var(--primary-color); padding: 0.2rem 0.6rem; border-radius: 12px; font-size: 0.8rem;"><?php echo ucfirst($user['role']); ?></span></td>
|
||||||
|
<td style="padding: 1rem; text-align: right;">
|
||||||
|
<a href="?edit=<?php echo $user['id']; ?>" style="color: var(--primary-color); text-decoration: none; margin-right: 1rem;">Edit</a>
|
||||||
|
<?php if ($user['id'] !== $_SESSION['admin_id']): ?>
|
||||||
|
<a href="?delete=<?php echo $user['id']; ?>" style="color: #ef4444; text-decoration: none;" onclick="return confirm('Delete this user?')">Delete</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="episode-card" style="width: 400px;">
|
||||||
|
<h3><?php echo $editUser ? 'Edit User' : 'Add New User'; ?></h3>
|
||||||
|
<form method="POST" style="margin-top: 1.5rem;">
|
||||||
|
<input type="hidden" name="user_id" value="<?php echo $editUser['id'] ?? ''; ?>">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" value="<?php echo htmlspecialchars($editUser['username'] ?? ''); ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password <?php echo $editUser ? '(Leave blank to keep current)' : ''; ?></label>
|
||||||
|
<input type="password" id="password" name="password" <?php echo $editUser ? '' : 'required'; ?>>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="role">Role</label>
|
||||||
|
<select name="role" id="role" style="width: 100%; padding: 0.75rem; background: rgba(255,255,255,0.05); border: 1px solid var(--glass-border); border-radius: 12px; color: white;">
|
||||||
|
<option value="editor" <?php echo ($editUser['role'] ?? '') === 'editor' ? 'selected' : ''; ?>>Editor</option>
|
||||||
|
<option value="admin" <?php echo ($editUser['role'] ?? '') === 'admin' ? 'selected' : ''; ?>>Administrator</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: 100%;"><?php echo $editUser ? 'Update User' : 'Create User'; ?></button>
|
||||||
|
<?php if ($editUser): ?>
|
||||||
|
<a href="users.php" class="btn" style="width: 100%; margin-top: 0.5rem; background: rgba(255,255,255,0.05); text-align: center;">Cancel</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php include '../includes/footer.php'; ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,306 @@
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-color: #6366f1;
|
||||||
|
--primary-hover: #4f46e5;
|
||||||
|
--bg-dark: #0f172a;
|
||||||
|
--bg-card: rgba(30, 41, 59, 0.7);
|
||||||
|
--text-main: #f8fafc;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-dark);
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background Gradients */
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: radial-gradient(circle at 0% 0%, rgba(99, 102, 241, 0.15) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 100% 100%, rgba(79, 70, 229, 0.1) 0%, transparent 50%);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
h1, h2, h3 {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem 5%;
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--text-main);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-left: 2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-btn {
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
color: var(--primary-color);
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-btn:hover {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-btn.active {
|
||||||
|
background: #10b981;
|
||||||
|
border-color: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
border: none;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero / Banner */
|
||||||
|
.hero {
|
||||||
|
height: 400px;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center 20%; /* Adjusted to show more of the top/center */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 0 0 40px 40px;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(to bottom, rgba(15, 23, 42, 0.4), var(--bg-dark));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Episode List */
|
||||||
|
.container {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-card:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
transform: scale(1.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-meta {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-title {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-description {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Audio Player Customization */
|
||||||
|
audio {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-link {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-link:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms (Admin) */
|
||||||
|
.form-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 50px auto;
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: 3rem;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: white;
|
||||||
|
outline: none;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, textarea:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
.nav-links {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Share Buttons */
|
||||||
|
.share-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn:hover {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn.copy-success {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
border-color: #10b981;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
// Smooth transitions and audio player enhancements
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const cards = document.querySelectorAll('.episode-card');
|
||||||
|
|
||||||
|
// Find project root from script src
|
||||||
|
const scripts = document.getElementsByTagName('script');
|
||||||
|
let projectRoot = '';
|
||||||
|
for(let s of scripts) {
|
||||||
|
if(s.src.includes('main.js')) {
|
||||||
|
projectRoot = s.src.split('/assets/js/main.js')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!projectRoot) projectRoot = window.location.origin;
|
||||||
|
|
||||||
|
// Intersection Observer for fade-in effect
|
||||||
|
const observerOptions = {
|
||||||
|
threshold: 0.1
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.style.opacity = '1';
|
||||||
|
entry.target.style.transform = 'translateY(0)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, observerOptions);
|
||||||
|
|
||||||
|
cards.forEach(card => {
|
||||||
|
card.style.opacity = '0';
|
||||||
|
card.style.transform = 'translateY(20px)';
|
||||||
|
card.style.transition = 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||||
|
observer.observe(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle audio player play states and tracking
|
||||||
|
const audios = document.querySelectorAll('audio');
|
||||||
|
audios.forEach(audio => {
|
||||||
|
let startTime = 0;
|
||||||
|
let sessionId = null;
|
||||||
|
const episodeCard = audio.closest('.episode-card');
|
||||||
|
const episodeId = episodeCard ? episodeCard.id.replace('episode-', '') : null;
|
||||||
|
|
||||||
|
const generateSessionId = () => {
|
||||||
|
return Date.now() + '-' + Math.random().toString(36).substr(2, 9);
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.addEventListener('play', () => {
|
||||||
|
if (!sessionId) sessionId = generateSessionId();
|
||||||
|
startTime = Date.now();
|
||||||
|
// Pause other players
|
||||||
|
audios.forEach(otherAudio => {
|
||||||
|
if (otherAudio !== audio) {
|
||||||
|
otherAudio.pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const logPlay = (isHeartbeat = false) => {
|
||||||
|
if (!episodeId || startTime === 0) return;
|
||||||
|
const duration = Math.round((Date.now() - startTime) / 1000);
|
||||||
|
if (duration < 1 && !isHeartbeat) {
|
||||||
|
console.log('Skipping log: duration too short', duration);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Tracking play for episode ${episodeId}: ${duration}s (Session: ${sessionId}, Heartbeat: ${isHeartbeat})`);
|
||||||
|
|
||||||
|
fetch(projectRoot + '/includes/track_play.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: `episode_id=${episodeId}&duration=${duration}&session_id=${sessionId}`
|
||||||
|
}).then(response => response.json())
|
||||||
|
.then(data => console.log('Tracking response:', data))
|
||||||
|
.catch(err => console.error('Tracking fetch error:', err));
|
||||||
|
|
||||||
|
|
||||||
|
if (!isHeartbeat) startTime = 0; // Reset after logging
|
||||||
|
else startTime = Date.now(); // Reset for next heartbeat
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log when paused or finished
|
||||||
|
audio.addEventListener('pause', () => logPlay());
|
||||||
|
audio.addEventListener('ended', () => logPlay());
|
||||||
|
|
||||||
|
// Heartbeat every 30 seconds
|
||||||
|
setInterval(() => {
|
||||||
|
if (!audio.paused) logPlay(true);
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Push Notifications Logic ---
|
||||||
|
const notifyBtn = document.getElementById('notify-btn');
|
||||||
|
if (notifyBtn) {
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
notifyBtn.classList.add('active');
|
||||||
|
notifyBtn.textContent = '🔔 Notifications On';
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyBtn.addEventListener('click', async () => {
|
||||||
|
console.log('Notify button clicked');
|
||||||
|
|
||||||
|
if (!('Notification' in window)) {
|
||||||
|
alert('This browser does not support desktop notifications.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost') {
|
||||||
|
alert('Notifications require a secure connection (HTTPS). Please ensure your site has an SSL certificate.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
console.log('Permission status:', permission);
|
||||||
|
|
||||||
|
if (permission === 'granted') {
|
||||||
|
notifyBtn.classList.add('active');
|
||||||
|
notifyBtn.textContent = '🔔 Notifications On';
|
||||||
|
|
||||||
|
new Notification('Notifications Enabled!', {
|
||||||
|
body: 'You will now receive updates from our podcast.',
|
||||||
|
icon: projectRoot + '/favicon.ico'
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register(projectRoot + '/sw.js')
|
||||||
|
.then(async reg => {
|
||||||
|
console.log('SW Registered');
|
||||||
|
try {
|
||||||
|
const subscription = await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
// Note: In a real app, you'd generate VAPID keys.
|
||||||
|
// For now, we use a placeholder or skip if browser allows.
|
||||||
|
applicationServerKey: 'BM-YOUR-PUBLIC-VAPID-KEY-HERE'
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetch(projectRoot + '/includes/subscribe_push.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(subscription)
|
||||||
|
});
|
||||||
|
console.log('Subscription saved to server');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Push subscription failed:', e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('SW Error:', err));
|
||||||
|
}
|
||||||
|
} else if (permission === 'denied') {
|
||||||
|
alert('Notifications were denied. Please enable them in your browser settings if you wish to receive updates.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error requesting notification permission:', err);
|
||||||
|
alert('An error occurred while requesting permission. Check the console for details.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy text to clipboard with UI feedback
|
||||||
|
*/
|
||||||
|
function copyToClipboard(text, btnId) {
|
||||||
|
const btn = document.getElementById(btnId);
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.add('copy-success');
|
||||||
|
const originalHtml = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.classList.remove('copy-success');
|
||||||
|
btn.innerHTML = originalHtml;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?php
|
||||||
|
header('Content-Type: application/rss+xml; charset=utf-8');
|
||||||
|
require_once 'includes/db.php';
|
||||||
|
require_once 'includes/functions.php';
|
||||||
|
|
||||||
|
$site_title = getSetting($pdo, 'site_title');
|
||||||
|
$site_description = "The official podcast of " . $site_title;
|
||||||
|
$banner_image = getSetting($pdo, 'banner_image');
|
||||||
|
|
||||||
|
$protocol = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? "https" : "http";
|
||||||
|
$host = $_SERVER['HTTP_HOST'];
|
||||||
|
$base_url = $protocol . "://" . $host . PROJECT_ROOT_URL;
|
||||||
|
|
||||||
|
$banner_url = $base_url . "/assets/uploads/images/" . ($banner_image ?: 'default-banner.jpg');
|
||||||
|
if ($banner_image === 'default-banner.jpg' || empty($banner_image)) {
|
||||||
|
$banner_url = "https://images.unsplash.com/photo-1478737270239-2f02b77fc618?auto=format&fit=crop&q=80&w=1400&h=1400";
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->query("SELECT * FROM episodes ORDER BY release_date DESC");
|
||||||
|
$episodes = $stmt->fetchAll();
|
||||||
|
|
||||||
|
echo '<?xml version="1.0" encoding="UTF-8"?>' . PHP_EOL;
|
||||||
|
?>
|
||||||
|
<rss version="2.0"
|
||||||
|
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
|
||||||
|
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||||
|
xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
|
<channel>
|
||||||
|
<title><?php echo htmlspecialchars($site_title); ?></title>
|
||||||
|
<link><?php echo $base_url; ?>/</link>
|
||||||
|
<atom:link href="<?php echo $base_url; ?>/feed.php" rel="self" type="application/rss+xml" />
|
||||||
|
<language>en-us</language>
|
||||||
|
<itunes:author><?php echo htmlspecialchars($site_title); ?></itunes:author>
|
||||||
|
<itunes:summary><?php echo htmlspecialchars($site_description); ?></itunes:summary>
|
||||||
|
<description><?php echo htmlspecialchars($site_description); ?></description>
|
||||||
|
<itunes:owner>
|
||||||
|
<itunes:name><?php echo htmlspecialchars($site_title); ?></itunes:name>
|
||||||
|
<itunes:email>admin@example.com</itunes:email>
|
||||||
|
</itunes:owner>
|
||||||
|
<itunes:image href="<?php echo $banner_url; ?>" />
|
||||||
|
<itunes:explicit>no</itunes:explicit>
|
||||||
|
<itunes:category text="Religion & Spirituality">
|
||||||
|
<itunes:category text="Christianity" />
|
||||||
|
</itunes:category>
|
||||||
|
|
||||||
|
<?php foreach ($episodes as $episode):
|
||||||
|
$audio_url = $base_url . "/assets/uploads/audio/" . $episode['audio_file'];
|
||||||
|
$file_path = __DIR__ . "/assets/uploads/audio/" . $episode['audio_file'];
|
||||||
|
$file_size = file_exists($file_path) ? filesize($file_path) : 0;
|
||||||
|
?>
|
||||||
|
<item>
|
||||||
|
<title><?php echo htmlspecialchars($episode['title']); ?></title>
|
||||||
|
<itunes:author><?php echo htmlspecialchars($site_title); ?></itunes:author>
|
||||||
|
<itunes:summary><?php echo htmlspecialchars(strip_tags($episode['description'])); ?></itunes:summary>
|
||||||
|
<description><![CDATA[<?php echo nl2br(htmlspecialchars($episode['description'])); ?>]]></description>
|
||||||
|
<itunes:image href="<?php echo $banner_url; ?>" />
|
||||||
|
<enclosure url="<?php echo $audio_url; ?>" length="<?php echo $file_size; ?>" type="audio/mpeg" />
|
||||||
|
<guid isPermaLink="false"><?php echo $audio_url; ?></guid>
|
||||||
|
<pubDate><?php echo date(DATE_RSS, strtotime($episode['release_date'])); ?></pubDate>
|
||||||
|
<itunes:explicit>no</itunes:explicit>
|
||||||
|
</item>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
$configFile = __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
// Calculate project root URL path based on db.php location
|
||||||
|
$projectRootFs = str_replace('\\', '/', dirname(__DIR__));
|
||||||
|
$documentRootFs = str_replace('\\', '/', $_SERVER['DOCUMENT_ROOT']);
|
||||||
|
$projectRootUrl = str_replace($documentRootFs, '', $projectRootFs);
|
||||||
|
|
||||||
|
// Ensure it starts with / and doesn't end with /
|
||||||
|
$projectRootUrl = '/' . ltrim($projectRootUrl, '/');
|
||||||
|
$projectRootUrl = rtrim($projectRootUrl, '/');
|
||||||
|
define('PROJECT_ROOT_URL', $projectRootUrl);
|
||||||
|
|
||||||
|
if (!file_exists($configFile)) {
|
||||||
|
// If not in setup, redirect to setup
|
||||||
|
if (basename($_SERVER['PHP_SELF']) !== 'setup.php') {
|
||||||
|
header("Location: " . PROJECT_ROOT_URL . "/setup.php");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
require_once $configFile;
|
||||||
|
|
||||||
|
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
|
||||||
|
$options = [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO($dsn, $user, $pass, $options);
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
// If connection fails, maybe the config is wrong?
|
||||||
|
// For now just die with a friendly message
|
||||||
|
die("Database connection failed. Please check your config.php or run setup again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<footer style="text-align: center; padding: 3rem; color: var(--text-muted); border-top: 1px solid var(--glass-border); margin-top: 3rem;">
|
||||||
|
<p>© <?php echo date('Y'); ?> <?php echo htmlspecialchars(getSetting($pdo, 'footer_copyright')); ?>. Powered by <?php echo htmlspecialchars(getSetting($pdo, 'footer_powered_by')); ?>.</p>
|
||||||
|
</footer>
|
||||||
|
<script src="<?php echo PROJECT_ROOT_URL; ?>/assets/js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the user is logged in as admin
|
||||||
|
*/
|
||||||
|
function isAdmin() {
|
||||||
|
return isset($_SESSION['admin_logged_in']) && $_SESSION['admin_logged_in'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the user has a specific role
|
||||||
|
*/
|
||||||
|
function hasRole($role) {
|
||||||
|
return isAdmin() && ($_SESSION['user_role'] ?? 'editor') === $role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect to login if not authenticated
|
||||||
|
*/
|
||||||
|
function requireAdmin() {
|
||||||
|
if (!isAdmin()) {
|
||||||
|
header("Location: login.php");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require a specific role or redirect
|
||||||
|
*/
|
||||||
|
function requireRole($role) {
|
||||||
|
requireAdmin();
|
||||||
|
if (!hasRole($role)) {
|
||||||
|
logActivity(null, 'UNAUTHORIZED_ACCESS', 'User tried to access a restricted page.');
|
||||||
|
header("Location: dashboard.php?error=unauthorized");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an activity to the database
|
||||||
|
*/
|
||||||
|
function logActivity($user_id, $action, $details = null) {
|
||||||
|
global $pdo;
|
||||||
|
$username = $_SESSION['admin_username'] ?? 'GUEST';
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'] ?? 'UNKNOWN';
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO activity_log (user_id, username, action, details, ip_address) VALUES (?, ?, ?, ?, ?)");
|
||||||
|
$stmt->execute([$user_id, $username, $action, $details, $ip]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a setting value by key
|
||||||
|
*/
|
||||||
|
function getSetting($pdo, $key) {
|
||||||
|
$stmt = $pdo->prepare("SELECT value FROM settings WHERE `key` = ?");
|
||||||
|
$stmt->execute([$key]);
|
||||||
|
return $stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a setting value
|
||||||
|
*/
|
||||||
|
function updateSetting($pdo, $key, $value) {
|
||||||
|
$stmt = $pdo->prepare("UPDATE settings SET value = ? WHERE `key` = ?");
|
||||||
|
return $stmt->execute([$value, $key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle audio file upload
|
||||||
|
*/
|
||||||
|
function uploadAudio($file) {
|
||||||
|
$targetDir = "../assets/uploads/audio/";
|
||||||
|
$fileName = time() . '_' . basename($file["name"]);
|
||||||
|
$targetFilePath = $targetDir . $fileName;
|
||||||
|
$fileType = pathinfo($targetFilePath, PATHINFO_EXTENSION);
|
||||||
|
|
||||||
|
// Allow certain file formats
|
||||||
|
$allowTypes = array('mp3', 'wav', 'm4a', 'ogg');
|
||||||
|
if (in_array(strtolower($fileType), $allowTypes)) {
|
||||||
|
if (!is_dir($targetDir)) {
|
||||||
|
error_log("Upload failed: Directory $targetDir does not exist.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!is_writable($targetDir)) {
|
||||||
|
error_log("Upload failed: Directory $targetDir is not writable.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (move_uploaded_file($file["tmp_name"], $targetFilePath)) {
|
||||||
|
return $fileName;
|
||||||
|
} else {
|
||||||
|
error_log("Upload failed: move_uploaded_file returned false. Tmp: " . $file["tmp_name"] . " Dest: " . $targetFilePath);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error_log("Upload failed: Invalid file type $fileType.");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle image file upload
|
||||||
|
*/
|
||||||
|
function uploadImage($file) {
|
||||||
|
$targetDir = "../assets/uploads/images/";
|
||||||
|
$fileName = time() . '_' . basename($file["name"]);
|
||||||
|
$targetFilePath = $targetDir . $fileName;
|
||||||
|
$fileType = pathinfo($targetFilePath, PATHINFO_EXTENSION);
|
||||||
|
|
||||||
|
$allowTypes = array('jpg', 'png', 'jpeg', 'gif');
|
||||||
|
if (in_array(strtolower($fileType), $allowTypes)) {
|
||||||
|
if (move_uploaded_file($file["tmp_name"], $targetFilePath)) {
|
||||||
|
return $fileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date to a readable format
|
||||||
|
*/
|
||||||
|
function formatDate($date) {
|
||||||
|
return date("F j, Y", strtotime($date));
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/db.php';
|
||||||
|
require_once __DIR__ . '/functions.php';
|
||||||
|
$site_title = getSetting($pdo, 'site_title');
|
||||||
|
$banner_image = getSetting($pdo, 'banner_image');
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?php echo htmlspecialchars($site_title); ?></title>
|
||||||
|
<link rel="stylesheet" href="<?php echo PROJECT_ROOT_URL; ?>/assets/css/style.css">
|
||||||
|
<link rel="icon" type="image/png" href="<?php echo PROJECT_ROOT_URL; ?>/assets/uploads/images/icon.png">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="<?php echo PROJECT_ROOT_URL; ?>/" class="logo"><?php echo htmlspecialchars($site_title); ?></a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="<?php echo PROJECT_ROOT_URL; ?>/">Home</a>
|
||||||
|
<a href="<?php echo PROJECT_ROOT_URL; ?>/subscribe.php">How to Subscribe</a>
|
||||||
|
<button id="notify-btn" class="notify-btn">🔔 Notify Me</button>
|
||||||
|
<?php if (isAdmin()): ?>
|
||||||
|
<a href="<?php echo PROJECT_ROOT_URL; ?>/admin/dashboard.php">Dashboard</a>
|
||||||
|
<a href="<?php echo PROJECT_ROOT_URL; ?>/admin/logout.php">Logout</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<a href="<?php echo PROJECT_ROOT_URL; ?>/admin/login.php">Admin</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
require_once 'db.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (isset($data['endpoint'], $data['keys']['p256dh'], $data['keys']['auth'])) {
|
||||||
|
$endpoint = $data['endpoint'];
|
||||||
|
$p256dh = $data['keys']['p256dh'];
|
||||||
|
$auth = $data['keys']['auth'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if subscription already exists
|
||||||
|
$stmt = $pdo->prepare("SELECT id FROM subscriptions WHERE endpoint = ?");
|
||||||
|
$stmt->execute([$endpoint]);
|
||||||
|
|
||||||
|
if (!$stmt->fetch()) {
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO subscriptions (endpoint, p256dh, auth) VALUES (?, ?, ?)");
|
||||||
|
$stmt->execute([$endpoint, $p256dh, $auth]);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['status' => 'success']);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Database error']);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Invalid subscription data']);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Invalid request method']);
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
require_once 'db.php';
|
||||||
|
require_once 'functions.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$episode_id = (int)($_POST['episode_id'] ?? 0);
|
||||||
|
$duration = (int)($_POST['duration'] ?? 0);
|
||||||
|
$session_id = $_POST['session_id'] ?? null;
|
||||||
|
|
||||||
|
if ($episode_id > 0) {
|
||||||
|
try {
|
||||||
|
// Check if this is a new session to log as "Listener Started"
|
||||||
|
$checkStmt = $pdo->prepare("SELECT COUNT(*) FROM plays WHERE session_id = ?");
|
||||||
|
$checkStmt->execute([$session_id]);
|
||||||
|
$isNewSession = ($checkStmt->fetchColumn() == 0);
|
||||||
|
|
||||||
|
$sql = "INSERT INTO plays (episode_id, duration, session_id) VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE duration = duration + VALUES(duration)";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute([$episode_id, $duration, $session_id]);
|
||||||
|
|
||||||
|
if ($isNewSession && $session_id) {
|
||||||
|
$titleStmt = $pdo->prepare("SELECT title FROM episodes WHERE id = ?");
|
||||||
|
$titleStmt->execute([$episode_id]);
|
||||||
|
$title = $titleStmt->fetchColumn();
|
||||||
|
logActivity(null, 'LISTENER_STARTED', "Episode: $title (Session: $session_id)");
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents('debug.log', date('[Y-m-d H:i:s] ') . "SUCCESS: Logged play for episode $episode_id ($duration s, session: $session_id)" . PHP_EOL, FILE_APPEND);
|
||||||
|
echo json_encode(['status' => 'success']);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
file_put_contents('debug.log', date('[Y-m-d H:i:s] ') . "DB ERROR: " . $e->getMessage() . PHP_EOL, FILE_APPEND);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Database error']);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
file_put_contents('debug.log', date('[Y-m-d H:i:s] ') . "INVALID DATA: ID=$episode_id, DUR=$duration" . PHP_EOL, FILE_APPEND);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Invalid episode ID']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
<?php
|
||||||
|
require_once 'includes/header.php';
|
||||||
|
|
||||||
|
// Fetch episodes ordered by latest release date with play counts
|
||||||
|
$sql = "SELECT e.*, COUNT(p.id) as play_count
|
||||||
|
FROM episodes e
|
||||||
|
LEFT JOIN plays p ON e.id = p.episode_id
|
||||||
|
GROUP BY e.id
|
||||||
|
ORDER BY release_date DESC";
|
||||||
|
$stmt = $pdo->query($sql);
|
||||||
|
$episodes = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$banner_url = PROJECT_ROOT_URL . "/assets/uploads/images/" . $banner_image;
|
||||||
|
if ($banner_image === 'default-banner.jpg') {
|
||||||
|
// Check if default exists, if not use a placeholder gradient
|
||||||
|
$banner_url = "https://images.unsplash.com/photo-1478737270239-2f02b77fc618?auto=format&fit=crop&q=80&w=1920&h=400";
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="hero" style="background-image: url('<?php echo $banner_url; ?>');">
|
||||||
|
<div class="hero-content">
|
||||||
|
<h1><?php echo htmlspecialchars($site_title); ?></h1>
|
||||||
|
<p>Listen to our latest messages and sermons.</p>
|
||||||
|
<div style="margin-top: 2rem; display: flex; gap: 1rem; justify-content: center;">
|
||||||
|
<a href="subscribe.php" class="btn btn-primary">How to Subscribe</a>
|
||||||
|
<a href="feed.php" class="btn" style="background: rgba(255,255,255,0.1); backdrop-filter: blur(10px); border: 1px solid var(--glass-border);">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 0.5rem;"><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle cx="5" cy="19" r="1"></circle></svg>
|
||||||
|
RSS Feed
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<?php foreach ($episodes as $episode): ?>
|
||||||
|
<article class="episode-card" id="episode-<?php echo $episode['id']; ?>">
|
||||||
|
<div style="display: flex; gap: 2rem; align-items: flex-start;">
|
||||||
|
<?php if ($episode['cover_image']): ?>
|
||||||
|
<div class="episode-cover">
|
||||||
|
<img src="<?php echo PROJECT_ROOT_URL; ?>/assets/uploads/images/<?php echo $episode['cover_image']; ?>" alt="<?php echo htmlspecialchars($episode['title']); ?>" style="width: 200px; height: 200px; object-fit: cover; border-radius: 16px; border: 1px solid var(--glass-border);">
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<div class="episode-meta">
|
||||||
|
Released on <?php echo formatDate($episode['release_date']); ?> •
|
||||||
|
<span style="color: var(--primary-color); font-weight: 600;"><?php echo number_format($episode['play_count']); ?> listens</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="episode-title"><?php echo htmlspecialchars($episode['title']); ?></h2>
|
||||||
|
<div class="episode-description">
|
||||||
|
<?php echo nl2br(htmlspecialchars($episode['description'])); ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="audio-player-wrapper">
|
||||||
|
<audio controls preload="none">
|
||||||
|
<source src="<?php echo PROJECT_ROOT_URL; ?>/assets/uploads/audio/<?php echo $episode['audio_file']; ?>" type="audio/mpeg">
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="episode-actions" style="justify-content: space-between;">
|
||||||
|
<div style="display: flex; gap: 1.5rem; align-items: center;">
|
||||||
|
<a href="<?php echo PROJECT_ROOT_URL; ?>/assets/uploads/audio/<?php echo $episode['audio_file']; ?>" class="download-link" download>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
|
||||||
|
Download Episode
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="share-group">
|
||||||
|
<span style="font-size: 0.8rem; color: var(--text-muted); margin-right: 0.5rem;">Share:</span>
|
||||||
|
<?php
|
||||||
|
$share_url = (isset($_SERVER['HTTPS']) ? "https" : "http") . "://$_SERVER[HTTP_HOST]" . PROJECT_ROOT_URL . "/#episode-" . $episode['id'];
|
||||||
|
$share_title = urlencode($episode['title'] . " - " . $site_title);
|
||||||
|
?>
|
||||||
|
<a href="https://www.facebook.com/sharer/sharer.php?u=<?php echo urlencode($share_url); ?>" target="_blank" class="share-btn" title="Share on Facebook">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"></path></svg>
|
||||||
|
</a>
|
||||||
|
<a href="mailto:?subject=<?php echo $share_title; ?>&body=Listen to this episode: <?php echo urlencode($share_url); ?>" class="share-btn" title="Share via Email">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
|
||||||
|
</a>
|
||||||
|
<button id="share-copy-<?php echo $episode['id']; ?>" onclick="copyToClipboard('<?php echo $share_url; ?>', 'share-copy-<?php echo $episode['id']; ?>')" class="share-btn" title="Copy Link">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<?php if (empty($episodes)): ?>
|
||||||
|
<div style="text-align: center; padding: 4rem; background: var(--bg-card); border-radius: 24px; border: 1px solid var(--glass-border);">
|
||||||
|
<h3>Welcome to our Podcast!</h3>
|
||||||
|
<p style="color: var(--text-muted); margin-top: 1rem;">We haven't uploaded any episodes yet. Please check back soon!</p>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php require_once 'includes/footer.php'; ?>
|
||||||
|
|
@ -0,0 +1,226 @@
|
||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
$error = '';
|
||||||
|
$success = false;
|
||||||
|
$systemChecks = [];
|
||||||
|
|
||||||
|
// 1. Check folder permissions
|
||||||
|
$uploadDirs = [
|
||||||
|
'assets/uploads/audio',
|
||||||
|
'assets/uploads/images',
|
||||||
|
'includes'
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($uploadDirs as $dir) {
|
||||||
|
$fullPath = __DIR__ . '/' . $dir;
|
||||||
|
if (!is_dir($fullPath)) {
|
||||||
|
@mkdir($fullPath, 0755, true);
|
||||||
|
}
|
||||||
|
$isWritable = is_writable($fullPath);
|
||||||
|
$systemChecks['dirs'][$dir] = [
|
||||||
|
'status' => $isWritable,
|
||||||
|
'message' => $isWritable ? 'Writable' : 'Not Writable'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check PHP limits
|
||||||
|
$uploadMax = ini_get('upload_max_filesize');
|
||||||
|
$postMax = ini_get('post_max_size');
|
||||||
|
$systemChecks['php'] = [
|
||||||
|
'upload_max' => $uploadMax,
|
||||||
|
'post_max' => $postMax
|
||||||
|
];
|
||||||
|
|
||||||
|
if (file_exists('includes/config.php')) {
|
||||||
|
header("Location: index.php");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$host = $_POST['db_host'] ?? 'localhost';
|
||||||
|
$db_name = $_POST['db_name'] ?? 'church_podcast';
|
||||||
|
$db_user = $_POST['db_user'] ?? 'root';
|
||||||
|
$db_pass = $_POST['db_pass'] ?? '';
|
||||||
|
|
||||||
|
$admin_user = $_POST['admin_user'] ?? 'admin';
|
||||||
|
$admin_pass = $_POST['admin_pass'] ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Try to connect to MySQL (without selecting DB first)
|
||||||
|
$dsn = "mysql:host=$host;charset=utf8mb4";
|
||||||
|
$pdo = new PDO($dsn, $db_user, $db_pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||||
|
|
||||||
|
// 2. Create Database
|
||||||
|
$pdo->exec("CREATE DATABASE IF NOT EXISTS `$db_name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||||
|
$pdo->exec("USE `$db_name` ");
|
||||||
|
|
||||||
|
// 3. Run Schema
|
||||||
|
$schema = file_get_contents('sql/schema.sql');
|
||||||
|
$schema = preg_replace('/CREATE DATABASE IF NOT EXISTS .*?;/i', '', $schema);
|
||||||
|
$schema = preg_replace('/USE .*?;/i', '', $schema);
|
||||||
|
$pdo->exec($schema);
|
||||||
|
|
||||||
|
// 4. Create first Admin
|
||||||
|
$hashed_pass = password_hash($admin_pass, PASSWORD_DEFAULT);
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO admins (username, password, role) VALUES (?, ?, 'admin')");
|
||||||
|
$stmt->execute([$admin_user, $hashed_pass]);
|
||||||
|
|
||||||
|
// 5. Save Config
|
||||||
|
$configContent = "<?php
|
||||||
|
\$host = '$host';
|
||||||
|
\$db = '$db_name';
|
||||||
|
\$user = '$db_user';
|
||||||
|
\$pass = '$db_pass';
|
||||||
|
\$charset = 'utf8mb4';
|
||||||
|
?>";
|
||||||
|
if (file_put_contents('includes/config.php', $configContent) === false) {
|
||||||
|
throw new Exception("Could not write config.php. Please check folder permissions for 'includes/' directory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$success = true;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$msg = $e->getMessage();
|
||||||
|
if (strpos($msg, 'Access denied') !== false) {
|
||||||
|
$error = "<strong>Access Denied:</strong> Your database user doesn't have permission. <br><br>
|
||||||
|
If you are using 'root' on MySQL 8.0, this is normal. Please run these commands in your terminal:<br>
|
||||||
|
<code style='display:block; background:#1e293b; padding:10px; margin-top:10px; font-size:0.8rem; text-align:left;'>
|
||||||
|
sudo mysql<br>
|
||||||
|
CREATE DATABASE IF NOT EXISTS $db_name;<br>
|
||||||
|
CREATE USER 'podcast_user'@'localhost' IDENTIFIED WITH mysql_native_password BY 'your_password';<br>
|
||||||
|
GRANT ALL PRIVILEGES ON $db_name.* TO 'podcast_user'@'localhost';<br>
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
</code><br>
|
||||||
|
Then use 'podcast_user' and your password in the form below.";
|
||||||
|
} else {
|
||||||
|
$error = "Setup failed: " . $msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Setup - Podcast Server</title>
|
||||||
|
<link rel="stylesheet" href="assets/css/style.css">
|
||||||
|
<style>
|
||||||
|
.check-item { display: flex; justify-content: space-between; padding: 10px; border-bottom: 1px solid var(--glass-border); }
|
||||||
|
.status-ok { color: #10b981; }
|
||||||
|
.status-fail { color: #ef4444; }
|
||||||
|
.help-box { background: rgba(239, 68, 68, 0.1); border: 1px solid #ef4444; padding: 15px; border-radius: 10px; margin-top: 10px; font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="admin-login">
|
||||||
|
<div class="form-container" style="max-width: 900px;">
|
||||||
|
<h1 style="text-align: center; margin-bottom: 2rem;">🚀 Welcome to Podcast Server</h1>
|
||||||
|
|
||||||
|
<?php if ($success): ?>
|
||||||
|
<div style="background: rgba(16, 185, 129, 0.1); color: #10b981; padding: 2rem; border-radius: 12px; text-align: center;">
|
||||||
|
<h2>Setup Complete!</h2>
|
||||||
|
<p>The configuration file has been created and the database initialized.</p>
|
||||||
|
<a href="admin/login.php" class="btn btn-primary" style="margin-top: 1.5rem;">Go to Login</a>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div style="display: grid; grid-template-columns: 350px 1fr; gap: 3rem;">
|
||||||
|
<!-- System Checks -->
|
||||||
|
<div style="background: rgba(15, 23, 42, 0.5); padding: 1.5rem; border-radius: 15px; border: 1px solid var(--glass-border);">
|
||||||
|
<h3 style="margin-bottom: 1.5rem;">Environment Check</h3>
|
||||||
|
|
||||||
|
<div class="check-group">
|
||||||
|
<p style="font-weight: 600; font-size: 0.8rem; color: var(--text-muted); text-transform: uppercase;">Permissions</p>
|
||||||
|
<?php
|
||||||
|
$hasPermIssue = false;
|
||||||
|
foreach ($systemChecks['dirs'] as $dir => $data):
|
||||||
|
if (!$data['status']) $hasPermIssue = true;
|
||||||
|
?>
|
||||||
|
<div class="check-item">
|
||||||
|
<span style="font-size: 0.9rem;"><?php echo $dir; ?></span>
|
||||||
|
<span class="<?php echo $data['status'] ? 'status-ok' : 'status-fail'; ?>">
|
||||||
|
<?php echo $data['message']; ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<?php if ($hasPermIssue): ?>
|
||||||
|
<div class="help-box">
|
||||||
|
<strong>Fix Permissions:</strong><br>
|
||||||
|
Run this command in your Ubuntu terminal:<br>
|
||||||
|
<code style="display:block; margin-top:5px; font-size:0.75rem;">sudo chown -R www-data:www-data <?php echo __DIR__; ?></code>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="check-group" style="margin-top: 2rem;">
|
||||||
|
<p style="font-weight: 600; font-size: 0.8rem; color: var(--text-muted); text-transform: uppercase;">PHP Settings</p>
|
||||||
|
<div class="check-item">
|
||||||
|
<span style="font-size: 0.9rem;">Max Upload</span>
|
||||||
|
<span><?php echo $systemChecks['php']['upload_max']; ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="check-item">
|
||||||
|
<span style="font-size: 0.9rem;">Post Max Data</span>
|
||||||
|
<span><?php echo $systemChecks['php']['post_max']; ?></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (intval($systemChecks['php']['upload_max']) < 50): ?>
|
||||||
|
<div class="help-box">
|
||||||
|
<strong>Increase Limits:</strong><br>
|
||||||
|
Edit <code>/etc/php/8.0/apache2/php.ini</code> and set:<br>
|
||||||
|
<code>upload_max_filesize = 100M</code><br>
|
||||||
|
<code>post_max_size = 110M</code><br>
|
||||||
|
Then: <code>sudo systemctl restart apache2</code>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Setup Form -->
|
||||||
|
<div>
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div style="background: rgba(239, 68, 68, 0.1); border: 1px solid #ef4444; color: #ef4444; padding: 1.5rem; border-radius: 12px; margin-bottom: 2rem;">
|
||||||
|
<?php echo $error; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem;">
|
||||||
|
<div>
|
||||||
|
<h4 style="margin-bottom: 1rem;">Database Configuration</h4>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="db_host">MySQL Host</label>
|
||||||
|
<input type="text" id="db_host" name="db_host" value="localhost" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="db_name">Database Name</label>
|
||||||
|
<input type="text" id="db_name" name="db_name" value="church_podcast" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="db_user">MySQL Username</label>
|
||||||
|
<input type="text" id="db_user" name="db_user" value="root" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="db_pass">MySQL Password</label>
|
||||||
|
<input type="password" id="db_pass" name="db_pass">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 style="margin-bottom: 1rem;">Admin User</h4>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="admin_user">Username</label>
|
||||||
|
<input type="text" id="admin_user" name="admin_user" value="admin" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="admin_pass">Password</label>
|
||||||
|
<input type="password" id="admin_pass" name="admin_pass" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: 100%; margin-top: 1rem;">Finish Setup</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
-- Database schema for Podcast Hosting Server
|
||||||
|
|
||||||
|
CREATE DATABASE IF NOT EXISTS church_podcast;
|
||||||
|
USE church_podcast;
|
||||||
|
|
||||||
|
-- Episodes table
|
||||||
|
CREATE TABLE IF NOT EXISTS episodes (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
audio_file VARCHAR(255) NOT NULL,
|
||||||
|
cover_image VARCHAR(255),
|
||||||
|
release_date DATE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Settings table
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
`key` VARCHAR(50) PRIMARY KEY,
|
||||||
|
`value` TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins table
|
||||||
|
CREATE TABLE IF NOT EXISTS admins (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
role ENUM('admin', 'editor') DEFAULT 'editor',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Plays table (Analytics)
|
||||||
|
CREATE TABLE IF NOT EXISTS plays (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
episode_id INT NOT NULL,
|
||||||
|
duration INT DEFAULT 0, -- seconds listened
|
||||||
|
session_id VARCHAR(100) UNIQUE,
|
||||||
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (episode_id) REFERENCES episodes(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Default settings
|
||||||
|
INSERT IGNORE INTO settings (`key`, `value`) VALUES
|
||||||
|
('site_title', 'Our Church Podcast'),
|
||||||
|
('banner_image', 'default-banner.jpg'),
|
||||||
|
('footer_copyright', 'Our Church'),
|
||||||
|
('footer_powered_by', 'Antigravity Podcast Server');
|
||||||
|
|
||||||
|
-- Subscriptions table (Push Notifications)
|
||||||
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
p256dh VARCHAR(255) NOT NULL,
|
||||||
|
auth VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Activity Log table
|
||||||
|
CREATE TABLE IF NOT EXISTS activity_log (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT,
|
||||||
|
username VARCHAR(50),
|
||||||
|
action VARCHAR(255) NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?php
|
||||||
|
require_once 'includes/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="hero" style="background-image: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%); height: 300px;">
|
||||||
|
<div class="hero-content">
|
||||||
|
<h1>How to Subscribe</h1>
|
||||||
|
<p>Take our podcast with you wherever you go.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container" style="max-width: 800px;">
|
||||||
|
<div class="episode-card">
|
||||||
|
<h3 style="margin-bottom: 1.5rem;">Choose Your Favorite App</h3>
|
||||||
|
|
||||||
|
<div style="display: grid; gap: 2rem;">
|
||||||
|
<div style="border-left: 4px solid var(--primary-color); padding-left: 1.5rem;">
|
||||||
|
<h4>Apple Podcasts</h4>
|
||||||
|
<p style="color: var(--text-muted); margin-top: 0.5rem;">Open the Apple Podcasts app, search for "<?php echo htmlspecialchars($site_title); ?>", and tap "Follow".</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="border-left: 4px solid #1DB954; padding-left: 1.5rem;">
|
||||||
|
<h4>Spotify</h4>
|
||||||
|
<p style="color: var(--text-muted); margin-top: 0.5rem;">Search for "<?php echo htmlspecialchars($site_title); ?>" on Spotify and hit the "Follow" button to stay updated.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="border-left: 4px solid #FBBC05; padding-left: 1.5rem;">
|
||||||
|
<h4>Google Podcasts / YouTube Music</h4>
|
||||||
|
<p style="color: var(--text-muted); margin-top: 0.5rem;">Find us on YouTube Music or Google Podcasts by searching for our show title.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="border-left: 4px solid var(--text-main); padding-left: 1.5rem;">
|
||||||
|
<h4>Other Apps (RSS Feed)</h4>
|
||||||
|
<p style="color: var(--text-muted); margin-top: 0.5rem;">If you use another app like Overcast or Pocket Casts, you can manually add our RSS feed:</p>
|
||||||
|
<div style="display: flex; gap: 0.5rem; margin-top: 0.75rem; align-items: center;">
|
||||||
|
<code id="feed-url" style="background: rgba(0,0,0,0.3); padding: 0.5rem 1rem; border-radius: 8px; flex: 1; font-size: 0.85rem; border: 1px solid var(--glass-border);">https://<?php echo $_SERVER['HTTP_HOST'] . PROJECT_ROOT_URL; ?>/feed.php</code>
|
||||||
|
<button onclick="copyFeedUrl()" id="copy-btn" class="btn" style="background: var(--primary-color); padding: 0.5rem 1rem; font-size: 0.85rem;">Copy URL</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyFeedUrl() {
|
||||||
|
const url = document.getElementById('feed-url').innerText;
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
const btn = document.getElementById('copy-btn');
|
||||||
|
const originalText = btn.innerText;
|
||||||
|
btn.innerText = 'Copied!';
|
||||||
|
btn.style.background = '#10b981';
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerText = originalText;
|
||||||
|
btn.style.background = '';
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 3rem;">
|
||||||
|
<a href="<?php echo PROJECT_ROOT_URL; ?>/" class="btn btn-primary">← Back to Episodes</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php require_once 'includes/footer.php'; ?>
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
// Service Worker for Push Notifications
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
const data = event.data ? event.data.json() : { title: 'New Episode!', body: 'Check out the latest podcast episode.' };
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
body: data.body,
|
||||||
|
icon: '/assets/uploads/images/icon.png', // Fallback icon
|
||||||
|
badge: '/assets/uploads/images/badge.png',
|
||||||
|
data: {
|
||||||
|
url: data.url || '/'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title, options)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
event.waitUntil(
|
||||||
|
clients.openWindow(event.notification.data.url)
|
||||||
|
);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue