Initial commit: RSS feed dashboard aggregating YouTube, Reddit and RT

- PHP 8 + PDO SQLite cache with on-demand TTL refresh and dedup by link hash
- laminas-feed + Guzzle for RSS/Atom parsing and HTTP
- Bootstrap 5 UI with source and time-range filters, AJAX load-more
- User cron-free deployment under Apache/php-fpm; .htaccess hardening
This commit is contained in:
Harwinder Sekhon
2026-10-05 17:07:48 -04:00
commit ad4dc6b984
21 changed files with 2732 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
Require all denied
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use App\View;
/** @var array<int, array<string, mixed>> $items */
foreach ($items as $item):
$source = (string) $item['source'];
$color = View::sourceColor($source);
$link = View::e((string) $item['link']);
$publishedAt = $item['published_at'] ?? null;
?>
<div class="col-12 col-md-6 col-xl-4">
<article class="card h-100 feed-card" style="--src: <?= View::e($color) ?>">
<?php if (!empty($item['thumbnail'])): ?>
<a href="<?= $link ?>" target="_blank" rel="noopener noreferrer" class="feed-thumb-link">
<img src="<?= View::e((string) $item['thumbnail']) ?>"
class="card-img-top feed-thumb"
alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer">
<span class="thumb-tag"><?= View::e(View::sourceLabel($source)) ?></span>
</a>
<?php endif; ?>
<div class="card-body d-flex flex-column">
<div class="d-flex align-items-center gap-2 mb-2">
<?php if (empty($item['thumbnail'])): ?>
<span class="source-chip"><?= View::e(View::sourceLabel($source)) ?></span>
<?php endif; ?>
<small class="feed-name text-truncate mono"><?= View::e((string) ($item['feed_label'] ?? '')) ?></small>
</div>
<h2 class="card-title mb-2">
<a href="<?= $link ?>" target="_blank" rel="noopener noreferrer"><?= View::e((string) $item['title']) ?></a>
</h2>
<?php if (!empty($item['description'])): ?>
<p class="card-text flex-grow-1 mb-2"><?= View::e((string) $item['description']) ?></p>
<?php endif; ?>
<div class="card-footer-meta mt-auto d-flex justify-content-between align-items-center pt-3">
<span class="author text-truncate me-2"><?= View::e((string) ($item['author'] ?? '')) ?></span>
<?php if ($publishedAt): ?>
<time datetime="<?= View::e((string) $publishedAt) ?>"
data-timestamp="<?= View::e((string) $publishedAt) ?>"
class="timestamp mono flex-shrink-0"><?= View::e(View::timeAgo((string) $publishedAt)) ?></time>
<?php endif; ?>
</div>
</div>
</article>
</div>
<?php endforeach; ?>
+162
View File
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
use App\View;
/** @var array<string, mixed> $config */
/** @var array<int, string> $sources */
/** @var string|null $activeSource */
/** @var string|null $activeRange */
/** @var array<string, mixed> $page */
/** @var array<string, mixed> $refresh */
$tabs = ['' => 'All'];
foreach ($sources as $source) {
$tabs[$source] = View::sourceLabel($source);
}
$activeRange = $activeRange ?? '';
$ranges = $config['ranges'] ?? [];
$rangeOptions = ['' => 'All time'];
foreach (array_keys($ranges) as $rangeKey) {
$rangeOptions[$rangeKey] = View::rangeLabel($rangeKey);
}
$linkFor = static function (string $source, string $range): string {
$params = [];
if ($source !== '') {
$params['source'] = $source;
}
if ($range !== '') {
$params['range'] = $range;
}
return $params === [] ? '?' : '?' . http_build_query($params);
};
$errors = $refresh['errors'] ?? [];
$total = (int) $page['total'];
?>
<!doctype html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RSS Feed Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link href="assets/style.css" rel="stylesheet">
</head>
<body class="app-body">
<header class="site-header">
<div class="container-xxl d-flex align-items-center justify-content-between gap-3 py-3">
<a class="brand" href="?">
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-text">RSS <em>Feed</em> Dashboard</span>
</a>
<div class="header-meta d-none d-md-flex align-items-center">
<span class="pulse" aria-hidden="true"></span>
<span class="mono"><?= $total ?> stories</span>
<span class="sep">/</span>
<span class="mono">YouTube · Reddit · RT</span>
</div>
</div>
<div class="spectrum-rail" aria-hidden="true"></div>
</header>
<main class="container-xxl py-4">
<div class="toolbar mb-4">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3">
<ul class="nav filter-pills flex-nowrap overflow-auto">
<?php foreach ($tabs as $key => $label): ?>
<?php
$isActive = ($activeSource ?? '') === $key;
$color = $key === '' ? 'var(--accent)' : View::sourceColor($key);
?>
<li class="nav-item">
<a class="filter-pill <?= $isActive ? 'active' : '' ?>"
style="--pill: <?= View::e($color) ?>"
href="<?= $linkFor($key, $activeRange) ?>">
<span class="pill-dot" aria-hidden="true"></span><?= View::e($label) ?>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php if (($refresh['attempted'] ?? 0) > 0): ?>
<span class="refresh-chip mono">
<span aria-hidden="true">↻</span>
<?= (int) $refresh['succeeded'] ?>/<?= (int) $refresh['attempted'] ?> feeds
· <?= (int) $refresh['new_items'] ?> new
</span>
<?php endif; ?>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 mt-3">
<span class="toolbar-label mono">Time</span>
<ul class="nav range-pills flex-nowrap overflow-auto">
<?php foreach ($rangeOptions as $key => $label): ?>
<li class="nav-item">
<a class="range-pill <?= $activeRange === $key ? 'active' : '' ?>"
href="<?= $linkFor($activeSource ?? '', $key) ?>"><?= View::e($label) ?></a>
</li>
<?php endforeach; ?>
</ul>
</div>
</div>
<?php if ($errors !== []): ?>
<div class="alert alert-warning alert-dismissible fade show small alert-soft" role="alert">
<strong>Some feeds could not be refreshed:</strong>
<ul class="mb-0 ps-3">
<?php foreach ($errors as $error): ?>
<li><?= View::e((string) $error) ?></li>
<?php endforeach; ?>
</ul>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php endif; ?>
<div id="feed"
class="row g-4"
data-api="api/items.php"
data-source="<?= View::e((string) ($activeSource ?? '')) ?>"
data-range="<?= View::e($activeRange) ?>"
data-page="1"
data-page-size="<?= (int) $page['page_size'] ?>"
data-has-more="<?= $page['has_more'] ? '1' : '0' ?>"
data-total="<?= $total ?>">
<?= View::render('cards', ['items' => $page['items']]) ?>
</div>
<div id="feed-empty" class="empty-state text-center <?= $total > 0 ? 'd-none' : '' ?>">
<div class="empty-mark" aria-hidden="true"></div>
<?php if ($activeRange !== ''): ?>
<p class="empty-title mb-1">Nothing in the past <?= View::e(View::rangeLabel($activeRange)) ?></p>
<p class="small mb-0">Try a wider time range to see more.</p>
<?php else: ?>
<p class="empty-title mb-1">Nothing in the feed yet</p>
<p class="small mb-0">Feeds may be rate limited right now — reload in a moment.</p>
<?php endif; ?>
</div>
<div class="text-center mt-5">
<button id="load-more"
type="button"
class="btn btn-energy <?= $page['has_more'] ? '' : 'd-none' ?>">
<span class="spinner-border spinner-border-sm me-2 d-none" role="status" aria-hidden="true"></span>
Load more stories
</button>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="assets/app.js" defer></script>
</body>
</html>