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
+7
View File
@@ -0,0 +1,7 @@
/vendor/
/data/
/composer.phar
composer-setup.php
*.sqlite
*.sqlite-shm
*.sqlite-wal
+9
View File
@@ -0,0 +1,9 @@
DirectoryIndex index.php index.html
# Prevent directory listing if index.php is unavailable.
Options -Indexes
# Never expose Composer or project metadata over HTTP.
<FilesMatch "^(composer\.(json|lock|phar)|\.gitignore)$">
Require all denied
</FilesMatch>
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/** @var array<string, mixed> $app */
$app = require dirname(__DIR__) . '/src/bootstrap.php';
$config = $app['config'];
$service = $app['service'];
// Respect the TTL so long-lived pages stay fresh without hammering providers.
$refresh = $service->refreshStale();
$sources = $service->sources();
$source = $_GET['source'] ?? null;
if (!is_string($source) || !in_array($source, $sources, true)) {
$source = null;
}
$ranges = $config['ranges'] ?? [];
$range = $_GET['range'] ?? '';
if (!is_string($range) || !array_key_exists($range, $ranges)) {
$range = '';
}
$since = $range !== '' ? gmdate('c', time() - (int) $ranges[$range]) : null;
$pageNumber = max(1, (int) ($_GET['page'] ?? 1));
$page = $service->page($source, $pageNumber, (int) $config['page_size'], $since);
$html = App\View::render('cards', ['items' => $page['items']]);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode([
'html' => $html,
'page' => $page['page'],
'page_size' => $page['page_size'],
'count' => count($page['items']),
'total' => $page['total'],
'has_more' => $page['has_more'],
'range' => $range,
'refresh' => $refresh,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+124
View File
@@ -0,0 +1,124 @@
(function () {
'use strict';
var feed = document.getElementById('feed');
if (!feed) {
return;
}
var loadMoreBtn = document.getElementById('load-more');
var emptyState = document.getElementById('feed-empty');
var spinner = loadMoreBtn ? loadMoreBtn.querySelector('.spinner-border') : null;
var apiUrl = feed.dataset.api;
var source = feed.dataset.source || '';
var range = feed.dataset.range || '';
var currentPage = parseInt(feed.dataset.page, 10) || 1;
var hasMore = feed.dataset.hasMore === '1';
var loading = false;
function timeAgo(iso) {
var ts = Date.parse(iso);
if (isNaN(ts)) {
return '';
}
var diff = Math.floor((Date.now() - ts) / 1000);
if (diff < 60) {
return 'just now';
}
var units = [
[31536000, 'y'], [2592000, 'mo'], [604800, 'w'],
[86400, 'd'], [3600, 'h'], [60, 'm']
];
for (var i = 0; i < units.length; i++) {
if (diff >= units[i][0]) {
return Math.floor(diff / units[i][0]) + units[i][1] + ' ago';
}
}
return 'just now';
}
function refreshTimes() {
document.querySelectorAll('time[data-timestamp]').forEach(function (el) {
var text = timeAgo(el.dataset.timestamp);
if (text) {
el.textContent = text;
}
});
}
function setLoading(isLoading) {
if (!loadMoreBtn) {
return;
}
loadMoreBtn.disabled = isLoading;
if (spinner) {
spinner.classList.toggle('d-none', !isLoading);
}
}
function updateButton() {
if (!loadMoreBtn) {
return;
}
loadMoreBtn.classList.toggle('d-none', !hasMore);
}
function loadMore() {
if (loading || !hasMore) {
return;
}
loading = true;
setLoading(true);
var params = new URLSearchParams({ page: String(currentPage + 1) });
if (source) {
params.set('source', source);
}
if (range) {
params.set('range', range);
}
fetch(apiUrl + '?' + params.toString(), {
headers: { 'Accept': 'application/json' }
})
.then(function (response) {
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
return response.json();
})
.then(function (data) {
if (data.html) {
feed.insertAdjacentHTML('beforeend', data.html);
}
currentPage = data.page;
hasMore = Boolean(data.has_more);
feed.dataset.page = String(currentPage);
feed.dataset.hasMore = hasMore ? '1' : '0';
if (emptyState) {
emptyState.classList.toggle('d-none', data.total > 0);
}
refreshTimes();
updateButton();
})
.catch(function (error) {
console.error('Failed to load more items:', error);
})
.finally(function () {
loading = false;
setLoading(false);
});
}
if (loadMoreBtn) {
loadMoreBtn.addEventListener('click', loadMore);
}
refreshTimes();
window.setInterval(refreshTimes, 60000);
})();
+439
View File
@@ -0,0 +1,439 @@
:root {
--ink: #160b2e;
--ink-soft: #5b5178;
--bg: #f4f1ff;
--surface: #ffffff;
--line: #e6dffb;
--accent: #6a3dff;
--youtube: #ff1f4b;
--reddit: #ff6a00;
--rt: #00c389;
--sun: #ffc24b;
--display: "Space Grotesk", system-ui, sans-serif;
--body: "Plus Jakarta Sans", system-ui, -apple-system, sans-serif;
--mono: "JetBrains Mono", ui-monospace, monospace;
}
/* ---------- Shell ---------- */
.app-body {
position: relative;
min-height: 100vh;
background-color: var(--bg);
color: var(--ink);
font-family: var(--body);
-webkit-font-smoothing: antialiased;
}
.app-body::before {
content: "";
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background:
radial-gradient(42rem 30rem at 6% -8%, rgba(106, 61, 255, 0.18), transparent 62%),
radial-gradient(36rem 26rem at 98% -4%, rgba(255, 31, 75, 0.14), transparent 60%),
radial-gradient(34rem 26rem at 52% 112%, rgba(0, 195, 137, 0.16), transparent 62%),
radial-gradient(30rem 22rem at 82% 78%, rgba(255, 106, 0, 0.12), transparent 60%);
}
.site-header,
main {
position: relative;
z-index: 1;
}
/* ---------- Header ---------- */
.site-header {
position: sticky;
top: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.78);
border-bottom: 1px solid var(--line);
backdrop-filter: saturate(180%) blur(16px);
-webkit-backdrop-filter: saturate(180%) blur(16px);
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.65rem;
text-decoration: none;
}
.brand-mark {
width: 26px;
height: 26px;
border-radius: 9px;
background: conic-gradient(from 220deg, var(--accent), var(--youtube), var(--reddit), var(--rt), var(--accent));
box-shadow: 0 6px 16px rgba(106, 61, 255, 0.4);
}
.brand-text {
font-family: var(--display);
font-weight: 700;
font-size: 1.12rem;
letter-spacing: -0.01em;
color: var(--ink);
}
.brand-text em {
font-style: normal;
background: linear-gradient(90deg, var(--accent), var(--youtube) 55%, var(--reddit));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.header-meta {
gap: 0.55rem;
font-size: 0.76rem;
color: var(--ink-soft);
}
.header-meta .sep {
opacity: 0.4;
}
.pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--rt);
animation: pulse 2.2s ease-out infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(0, 195, 137, 0.55); }
70% { box-shadow: 0 0 0 9px rgba(0, 195, 137, 0); }
100% { box-shadow: 0 0 0 0 rgba(0, 195, 137, 0); }
}
.spectrum-rail {
height: 5px;
background: linear-gradient(90deg,
var(--accent), var(--youtube) 32%, var(--reddit) 60%, var(--sun) 78%, var(--rt));
}
/* ---------- Filter pills ---------- */
.filter-pills {
gap: 0.5rem;
}
.filter-pill {
--pill: var(--accent);
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.42rem 0.95rem;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.7);
color: var(--ink);
font-weight: 600;
font-size: 0.86rem;
text-decoration: none;
white-space: nowrap;
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.filter-pill .pill-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--pill);
transition: background 0.15s ease;
}
.filter-pill:hover {
transform: translateY(-1px);
box-shadow: 0 8px 18px -10px var(--pill);
color: var(--ink);
}
.filter-pill.active {
background: var(--pill);
border-color: transparent;
color: #fff;
box-shadow: 0 10px 22px -10px var(--pill);
}
.filter-pill.active .pill-dot {
background: rgba(255, 255, 255, 0.9);
}
.refresh-chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.32rem 0.7rem;
border-radius: 999px;
background: rgba(106, 61, 255, 0.1);
color: var(--accent);
font-size: 0.72rem;
font-weight: 500;
}
/* ---------- Time range ---------- */
.toolbar-label {
font-size: 0.66rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-soft);
padding-right: 0.15rem;
}
.range-pills {
gap: 0.35rem;
}
.range-pill {
display: inline-flex;
align-items: center;
padding: 0.32rem 0.75rem;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.7);
color: var(--ink-soft);
font-size: 0.78rem;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.range-pill:hover {
color: var(--ink);
border-color: var(--accent);
transform: translateY(-1px);
}
.range-pill.active {
background: var(--accent);
border-color: transparent;
color: #fff;
box-shadow: 0 10px 22px -10px var(--accent);
}
/* ---------- Cards ---------- */
.feed-card {
--src: var(--accent);
position: relative;
border: 1px solid var(--line);
border-top: 4px solid var(--src);
border-radius: 18px;
background: var(--surface);
overflow: hidden;
box-shadow: 0 1px 2px rgba(22, 11, 46, 0.04), 0 14px 34px -26px rgba(22, 11, 46, 0.5);
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.feed-card:hover {
transform: translateY(-4px);
box-shadow: 0 20px 44px -20px rgba(22, 11, 46, 0.4);
box-shadow: 0 20px 44px -20px color-mix(in srgb, var(--src) 55%, transparent);
}
.feed-thumb-link {
position: relative;
display: block;
overflow: hidden;
background: #efeaff;
}
.feed-thumb-link::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(to top, rgba(22, 11, 46, 0.5), transparent 52%);
}
.feed-thumb {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
display: block;
transition: transform 0.4s ease;
}
.feed-card:hover .feed-thumb {
transform: scale(1.06);
}
.thumb-tag,
.source-chip {
font-family: var(--mono);
font-size: 0.64rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: #fff;
background: var(--src);
}
.thumb-tag {
position: absolute;
left: 0.7rem;
bottom: 0.7rem;
z-index: 1;
padding: 0.24rem 0.6rem;
border-radius: 999px;
box-shadow: 0 6px 16px -6px rgba(22, 11, 46, 0.6);
}
.source-chip {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.55rem;
border-radius: 999px;
}
.feed-card .card-body {
padding: 1rem 1.1rem 1.1rem;
}
.feed-name {
font-size: 0.68rem;
letter-spacing: 0.02em;
color: var(--ink-soft);
}
.feed-card .card-title {
font-family: var(--display);
font-size: 1.02rem;
font-weight: 600;
line-height: 1.32;
}
.feed-card .card-title a {
color: var(--ink);
text-decoration: none;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
transition: color 0.15s ease;
}
.feed-card .card-title a:hover {
color: var(--src);
}
.feed-card .card-text {
font-size: 0.86rem;
line-height: 1.55;
color: var(--ink-soft);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-footer-meta {
border-top: 1px dashed var(--line);
}
.author {
font-size: 0.78rem;
color: var(--ink-soft);
}
.timestamp {
font-size: 0.72rem;
font-weight: 500;
color: var(--src);
}
.mono {
font-family: var(--mono);
}
/* ---------- Empty state ---------- */
.empty-state {
padding: 4.5rem 1rem;
color: var(--ink-soft);
}
.empty-mark {
width: 58px;
height: 58px;
margin: 0 auto 1rem;
border-radius: 20px;
background: conic-gradient(from 220deg, var(--accent), var(--youtube), var(--reddit), var(--rt), var(--accent));
filter: saturate(1.1);
animation: spin 12s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-title {
font-family: var(--display);
font-size: 1.15rem;
color: var(--ink);
}
/* ---------- Button ---------- */
.btn-energy {
border: 0;
border-radius: 999px;
padding: 0.7rem 1.7rem;
font-family: var(--display);
font-weight: 600;
color: #fff;
background: linear-gradient(100deg, var(--accent), var(--youtube) 48%, var(--reddit) 78%, var(--rt));
background-size: 160% 100%;
box-shadow: 0 14px 30px -14px rgba(106, 61, 255, 0.8);
transition: transform 0.15s ease, background-position 0.4s ease, box-shadow 0.15s ease;
}
.btn-energy:hover,
.btn-energy:focus {
color: #fff;
transform: translateY(-2px);
background-position: 100% 0;
box-shadow: 0 18px 36px -14px rgba(255, 31, 75, 0.7);
}
.btn-energy:disabled {
opacity: 0.65;
transform: none;
color: #fff;
}
.alert-soft {
border: 1px solid #ffe1a8;
border-radius: 14px;
background: #fff8ea;
color: #7a5a12;
}
/* ---------- Focus & motion ---------- */
a:focus-visible,
button:focus-visible {
outline: 3px solid rgba(106, 61, 255, 0.55);
outline-offset: 2px;
border-radius: 8px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "harwinder/rssfeed",
"description": "Dashboard aggregating YouTube and Reddit RSS feeds with pagination.",
"type": "project",
"license": "MIT",
"require": {
"php": ">=8.1",
"ext-pdo": "*",
"ext-curl": "*",
"laminas/laminas-feed": "^2.23",
"guzzlehttp/guzzle": "^7.9"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"config": {
"sort-packages": true,
"optimize-autoloader": true
}
}
Generated
+914
View File
@@ -0,0 +1,914 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "c77683974e2ef866a5984862d2060cf3",
"packages": [
{
"name": "guzzlehttp/guzzle",
"version": "7.15.5",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
"reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a",
"reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/promises": "^2.5.3",
"guzzlehttp/psr7": "^2.13.1",
"php": "^7.2.5 || ^8.0",
"psr/http-client": "^1.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0",
"symfony/polyfill-php80": "^1.25"
},
"provide": {
"psr/http-client-implementation": "1.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*",
"guzzle/client-integration-tests": "3.0.3",
"guzzlehttp/test-server": "^0.7",
"php-http/message-factory": "^1.1",
"phpunit/phpunit": "^8.5.52 || ^9.6.34",
"psr/log": "^1.1 || ^2.0 || ^3.0"
},
"suggest": {
"ext-curl": "Required for CURL handler support",
"ext-intl": "Required for Internationalized Domain Name (IDN) support",
"psr/log": "Required for using the Log middleware"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"files": [
"src/functions_include.php"
],
"psr-4": {
"GuzzleHttp\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Jeremy Lindblom",
"email": "jeremeamia@gmail.com",
"homepage": "https://github.com/jeremeamia"
},
{
"name": "George Mponos",
"email": "gmponos@gmail.com",
"homepage": "https://github.com/gmponos"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://github.com/sagikazarmark"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
}
],
"description": "Guzzle is a PHP HTTP client library",
"keywords": [
"client",
"curl",
"framework",
"http",
"http client",
"psr-18",
"psr-7",
"rest",
"web service"
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
"source": "https://github.com/guzzle/guzzle/tree/7.15.5"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle",
"type": "tidelift"
}
],
"time": "2026-08-24T09:21:06+00:00"
},
{
"name": "guzzlehttp/promises",
"version": "2.5.3",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
"reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1",
"reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.52 || ^9.6.34"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Promise\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
}
],
"description": "Guzzle promises library",
"keywords": [
"promise"
],
"support": {
"issues": "https://github.com/guzzle/promises/issues",
"source": "https://github.com/guzzle/promises/tree/2.5.3"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises",
"type": "tidelift"
}
],
"time": "2026-08-24T09:11:28+00:00"
},
{
"name": "guzzlehttp/psr7",
"version": "2.13.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "95e7828100de18b4e269fb1703be530082d5166d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d",
"reference": "95e7828100de18b4e269fb1703be530082d5166d",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0",
"ralouphie/getallheaders": "^3.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0",
"symfony/polyfill-php80": "^1.25"
},
"provide": {
"psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"http-interop/http-factory-tests": "1.1.0",
"jshttp/mime-db": "1.54.0.1",
"phpunit/phpunit": "^8.5.52 || ^9.6.34"
},
"suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Psr7\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "George Mponos",
"email": "gmponos@gmail.com",
"homepage": "https://github.com/gmponos"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://github.com/sagikazarmark"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://sagikazarmark.hu"
}
],
"description": "PSR-7 message implementation that also provides common utility methods",
"keywords": [
"http",
"message",
"psr-7",
"request",
"response",
"stream",
"uri",
"url"
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/2.13.1"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
"type": "tidelift"
}
],
"time": "2026-08-24T09:13:11+00:00"
},
{
"name": "laminas/laminas-escaper",
"version": "2.18.0",
"source": {
"type": "git",
"url": "https://github.com/laminas/laminas-escaper.git",
"reference": "06f211dfffff18d91844c1f55250d5d13c007e18"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/06f211dfffff18d91844c1f55250d5d13c007e18",
"reference": "06f211dfffff18d91844c1f55250d5d13c007e18",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-mbstring": "*",
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
},
"conflict": {
"zendframework/zend-escaper": "*"
},
"require-dev": {
"infection/infection": "^0.31.0",
"laminas/laminas-coding-standard": "~3.1.0",
"phpunit/phpunit": "^11.5.42",
"psalm/plugin-phpunit": "^0.19.5",
"vimeo/psalm": "^6.13.1"
},
"type": "library",
"autoload": {
"psr-4": {
"Laminas\\Escaper\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"description": "Securely and safely escape HTML, HTML attributes, JavaScript, CSS, and URLs",
"homepage": "https://laminas.dev",
"keywords": [
"escaper",
"laminas"
],
"support": {
"chat": "https://laminas.dev/chat",
"docs": "https://docs.laminas.dev/laminas-escaper/",
"forum": "https://discourse.laminas.dev",
"issues": "https://github.com/laminas/laminas-escaper/issues",
"rss": "https://github.com/laminas/laminas-escaper/releases.atom",
"source": "https://github.com/laminas/laminas-escaper"
},
"funding": [
{
"url": "https://funding.communitybridge.org/projects/laminas-project",
"type": "community_bridge"
}
],
"time": "2025-10-14T18:31:13+00:00"
},
{
"name": "laminas/laminas-feed",
"version": "2.26.3",
"source": {
"type": "git",
"url": "https://github.com/laminas/laminas-feed.git",
"reference": "86ce3b1ea7c5010413cb24c8d58530b084e8f901"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laminas/laminas-feed/zipball/86ce3b1ea7c5010413cb24c8d58530b084e8f901",
"reference": "86ce3b1ea7c5010413cb24c8d58530b084e8f901",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-filter": "*",
"ext-libxml": "*",
"laminas/laminas-escaper": "^2.9",
"laminas/laminas-stdlib": "^3.6",
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
},
"conflict": {
"laminas/laminas-servicemanager": "<3.3",
"zendframework/zend-feed": "*"
},
"require-dev": {
"laminas/laminas-cache": "^2.13.2 || ^3.12",
"laminas/laminas-cache-storage-adapter-memory": "^1.1.0 || ^2.3",
"laminas/laminas-coding-standard": "~2.5.0",
"laminas/laminas-db": "^2.18",
"laminas/laminas-http": "^2.19",
"laminas/laminas-servicemanager": "^3.22.1",
"laminas/laminas-validator": "^2.46",
"phpunit/phpunit": "^10.5.5",
"psalm/plugin-phpunit": "^0.19.0",
"psr/http-message": "^2.0",
"vimeo/psalm": "^5.18.0"
},
"suggest": {
"laminas/laminas-cache": "Laminas\\Cache component, for optionally caching feeds between requests",
"laminas/laminas-db": "Laminas\\Db component, for use with PubSubHubbub",
"laminas/laminas-http": "Laminas\\Http for PubSubHubbub, and optionally for use with Laminas\\Feed\\Reader",
"laminas/laminas-servicemanager": "Laminas\\ServiceManager component, for easily extending ExtensionManager implementations",
"laminas/laminas-validator": "Laminas\\Validator component, for validating email addresses used in Atom feeds and entries when using the Writer subcomponent",
"psr/http-message": "PSR-7 ^1.0.1, if you wish to use Laminas\\Feed\\Reader\\Http\\Psr7ResponseDecorator"
},
"type": "library",
"autoload": {
"psr-4": {
"Laminas\\Feed\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"description": "provides functionality for creating and consuming RSS and Atom feeds",
"homepage": "https://laminas.dev",
"keywords": [
"atom",
"feed",
"laminas",
"rss"
],
"support": {
"chat": "https://laminas.dev/chat",
"docs": "https://docs.laminas.dev/laminas-feed/",
"forum": "https://discourse.laminas.dev",
"issues": "https://github.com/laminas/laminas-feed/issues",
"rss": "https://github.com/laminas/laminas-feed/releases.atom",
"source": "https://github.com/laminas/laminas-feed"
},
"funding": [
{
"url": "https://crowdfunding.linuxfoundation.org/initiatives/laminas-project",
"type": "custom"
}
],
"time": "2026-10-03T12:45:16+00:00"
},
{
"name": "laminas/laminas-stdlib",
"version": "3.21.0",
"source": {
"type": "git",
"url": "https://github.com/laminas/laminas-stdlib.git",
"reference": "b1c81514cfe158aadf724c42b34d3d0a8164c096"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laminas/laminas-stdlib/zipball/b1c81514cfe158aadf724c42b34d3d0a8164c096",
"reference": "b1c81514cfe158aadf724c42b34d3d0a8164c096",
"shasum": ""
},
"require": {
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
},
"conflict": {
"zendframework/zend-stdlib": "*"
},
"require-dev": {
"laminas/laminas-coding-standard": "^3.1.0",
"phpbench/phpbench": "^1.4.1",
"phpunit/phpunit": "^11.5.42",
"psalm/plugin-phpunit": "^0.19.5",
"vimeo/psalm": "^6.13.1"
},
"type": "library",
"autoload": {
"psr-4": {
"Laminas\\Stdlib\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"description": "SPL extensions, array utilities, error handlers, and more",
"homepage": "https://laminas.dev",
"keywords": [
"laminas",
"stdlib"
],
"support": {
"chat": "https://laminas.dev/chat",
"docs": "https://docs.laminas.dev/laminas-stdlib/",
"forum": "https://discourse.laminas.dev",
"issues": "https://github.com/laminas/laminas-stdlib/issues",
"rss": "https://github.com/laminas/laminas-stdlib/releases.atom",
"source": "https://github.com/laminas/laminas-stdlib"
},
"funding": [
{
"url": "https://funding.communitybridge.org/projects/laminas-project",
"type": "community_bridge"
}
],
"time": "2025-10-11T18:13:12+00:00"
},
{
"name": "psr/http-client",
"version": "1.0.3",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-client.git",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
"shasum": ""
},
"require": {
"php": "^7.0 || ^8.0",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP clients",
"homepage": "https://github.com/php-fig/http-client",
"keywords": [
"http",
"http-client",
"psr",
"psr-18"
],
"support": {
"source": "https://github.com/php-fig/http-client"
},
"time": "2023-09-23T14:17:50+00:00"
},
{
"name": "psr/http-factory",
"version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-factory.git",
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
"shasum": ""
},
"require": {
"php": ">=7.1",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
"keywords": [
"factory",
"http",
"message",
"psr",
"psr-17",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-factory"
},
"time": "2024-04-15T12:06:14+00:00"
},
{
"name": "psr/http-message",
"version": "2.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-message.git",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP messages",
"homepage": "https://github.com/php-fig/http-message",
"keywords": [
"http",
"http-message",
"psr",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-message/tree/2.0"
},
"time": "2023-04-04T09:54:51+00:00"
},
{
"name": "ralouphie/getallheaders",
"version": "3.0.3",
"source": {
"type": "git",
"url": "https://github.com/ralouphie/getallheaders.git",
"reference": "120b605dfeb996808c31b6477290a714d356e822"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
"reference": "120b605dfeb996808c31b6477290a714d356e822",
"shasum": ""
},
"require": {
"php": ">=5.6"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.1",
"phpunit/phpunit": "^5 || ^6.5"
},
"type": "library",
"autoload": {
"files": [
"src/getallheaders.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ralph Khattar",
"email": "ralph.khattar@gmail.com"
}
],
"description": "A polyfill for getallheaders.",
"support": {
"issues": "https://github.com/ralouphie/getallheaders/issues",
"source": "https://github.com/ralouphie/getallheaders/tree/develop"
},
"time": "2019-03-08T08:55:37+00:00"
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.7.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
"reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d",
"reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.7-dev"
}
},
"autoload": {
"files": [
"function.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-05T06:23:12+00:00"
},
{
"name": "symfony/polyfill-php80",
"version": "v1.43.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
"reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
"reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Php80\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ion Bazan",
"email": "ion.bazan@gmail.com"
},
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-09-25T15:50:05+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": ">=8.1",
"ext-pdo": "*",
"ext-curl": "*"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
}
+1
View File
@@ -0,0 +1 @@
Require all denied
+38
View File
@@ -0,0 +1,38 @@
<?php
return [
// SQLite database location (kept outside the web root).
'db_path' => dirname(__DIR__) . '/data/feeds.sqlite',
// Seconds before a feed is considered stale and re-fetched on demand.
'cache_ttl' => 1800,
// Shorter retry interval for feeds whose last fetch failed (e.g. rate limited).
'error_retry_ttl' => 120,
// Minimum milliseconds between requests to the same host.
'host_interval_ms' => 1000,
// Hosts with aggressive rate limits: only fetch this many of their feeds per refresh.
'throttled_hosts' => ['www.reddit.com'],
'throttled_host_max_per_refresh' => 1,
// Number of items per page.
'page_size' => 20,
// Time-range filters (key => window length in seconds), shown newest-first.
'ranges' => [
'1h' => 3600,
'2h' => 7200,
'6h' => 21600,
'24h' => 86400,
'7d' => 604800,
],
// HTTP client settings.
'request_timeout' => 15,
'connect_timeout' => 10,
// Identify the client to feed providers (important for Reddit).
'user_agent' => 'RSSFeed/1.0 (+https://localhost; contact: admin@localhost)',
];
+64
View File
@@ -0,0 +1,64 @@
<?php
return [
// YouTube Atom feeds (channel handles resolved to channel IDs).
[
'source' => 'youtube',
'label' => 'Prof. Jiang - Fluxhistory',
'url' => 'https://www.youtube.com/feeds/videos.xml?channel_id=UCtmugk6zDcFsiqLpSiE3Wmw', // @Prof.Jiang_Fluxhistory
],
[
'source' => 'youtube',
'label' => 'Fireship',
'url' => 'https://www.youtube.com/feeds/videos.xml?channel_id=UCsBjURrPoezykLs9EqgamOA', // @Fireship
],
[
'source' => 'youtube',
'label' => 'Ravish Kumar',
'url' => 'https://www.youtube.com/feeds/videos.xml?channel_id=UC0yXUUIaPVAqZLgRjvtMftw', // @ravishkumar.official
],
[
'source' => 'youtube',
'label' => 'David Bombal',
'url' => 'https://www.youtube.com/feeds/videos.xml?channel_id=UCP7WmQ_U4GB3K51Od9QvM0w', // @davidbombal
],
// Reddit RSS feeds.
[
'source' => 'reddit',
'label' => 'r/DataHoarder',
'url' => 'https://www.reddit.com/r/DataHoarder/.rss',
],
[
'source' => 'reddit',
'label' => 'r/Piracy',
'url' => 'https://www.reddit.com/r/Piracy/.rss',
],
[
'source' => 'reddit',
'label' => 'r/homelabindia',
'url' => 'https://www.reddit.com/r/homelabindia/.rss',
],
[
'source' => 'reddit',
'label' => 'r/LocalLLM',
'url' => 'https://www.reddit.com/r/LocalLLM/.rss',
],
[
'source' => 'reddit',
'label' => 'r/homelab',
'url' => 'https://www.reddit.com/r/homelab/.rss',
],
[
'source' => 'reddit',
'label' => 'r/networking',
'url' => 'https://www.reddit.com/r/networking/.rss',
],
// RT news (RSS with image enclosures).
[
'source' => 'rt',
'label' => 'RT News',
'url' => 'https://www.rt.com/rss/',
],
];
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/** @var array<string, mixed> $app */
$app = require __DIR__ . '/src/bootstrap.php';
$config = $app['config'];
$service = $app['service'];
$refresh = $service->refreshStale();
$sources = $service->sources();
$activeSource = $_GET['source'] ?? null;
if (!is_string($activeSource) || !in_array($activeSource, $sources, true)) {
$activeSource = null;
}
$ranges = $config['ranges'] ?? [];
$activeRange = $_GET['range'] ?? '';
if (!is_string($activeRange) || !array_key_exists($activeRange, $ranges)) {
$activeRange = '';
}
$since = $activeRange !== '' ? gmdate('c', time() - (int) $ranges[$activeRange]) : null;
$page = $service->page($activeSource, 1, (int) $config['page_size'], $since);
echo App\View::render('layout', [
'config' => $config,
'sources' => $sources,
'activeSource' => $activeSource,
'activeRange' => $activeRange,
'page' => $page,
'refresh' => $refresh,
]);
+1
View File
@@ -0,0 +1 @@
Require all denied
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
final class Database
{
public static function connect(string $path): PDO
{
$dir = dirname($path);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
throw new \RuntimeException(sprintf('Unable to create database directory "%s".', $dir));
}
$pdo = new PDO('sqlite:' . $path, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$pdo->exec('PRAGMA journal_mode = WAL');
$pdo->exec('PRAGMA busy_timeout = 5000');
$pdo->exec('PRAGMA foreign_keys = ON');
self::migrate($pdo);
return $pdo;
}
private static function migrate(PDO $pdo): void
{
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
feed_label TEXT,
feed_url TEXT NOT NULL,
title TEXT NOT NULL,
link TEXT NOT NULL,
author TEXT,
published_at TEXT,
description TEXT,
thumbnail TEXT,
fetched_at TEXT NOT NULL
)
SQL);
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_items_published ON items (published_at DESC)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_items_source ON items (source, published_at DESC)');
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS feeds (
url TEXT PRIMARY KEY,
source TEXT NOT NULL,
label TEXT,
last_attempt_at TEXT,
last_success_at TEXT,
last_error TEXT
)
SQL);
}
}
+303
View File
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace App;
use DOMDocument;
use DOMElement;
use DOMXPath;
use GuzzleHttp\Client;
use Laminas\Feed\Reader\Reader;
final class FeedFetcher
{
private const MAX_DESCRIPTION_LENGTH = 600;
private const MAX_ATTEMPTS = 2;
private Client $http;
/** @var array<string, float> */
private array $lastRequestPerHost = [];
private int $hostIntervalMs;
public function __construct(private array $options = [])
{
$this->hostIntervalMs = (int) ($options['host_interval_ms'] ?? 1000);
$this->http = new Client([
'timeout' => $options['request_timeout'] ?? 15,
'connect_timeout' => $options['connect_timeout'] ?? 10,
'http_errors' => false,
'allow_redirects' => true,
'headers' => [
'User-Agent' => $options['user_agent'] ?? 'RSSFeed/1.0',
'Accept' => 'application/atom+xml,application/rss+xml,application/xml,text/xml;q=0.9,*/*;q=0.8',
'Accept-Language' => 'en-US,en;q=0.9',
],
]);
}
/**
* Fetch and normalize a single feed.
*
* @return array{title: ?string, items: array<int, array<string, mixed>>}
*/
public function fetch(array $feed): array
{
$url = (string) $feed['url'];
$source = (string) $feed['source'];
$label = (string) ($feed['label'] ?? $url);
$xml = $this->download($url);
$parsed = Reader::importString($xml);
$extras = self::extractExtras($xml);
$now = gmdate('c');
$items = [];
foreach ($parsed as $entry) {
$link = self::safeUrl((string) $entry->getLink());
if ($link === null) {
continue;
}
$title = self::cleanText((string) $entry->getTitle());
$description = (string) $entry->getDescription();
if (trim($description) === '') {
$description = (string) $entry->getContent();
}
$thumbnail = $extras[$link]['thumbnail'] ?? null;
$mediaDescription = $extras[$link]['description'] ?? null;
if (trim($description) === '' && $mediaDescription !== null) {
$description = $mediaDescription;
}
if ($thumbnail === null) {
$enclosure = $entry->getEnclosure();
if ($enclosure !== null && self::isImageEnclosure($enclosure)) {
$thumbnail = (string) $enclosure->url;
}
}
if ($source === 'youtube' && $thumbnail === null) {
$videoId = self::youtubeVideoId($link);
if ($videoId !== null) {
$thumbnail = 'https://i.ytimg.com/vi/' . $videoId . '/mqdefault.jpg';
}
}
$date = $entry->getDateCreated() ?: $entry->getDateModified();
$items[] = [
'id' => hash('sha256', $link),
'source' => $source,
'feed_label' => $label,
'feed_url' => $url,
'title' => $title !== '' ? $title : '(untitled)',
'link' => $link,
'author' => self::authorName($entry->getAuthor()),
'published_at' => $date?->setTimezone(new \DateTimeZone('UTC'))->format('c'),
'description' => self::cleanText($description, self::MAX_DESCRIPTION_LENGTH),
'thumbnail' => self::safeUrl($thumbnail),
'fetched_at' => $now,
];
}
return ['title' => (string) $parsed->getTitle(), 'items' => $items];
}
private function download(string $url): string
{
$attempt = 0;
$lastStatus = 0;
while ($attempt < self::MAX_ATTEMPTS) {
$attempt++;
$this->throttle($url);
$response = $this->http->get($url);
$lastStatus = $response->getStatusCode();
if ($lastStatus === 200) {
return (string) $response->getBody();
}
// Only retry transient server errors; client errors (incl. 429) are
// handled by the caller with a staggered cooldown.
if ($lastStatus < 500) {
break;
}
if ($attempt < self::MAX_ATTEMPTS) {
$retryAfter = (int) $response->getHeaderLine('Retry-After');
$delay = $retryAfter > 0 ? min($retryAfter, 3) : 1;
sleep($delay);
}
}
throw new \RuntimeException(sprintf('HTTP %d while fetching %s', $lastStatus, $url));
}
/**
* Space out requests to the same host to avoid provider rate limiting.
*/
private function throttle(string $url): void
{
$host = (string) parse_url($url, PHP_URL_HOST);
if ($host === '' || $this->hostIntervalMs <= 0) {
return;
}
$now = microtime(true);
if (isset($this->lastRequestPerHost[$host])) {
$elapsedMs = ($now - $this->lastRequestPerHost[$host]) * 1000;
$waitMs = $this->hostIntervalMs - $elapsedMs;
if ($waitMs > 0) {
usleep((int) ($waitMs * 1000));
}
}
$this->lastRequestPerHost[$host] = microtime(true);
}
/**
* Extract media thumbnails / descriptions from the raw XML, which the
* laminas reader does not expose (YouTube media: RSS extension).
*
* @return array<string, array{thumbnail: ?string, description: ?string}>
*/
private static function extractExtras(string $xml): array
{
$dom = new DOMDocument();
$previous = libxml_use_internal_errors(true);
$loaded = $dom->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
libxml_clear_errors();
libxml_use_internal_errors($previous);
if (!$loaded) {
return [];
}
$xpath = new DOMXPath($dom);
$nodes = $xpath->query('//*[local-name()="entry" or local-name()="item"]');
if ($nodes === false) {
return [];
}
$map = [];
foreach ($nodes as $node) {
$link = self::nodeLink($xpath, $node);
if ($link === '') {
continue;
}
$thumbnail = null;
$thumbNode = $xpath->query('.//*[local-name()="thumbnail"]', $node)->item(0);
if ($thumbNode instanceof DOMElement) {
$thumbnail = $thumbNode->getAttribute('url');
}
$description = null;
$descNode = $xpath->query('.//*[local-name()="group"]/*[local-name()="description"]', $node)->item(0);
if ($descNode !== null) {
$description = $descNode->textContent;
}
$map[$link] = [
'thumbnail' => self::safeUrl($thumbnail),
'description' => $description,
];
}
return $map;
}
private static function nodeLink(DOMXPath $xpath, \DOMNode $node): string
{
$links = $xpath->query('./*[local-name()="link"]', $node);
if ($links === false) {
return '';
}
$fallback = '';
foreach ($links as $link) {
if ($link instanceof DOMElement && $link->hasAttribute('href')) {
$href = trim($link->getAttribute('href'));
if ($link->getAttribute('rel') === 'alternate') {
return $href;
}
$fallback = $fallback ?: $href;
} else {
$fallback = $fallback ?: trim($link->textContent);
}
}
return $fallback;
}
private static function authorName(array|string|null $author): ?string
{
$name = is_array($author) ? ($author['name'] ?? null) : $author;
if (!is_string($name)) {
return null;
}
$name = ltrim(self::cleanText($name), '/');
return $name === '' ? null : $name;
}
private static function youtubeVideoId(string $link): ?string
{
if (preg_match('~[?&]v=([A-Za-z0-9_-]{6,})~', $link, $m)) {
return $m[1];
}
return null;
}
private static function cleanText(string $text, int $limit = 0): string
{
$text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
$text = trim($text);
if ($limit > 0 && mb_strlen($text) > $limit) {
$text = mb_substr($text, 0, $limit) . '…';
}
return $text;
}
private static function isImageEnclosure(object $enclosure): bool
{
$url = isset($enclosure->url) ? (string) $enclosure->url : '';
$type = isset($enclosure->type) ? (string) $enclosure->type : '';
if ($url === '' || self::safeUrl($url) === null) {
return false;
}
if (str_starts_with($type, 'image/')) {
return true;
}
$path = strtolower((string) parse_url($url, PHP_URL_PATH));
return (bool) preg_match('/\.(jpe?g|png|gif|webp|avif)$/', $path);
}
private static function safeUrl(?string $url): ?string
{
if ($url === null) {
return null;
}
$url = trim($url);
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
return in_array($scheme, ['http', 'https'], true) ? $url : null;
}
}
+207
View File
@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
final class FeedRepository
{
public function __construct(private PDO $pdo)
{
}
/**
* Keep the feeds table in sync with the configured feed list.
*
* @param array<int, array<string, mixed>> $feeds
*/
public function syncFeeds(array $feeds): void
{
$stmt = $this->pdo->prepare(<<<'SQL'
INSERT INTO feeds (url, source, label)
VALUES (:url, :source, :label)
ON CONFLICT(url) DO UPDATE SET source = excluded.source, label = excluded.label
SQL);
foreach ($feeds as $feed) {
$stmt->execute([
':url' => (string) $feed['url'],
':source' => (string) $feed['source'],
':label' => (string) ($feed['label'] ?? $feed['url']),
]);
}
}
/**
* Return the subset of feeds whose last attempt is missing or older than TTL.
* Feeds that previously failed use a shorter retry interval.
*
* @param array<int, array<string, mixed>> $feeds
* @return array<int, array<string, mixed>>
*/
public function staleFeeds(array $feeds, int $ttlSeconds, int $errorTtlSeconds): array
{
$attempts = [];
foreach ($this->pdo->query('SELECT url, last_attempt_at, last_error FROM feeds') as $row) {
$attempts[(string) $row['url']] = $row;
}
$now = time();
$stale = [];
foreach ($feeds as $feed) {
$row = $attempts[(string) $feed['url']] ?? null;
$last = $row['last_attempt_at'] ?? null;
if ($last === null) {
$stale[] = $feed;
continue;
}
$age = $now - (int) strtotime((string) $last);
$isError = ($row['last_error'] ?? null) !== null;
$threshold = $ttlSeconds;
if ($isError) {
// Stagger retries so rate-limited feeds do not all retry at once.
$threshold = $errorTtlSeconds + (crc32((string) $feed['url']) % max(1, $errorTtlSeconds));
}
if ($age >= $threshold) {
$stale[] = $feed;
}
}
return $stale;
}
/**
* Insert new items, ignoring ones already stored (deduplicated by link hash).
*
* @param array<int, array<string, mixed>> $items
*/
public function upsertItems(array $items): int
{
if ($items === []) {
return 0;
}
$stmt = $this->pdo->prepare(<<<'SQL'
INSERT OR IGNORE INTO items
(id, source, feed_label, feed_url, title, link, author, published_at, description, thumbnail, fetched_at)
VALUES
(:id, :source, :feed_label, :feed_url, :title, :link, :author, :published_at, :description, :thumbnail, :fetched_at)
SQL);
$inserted = 0;
$this->pdo->beginTransaction();
try {
foreach ($items as $item) {
$stmt->execute([
':id' => $item['id'],
':source' => $item['source'],
':feed_label' => $item['feed_label'],
':feed_url' => $item['feed_url'],
':title' => $item['title'],
':link' => $item['link'],
':author' => $item['author'],
':published_at' => $item['published_at'],
':description' => $item['description'],
':thumbnail' => $item['thumbnail'],
':fetched_at' => $item['fetched_at'],
]);
$inserted += $stmt->rowCount();
}
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
return $inserted;
}
public function recordAttempt(string $url, bool $success, ?string $error = null): void
{
$now = gmdate('c');
if ($success) {
$stmt = $this->pdo->prepare(
'UPDATE feeds SET last_attempt_at = :now, last_success_at = :now2, last_error = NULL WHERE url = :url'
);
$stmt->execute([':now' => $now, ':now2' => $now, ':url' => $url]);
return;
}
$stmt = $this->pdo->prepare(
'UPDATE feeds SET last_attempt_at = :now, last_error = :error WHERE url = :url'
);
$stmt->execute([':now' => $now, ':error' => $error, ':url' => $url]);
}
/**
* Build the shared WHERE clause for item queries.
*
* @param array<string, string> $params
*/
private function itemFilter(?string $source, ?string $since, array &$params): string
{
$where = [];
if ($source !== null) {
$where[] = 'source = :source';
$params[':source'] = $source;
}
if ($since !== null) {
$where[] = 'COALESCE(published_at, fetched_at) >= :since';
$params[':since'] = $since;
}
return $where === [] ? '' : ' WHERE ' . implode(' AND ', $where);
}
/**
* @return array<int, array<string, mixed>>
*/
public function getItems(int $limit, int $offset, ?string $source = null, ?string $since = null): array
{
$limit = max(1, $limit);
$offset = max(0, $offset);
$params = [];
$sql = 'SELECT * FROM items' . $this->itemFilter($source, $since, $params)
. ' ORDER BY COALESCE(published_at, fetched_at) DESC, id LIMIT :limit OFFSET :offset';
$stmt = $this->pdo->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value, PDO::PARAM_STR);
}
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
public function countItems(?string $source = null, ?string $since = null): int
{
$params = [];
$sql = 'SELECT COUNT(*) FROM items' . $this->itemFilter($source, $since, $params);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return (int) $stmt->fetchColumn();
}
/**
* @return array<int, array{url: string, label: string, source: string, last_success_at: ?string, last_error: ?string}>
*/
public function feedsStatus(): array
{
return $this->pdo->query('SELECT url, label, source, last_success_at, last_error FROM feeds ORDER BY label')->fetchAll();
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App;
final class FeedService
{
/**
* @param array<int, array<string, mixed>> $feeds
*/
public function __construct(
private FeedRepository $repository,
private FeedFetcher $fetcher,
private array $feeds,
private int $ttlSeconds,
private int $errorRetryTtlSeconds = 300,
private array $throttledHosts = [],
private int $throttledHostMax = 1,
) {
}
/**
* Refresh every feed whose cached copy is stale. Failures are recorded and
* skipped so previously cached items remain available.
*
* @return array{attempted: int, succeeded: int, failed: int, new_items: int, errors: array<int, string>}
*/
public function refreshStale(): array
{
$this->repository->syncFeeds($this->feeds);
$report = [
'attempted' => 0,
'succeeded' => 0,
'failed' => 0,
'new_items' => 0,
'errors' => [],
];
$hostCounts = [];
foreach ($this->repository->staleFeeds($this->feeds, $this->ttlSeconds, $this->errorRetryTtlSeconds) as $feed) {
$url = (string) $feed['url'];
$host = (string) parse_url($url, PHP_URL_HOST);
if (in_array($host, $this->throttledHosts, true)) {
$seen = $hostCounts[$host] ?? 0;
if ($seen >= $this->throttledHostMax) {
// Leave it stale so a later request picks it up.
continue;
}
$hostCounts[$host] = $seen + 1;
}
$report['attempted']++;
try {
$result = $this->fetcher->fetch($feed);
$report['new_items'] += $this->repository->upsertItems($result['items']);
$this->repository->recordAttempt($url, true);
$report['succeeded']++;
} catch (\Throwable $e) {
$this->repository->recordAttempt($url, false, $e->getMessage());
$report['failed']++;
$report['errors'][] = ($feed['label'] ?? $url) . ': ' . $e->getMessage();
}
}
return $report;
}
/**
* @return array{items: array<int, array<string, mixed>>, page: int, page_size: int, total: int, has_more: bool}
*/
public function page(?string $source, int $page, int $pageSize, ?string $since = null): array
{
$page = max(1, $page);
$pageSize = max(1, $pageSize);
$offset = ($page - 1) * $pageSize;
$items = $this->repository->getItems($pageSize, $offset, $source, $since);
$total = $this->repository->countItems($source, $since);
return [
'items' => $items,
'page' => $page,
'page_size' => $pageSize,
'total' => $total,
'has_more' => ($offset + count($items)) < $total,
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function feedsStatus(): array
{
return $this->repository->feedsStatus();
}
/**
* @return array<int, string>
*/
public function sources(): array
{
$sources = [];
foreach ($this->feeds as $feed) {
$sources[(string) $feed['source']] = true;
}
return array_keys($sources);
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App;
final class View
{
public static function e(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
public static function sourceLabel(string $source): string
{
return match ($source) {
'youtube' => 'YouTube',
'reddit' => 'Reddit',
'rt' => 'RT',
default => ucfirst($source),
};
}
public static function sourceBadge(string $source): string
{
return match ($source) {
'youtube' => 'text-bg-danger',
'reddit' => 'text-bg-warning',
'rt' => 'text-bg-dark',
default => 'text-bg-secondary',
};
}
public static function sourceColor(string $source): string
{
return match ($source) {
'youtube' => '#ff1f4b',
'reddit' => '#ff6a00',
'rt' => '#00c389',
default => '#6a3dff',
};
}
public static function rangeLabel(string $range): string
{
return match ($range) {
'1h' => '1 hour',
'2h' => '2 hours',
'6h' => '6 hours',
'24h' => '24 hours',
'7d' => '7 days',
default => 'All time',
};
}
public static function render(string $template, array $data = []): string
{
$path = dirname(__DIR__) . '/templates/' . $template . '.php';
if (!is_file($path)) {
throw new \RuntimeException(sprintf('Template "%s" not found.', $template));
}
extract($data, EXTR_SKIP);
ob_start();
include $path;
return (string) ob_get_clean();
}
public static function timeAgo(?string $iso): string
{
if ($iso === null || $iso === '') {
return '';
}
$timestamp = strtotime($iso);
if ($timestamp === false) {
return '';
}
$diff = time() - $timestamp;
if ($diff < 60) {
return 'just now';
}
$units = [
31536000 => 'y',
2592000 => 'mo',
604800 => 'w',
86400 => 'd',
3600 => 'h',
60 => 'm',
];
foreach ($units as $seconds => $label) {
if ($diff >= $seconds) {
return floor($diff / $seconds) . $label . ' ago';
}
}
return 'just now';
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
$config = require dirname(__DIR__) . '/config/app.php';
$feeds = require dirname(__DIR__) . '/config/feeds.php';
$pdo = App\Database::connect($config['db_path']);
$repository = new App\FeedRepository($pdo);
$fetcher = new App\FeedFetcher($config);
$service = new App\FeedService(
$repository,
$fetcher,
$feeds,
(int) $config['cache_ttl'],
(int) ($config['error_retry_ttl'] ?? 300),
(array) ($config['throttled_hosts'] ?? []),
(int) ($config['throttled_host_max_per_refresh'] ?? 1)
);
return [
'config' => $config,
'feeds' => $feeds,
'pdo' => $pdo,
'repository' => $repository,
'fetcher' => $fetcher,
'service' => $service,
];
+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>