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,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