Ready for release: Includes Auto-cron, Installer, and Schema
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# .env
|
||||||
|
MYSQL_ROOT_PASSWORD=someweirdrootpassword
|
||||||
|
MYSQL_DATABASE=rss_db
|
||||||
|
MYSQL_USER=rss_user
|
||||||
|
MYSQL_PASSWORD=secretpassword123
|
||||||
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# --- Security ---
|
||||||
|
# Contains database passwords and secrets
|
||||||
|
.env
|
||||||
|
# Legacy config file (if you still use it locally)
|
||||||
|
src/config.php
|
||||||
|
|
||||||
|
# --- Dependencies ---
|
||||||
|
# Composer downloads these automatically during build
|
||||||
|
src/vendor/
|
||||||
|
|
||||||
|
# --- Application Data ---
|
||||||
|
# RSS cache folder (generated dynamically)
|
||||||
|
src/cache/
|
||||||
|
# Cron job logs
|
||||||
|
src/cron_output.log
|
||||||
|
|
||||||
|
# --- System Files ---
|
||||||
|
# Mac OS metadata
|
||||||
|
.DS_Store
|
||||||
|
# Windows thumbnail cache
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
|
||||||
|
# --- IDE Settings (Optional but recommended) ---
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
FROM php:8.3-apache
|
||||||
|
|
||||||
|
# 1. Install system dependencies (Git, Unzip, Cron)
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
git \
|
||||||
|
unzip \
|
||||||
|
libzip-dev \
|
||||||
|
cron \
|
||||||
|
&& docker-php-ext-install zip pdo pdo_mysql
|
||||||
|
|
||||||
|
# 2. Install Composer globally
|
||||||
|
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
# 3. Enable Apache mod_rewrite
|
||||||
|
RUN a2enmod rewrite
|
||||||
|
|
||||||
|
# 4. Copy the entrypoint script and make it executable
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
# 5. Set working directory
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
# 6. Set Entrypoint
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
|
||||||
|
# 7. Default command
|
||||||
|
CMD ["apache2-foreground"]
|
||||||
24
config.php
Normal file
24
config.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
error_reporting(E_ALL & ~E_DEPRECATED);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
|
||||||
|
// Read variables from Docker Environment
|
||||||
|
$host = getenv('DB_HOST') ?: 'db';
|
||||||
|
$db = getenv('DB_NAME') ?: 'rss_db';
|
||||||
|
$user = getenv('DB_USER') ?: 'root';
|
||||||
|
$pass = getenv('DB_PASS'); // This comes from docker-compose
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO("mysql:host=$host;dbname=$db;charset=utf8mb4", $user, $pass, $options);
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
// Show a cleaner error if DB connection fails
|
||||||
|
die("Database Connection Failed. Check your .env file and container status.");
|
||||||
|
}
|
||||||
|
?>
|
||||||
42
docker-compose.yml .yml
Normal file
42
docker-compose.yml .yml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
volumes:
|
||||||
|
- ./src:/var/www/html
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
networks:
|
||||||
|
- rss-network
|
||||||
|
# Pass the variables to PHP
|
||||||
|
environment:
|
||||||
|
- DB_HOST=db
|
||||||
|
- DB_NAME=${MYSQL_DATABASE}
|
||||||
|
- DB_USER=${MYSQL_USER}
|
||||||
|
- DB_PASS=${MYSQL_PASSWORD}
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: rss_mysql
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE}
|
||||||
|
MYSQL_USER: ${MYSQL_USER}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/mysql
|
||||||
|
# MAGIC LINE: This auto-runs your SQL on first launch!
|
||||||
|
- ./schema.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
networks:
|
||||||
|
- rss-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
rss-network:
|
||||||
|
driver: bridge
|
||||||
24
docker-entrypoint.sh
Normal file
24
docker-entrypoint.sh
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 1. Install SimplePie if vendor folder is missing
|
||||||
|
if [ ! -d "/var/www/html/vendor" ]; then
|
||||||
|
echo "Vendor folder not found. Installing SimplePie..."
|
||||||
|
composer require simplepie/simplepie
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Ensure Cache folder exists and is writable
|
||||||
|
if [ ! -d "/var/www/html/cache" ]; then
|
||||||
|
mkdir -p /var/www/html/cache
|
||||||
|
chmod -R 777 /var/www/html/cache
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Setup Cron Job (Runs every 15 minutes)
|
||||||
|
# We overwrite the file to ensure no duplicates
|
||||||
|
echo "*/15 * * * * /usr/local/bin/php /var/www/html/fetch.php >> /var/www/html/cron_output.log 2>&1" > /etc/cron.d/rss-cron
|
||||||
|
chmod 0644 /etc/cron.d/rss-cron
|
||||||
|
crontab /etc/cron.d/rss-cron
|
||||||
|
service cron start
|
||||||
|
|
||||||
|
# 4. Start Apache
|
||||||
|
exec "$@"
|
||||||
51
fetch.php
Normal file
51
fetch.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
require 'config.php';
|
||||||
|
require 'vendor/autoload.php'; // Or manual include if not using composer
|
||||||
|
|
||||||
|
$feed = new SimplePie();
|
||||||
|
$feed->set_cache_location(__DIR__ . '/cache');
|
||||||
|
|
||||||
|
// 1. Get feeds
|
||||||
|
$stmt = $pdo->query("SELECT id, rss_url FROM feeds");
|
||||||
|
$feeds_list = $stmt->fetchAll();
|
||||||
|
|
||||||
|
foreach ($feeds_list as $row) {
|
||||||
|
$feed->set_feed_url($row['rss_url']);
|
||||||
|
$feed->init();
|
||||||
|
$feed->handle_content_type();
|
||||||
|
|
||||||
|
foreach ($feed->get_items() as $item) {
|
||||||
|
// FIX: Decode HTML entities so "AT&T" becomes "AT&T"
|
||||||
|
$title = html_entity_decode($item->get_title(), ENT_QUOTES | ENT_HTML5);
|
||||||
|
$desc = html_entity_decode($item->get_description(), ENT_QUOTES | ENT_HTML5);
|
||||||
|
|
||||||
|
$link = $item->get_permalink();
|
||||||
|
$date = $item->get_date('Y-m-d H:i:s');
|
||||||
|
if (!$date) { $date = date('Y-m-d H:i:s'); }
|
||||||
|
|
||||||
|
$sql = "INSERT IGNORE INTO items (feed_id, title, link, description, pub_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?)";
|
||||||
|
$stmtInsert = $pdo->prepare($sql);
|
||||||
|
$stmtInsert->execute([$row['id'], $title, $link, $desc, $date]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// --- CLEANUP ROUTINE ---
|
||||||
|
// Delete articles older than 30 days, BUT KEEP items marked as 'is_saved'
|
||||||
|
$days_to_keep = 30;
|
||||||
|
$sql_cleanup = "DELETE FROM items
|
||||||
|
WHERE pub_date < DATE_SUB(NOW(), INTERVAL ? DAY)
|
||||||
|
AND is_saved = 0";
|
||||||
|
|
||||||
|
$stmtCleanup = $pdo->prepare($sql_cleanup);
|
||||||
|
$stmtCleanup->execute([$days_to_keep]);
|
||||||
|
|
||||||
|
// -----------------------
|
||||||
|
|
||||||
|
// 2. Redirect back to dashboard immediately
|
||||||
|
header("Location: index.php");
|
||||||
|
exit;
|
||||||
|
|
||||||
|
?>
|
||||||
377
index.php
Normal file
377
index.php
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
<?php
|
||||||
|
require 'config.php';
|
||||||
|
|
||||||
|
// --- ACTION HANDLERS ---
|
||||||
|
|
||||||
|
// 1. Add Feed
|
||||||
|
if (isset($_POST['add_feed'])) {
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO feeds (site_name, rss_url, group_id) VALUES (?, ?, ?)");
|
||||||
|
$stmt->execute([$_POST['site_name'], $_POST['rss_url'], $_POST['group_id']]);
|
||||||
|
header("Location: index.php");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Add Group
|
||||||
|
if (isset($_POST['add_group'])) {
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO `groups` (name) VALUES (?)");
|
||||||
|
$stmt->execute([$_POST['group_name']]);
|
||||||
|
header("Location: index.php");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Delete Feed
|
||||||
|
if (isset($_POST['delete_feed'])) {
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM feeds WHERE id = ?");
|
||||||
|
$stmt->execute([$_POST['feed_id_to_delete']]);
|
||||||
|
header("Location: index.php");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Edit Feed
|
||||||
|
if (isset($_POST['edit_feed'])) {
|
||||||
|
$stmt = $pdo->prepare("UPDATE feeds SET site_name = ?, rss_url = ?, group_id = ? WHERE id = ?");
|
||||||
|
$stmt->execute([
|
||||||
|
$_POST['edit_site_name'],
|
||||||
|
$_POST['edit_rss_url'],
|
||||||
|
$_POST['edit_group_id'],
|
||||||
|
$_POST['edit_feed_id']
|
||||||
|
]);
|
||||||
|
header("Location: index.php");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Mark as Read (Updated with Undo Memory)
|
||||||
|
if (isset($_GET['mark_read'])) {
|
||||||
|
$id = $_GET['mark_read'];
|
||||||
|
|
||||||
|
// Update DB
|
||||||
|
$stmt = $pdo->prepare("UPDATE items SET is_read = 1, is_saved = 0 WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
// SAVE ID TO SESSION FOR UNDO
|
||||||
|
$_SESSION['undo_entry_id'] = $id;
|
||||||
|
|
||||||
|
// Redirect logic (Same as before)
|
||||||
|
$params = [];
|
||||||
|
if (isset($_GET['view'])) $params[] = "view=" . $_GET['view'];
|
||||||
|
if (isset($_GET['group_id'])) $params[] = "group_id=" . $_GET['group_id'];
|
||||||
|
$redirect = count($params) > 0 ? "?" . implode("&", $params) : "index.php";
|
||||||
|
header("Location: $redirect");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
// 6. Save/Unsave Logic
|
||||||
|
if (isset($_GET['save_item'])) {
|
||||||
|
$stmt = $pdo->prepare("UPDATE items SET is_saved = 1 WHERE id = ?");
|
||||||
|
$stmt->execute([$_GET['save_item']]);
|
||||||
|
$params = [];
|
||||||
|
if (isset($_GET['view'])) $params[] = "view=" . $_GET['view'];
|
||||||
|
if (isset($_GET['group_id'])) $params[] = "group_id=" . $_GET['group_id'];
|
||||||
|
$redirect = count($params) > 0 ? "?" . implode("&", $params) : "index.php";
|
||||||
|
header("Location: $redirect");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (isset($_GET['unsave_item'])) {
|
||||||
|
$stmt = $pdo->prepare("UPDATE items SET is_saved = 0 WHERE id = ?");
|
||||||
|
$stmt->execute([$_GET['unsave_item']]);
|
||||||
|
$params = [];
|
||||||
|
$params[] = "view=" . ($_GET['view'] ?? 'saved');
|
||||||
|
if (isset($_GET['group_id'])) $params[] = "group_id=" . $_GET['group_id'];
|
||||||
|
$redirect = "?" . implode("&", $params);
|
||||||
|
header("Location: $redirect");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. NEW: Undo Handler
|
||||||
|
if (isset($_GET['undo_action'])) {
|
||||||
|
if (isset($_SESSION['undo_entry_id'])) {
|
||||||
|
// Revert the last item to Unread (is_read = 0)
|
||||||
|
$stmt = $pdo->prepare("UPDATE items SET is_read = 0 WHERE id = ?");
|
||||||
|
$stmt->execute([$_SESSION['undo_entry_id']]);
|
||||||
|
|
||||||
|
// Clear the session so the button disappears
|
||||||
|
unset($_SESSION['undo_entry_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect logic to keep you on the same page
|
||||||
|
$params = [];
|
||||||
|
if (isset($_GET['view'])) $params[] = "view=" . $_GET['view'];
|
||||||
|
if (isset($_GET['group_id'])) $params[] = "group_id=" . $_GET['group_id'];
|
||||||
|
$redirect = count($params) > 0 ? "?" . implode("&", $params) : "index.php";
|
||||||
|
header("Location: $redirect");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DATA FETCHING ---
|
||||||
|
$groups = $pdo->query("SELECT * FROM `groups`")->fetchAll();
|
||||||
|
|
||||||
|
// Fetch all feeds for the management modal
|
||||||
|
$all_feeds = $pdo->query("SELECT feeds.*, groups.name as group_name FROM feeds LEFT JOIN `groups` ON feeds.group_id = groups.id ORDER BY site_name ASC")->fetchAll();
|
||||||
|
|
||||||
|
$view_mode = $_GET['view'] ?? 'unread';
|
||||||
|
$filter_group_id = $_GET['group_id'] ?? null;
|
||||||
|
|
||||||
|
// Build SQL
|
||||||
|
$sql = "SELECT items.id AS item_id, items.title, items.link, items.description, items.pub_date, feeds.site_name
|
||||||
|
FROM items JOIN feeds ON items.feed_id = feeds.id ";
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($view_mode === 'saved') {
|
||||||
|
$sql .= "WHERE is_saved = 1 ";
|
||||||
|
} else {
|
||||||
|
$sql .= "WHERE is_read = 0 AND is_saved = 0 ";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($filter_group_id) {
|
||||||
|
$sql .= "AND feeds.group_id = ? ";
|
||||||
|
$params[] = $filter_group_id;
|
||||||
|
}
|
||||||
|
$sql .= "ORDER BY pub_date DESC LIMIT 50";
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$items = $stmt->fetchAll();
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>RSS Catcher</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.sidebar { height: 100vh; overflow-y: auto; position: fixed; width: 25%; }
|
||||||
|
.main-content { margin-left: 25%; width: 75%; }
|
||||||
|
.nav-link.active { background-color: #0d6efd; color: white !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 bg-white p-4 border-end sidebar">
|
||||||
|
<h3 class="mb-4">RSS Catcher</h3>
|
||||||
|
|
||||||
|
<div class="list-group mb-4">
|
||||||
|
<a href="index.php" class="list-group-item list-group-item-action <?= ($view_mode == 'unread' && !$filter_group_id) ? 'active' : '' ?>">
|
||||||
|
📥 All Unread
|
||||||
|
</a>
|
||||||
|
<a href="?view=saved" class="list-group-item list-group-item-action <?= ($view_mode == 'saved') ? 'active' : '' ?>">
|
||||||
|
⭐ Saved for Later
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="text-muted text-uppercase small">Filter by Group</h6>
|
||||||
|
<div class="list-group mb-4">
|
||||||
|
<?php foreach($groups as $g): ?>
|
||||||
|
<a href="?group_id=<?= $g['id'] ?>"
|
||||||
|
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center <?= ($filter_group_id == $g['id']) ? 'active' : '' ?>">
|
||||||
|
<?= htmlspecialchars($g['name']) ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button class="btn btn-outline-dark btn-sm" data-bs-toggle="modal" data-bs-target="#manageFeedsModal">
|
||||||
|
🛠 Manage Feeds (Edit/Delete)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="accordion" id="adminControls">
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#addControls">
|
||||||
|
⚙️ Add New
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="addControls" class="accordion-collapse collapse" data-bs-parent="#adminControls">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<form method="post" class="mb-3">
|
||||||
|
<label class="form-label small">New Group</label>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="text" name="group_name" class="form-control" required>
|
||||||
|
<button class="btn btn-secondary" name="add_group">+</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<label class="form-label small">New Feed</label>
|
||||||
|
<input type="text" name="site_name" class="form-control form-control-sm mb-2" placeholder="Site Name" required>
|
||||||
|
<input type="url" name="rss_url" class="form-control form-control-sm mb-2" placeholder="RSS URL" required>
|
||||||
|
<select name="group_id" class="form-select form-select-sm mb-2">
|
||||||
|
<?php foreach($groups as $g): ?>
|
||||||
|
<option value="<?= $g['id'] ?>"><?= $g['name'] ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary btn-sm w-100" name="add_feed">Add Feed</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="fetch.php" class="btn btn-success btn-sm">Force Refresh</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-9 p-4 main-content">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
|
||||||
|
<h2>
|
||||||
|
<?php
|
||||||
|
$count_label = " <small class='text-muted'>(" . count($items) . ")</small>";
|
||||||
|
if ($view_mode === 'saved') echo "⭐ Saved Articles" . $count_label;
|
||||||
|
elseif ($filter_group_id) echo "📂 Group View" . $count_label;
|
||||||
|
else echo "📥 Unread Articles" . $count_label;
|
||||||
|
?>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<?php if (isset($_SESSION['undo_entry_id'])): ?>
|
||||||
|
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<span>✅ Article marked as read.</span>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$undoParams = "undo_action=true";
|
||||||
|
if (isset($_GET['view'])) $undoParams .= "&view=" . $_GET['view'];
|
||||||
|
if (isset($_GET['group_id'])) $undoParams .= "&group_id=" . $_GET['group_id'];
|
||||||
|
?>
|
||||||
|
|
||||||
|
<a href="?<?= $undoParams ?>" class="btn btn-sm btn-dark">↩ Undo</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (count($items) === 0): ?>
|
||||||
|
<div class="alert alert-info">No articles found in this view.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<?php foreach($items as $item): ?>
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">
|
||||||
|
<a href="<?= $item['link'] ?>" target="_blank" class="text-decoration-none text-dark">
|
||||||
|
<?= htmlspecialchars($item['title']) ?>
|
||||||
|
</a>
|
||||||
|
</h5>
|
||||||
|
<h6 class="card-subtitle mb-2 text-muted small">
|
||||||
|
<?= htmlspecialchars($item['site_name']) ?> | <?= date('M j, g:i a', strtotime($item['pub_date'])) ?>
|
||||||
|
</h6>
|
||||||
|
<p class="card-text text-secondary">
|
||||||
|
<?= strip_tags(substr($item['description'], 0, 250)) ?>...
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<?php
|
||||||
|
$contextParams = "&view=" . $view_mode;
|
||||||
|
if ($filter_group_id) $contextParams .= "&group_id=" . $filter_group_id;
|
||||||
|
?>
|
||||||
|
<a href="?mark_read=<?= $item['item_id'] ?><?= $contextParams ?>" class="btn btn-sm btn-outline-secondary">Mark as Read</a>
|
||||||
|
|
||||||
|
<?php if($view_mode === 'saved'): ?>
|
||||||
|
<a href="?unsave_item=<?= $item['item_id'] ?><?= $contextParams ?>" class="btn btn-sm btn-warning">Remove from Saved</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<a href="?save_item=<?= $item['item_id'] ?><?= $contextParams ?>" class="btn btn-sm btn-outline-primary">⭐ Save for Later</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="manageFeedsModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Manage Feeds</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Group</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach($all_feeds as $f): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= htmlspecialchars($f['site_name']) ?></td>
|
||||||
|
<td><?= htmlspecialchars($f['group_name']) ?></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-primary"
|
||||||
|
onclick="openEditModal(<?= $f['id'] ?>, '<?= addslashes($f['site_name']) ?>', '<?= addslashes($f['rss_url']) ?>', <?= $f['group_id'] ?>)">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<form method="post" style="display:inline;" onsubmit="return confirm('Delete this feed and ALL its articles?');">
|
||||||
|
<input type="hidden" name="feed_id_to_delete" value="<?= $f['id'] ?>">
|
||||||
|
<button class="btn btn-sm btn-danger" name="delete_feed">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="editFeedModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="post">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Edit Feed</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" name="edit_feed_id" id="edit_feed_id">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label>Site Name</label>
|
||||||
|
<input type="text" name="edit_site_name" id="edit_site_name" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label>RSS URL</label>
|
||||||
|
<input type="url" name="edit_rss_url" id="edit_rss_url" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label>Group</label>
|
||||||
|
<select name="edit_group_id" id="edit_group_id" class="form-select">
|
||||||
|
<?php foreach($groups as $g): ?>
|
||||||
|
<option value="<?= $g['id'] ?>"><?= $g['name'] ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-primary" name="edit_feed">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
function openEditModal(id, name, url, groupId) {
|
||||||
|
// Populate fields
|
||||||
|
document.getElementById('edit_feed_id').value = id;
|
||||||
|
document.getElementById('edit_site_name').value = name;
|
||||||
|
document.getElementById('edit_rss_url').value = url;
|
||||||
|
document.getElementById('edit_group_id').value = groupId;
|
||||||
|
|
||||||
|
// Open Modal
|
||||||
|
var myModal = new bootstrap.Modal(document.getElementById('editFeedModal'));
|
||||||
|
myModal.show();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
schema.sql
Normal file
30
schema.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
CREATE TABLE `groups` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` varchar(255) NOT NULL,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE `feeds` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`group_id` int(11) NOT NULL,
|
||||||
|
`site_name` varchar(255) NOT NULL,
|
||||||
|
`rss_url` varchar(255) NOT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
FOREIGN KEY (`group_id`) REFERENCES `groups`(`id`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE `items` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`feed_id` int(11) NOT NULL,
|
||||||
|
`title` varchar(255) NOT NULL,
|
||||||
|
`link` varchar(500) NOT NULL,
|
||||||
|
`description` TEXT,
|
||||||
|
`pub_date` datetime DEFAULT NULL,
|
||||||
|
`is_read` tinyint(1) DEFAULT 0,
|
||||||
|
`is_saved` tinyint(1) DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `unique_link` (`link`),
|
||||||
|
FOREIGN KEY (`feed_id`) REFERENCES `feeds`(`id`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO `groups` (`name`) VALUES ('Tech News');
|
||||||
Reference in New Issue
Block a user