Compare commits
2 Commits
de0edf9411
...
2a5c12a0a8
| Author | SHA1 | Date |
|---|---|---|
|
|
2a5c12a0a8 | |
|
|
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
|
||||||
22
LICENSE
22
LICENSE
|
|
@ -1,5 +1,26 @@
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
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.
|
||||||
|
=======
|
||||||
Copyright (c) 2026 michael
|
Copyright (c) 2026 michael
|
||||||
|
|
||||||
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:
|
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:
|
||||||
|
|
@ -7,3 +28,4 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
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.
|
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.
|
||||||
|
>>>>>>> de0edf94110f2fe94fafd5cb8cf258079f336584
|
||||||
|
|
|
||||||
90
README.md
90
README.md
|
|
@ -1,3 +1,91 @@
|
||||||
|
<<<<<<< HEAD
|
||||||
|
# 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.
|
||||||
|
=======
|
||||||
# Podcast-server
|
# 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.
|
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.
|
||||||
|
>>>>>>> de0edf94110f2fe94fafd5cb8cf258079f336584
|
||||||
|
|
|
||||||
|
|
@ -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