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:
@@ -0,0 +1,7 @@
|
|||||||
|
/vendor/
|
||||||
|
/data/
|
||||||
|
/composer.phar
|
||||||
|
composer-setup.php
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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);
|
||||||
|
})();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Require all denied
|
||||||
@@ -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)',
|
||||||
|
];
|
||||||
@@ -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/',
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Require all denied
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Require all denied
|
||||||
@@ -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; ?>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user