Stream the page shell first so a loader shows while feeds fetch

Opening the page used to block on refreshStale() before a single byte went
out, so the browser sat on a blank tab for the whole fetch. index.php now
renders templates/shell.php (head, header, filters, skeleton loader) and
flushes it, then refreshes and appends templates/content.php, which removes
the loader as the feed HTML arrives.

- templates/layout.php is split into shell.php + content.php
- refresh chip moved to a right-aligned row above the feed: the toolbar
  paints before the refresh report exists
- header story count shows the cached total, then corrects to the real one
- skeleton styles match the card grid and keep the sources' chroma; a
  noscript style hides the loader for clients without JS
- README: file map and page-load lifecycle updated
This commit is contained in:
Harwinder Sekhon
2026-10-08 15:28:51 -04:00
parent 20f4fd7095
commit 3c2d90fb7e
5 changed files with 216 additions and 74 deletions
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
use App\View;
/** @var string|null $activeSource */
/** @var string|null $activeRange */
/** @var array<string, mixed> $page */
/** @var array<string, mixed> $refresh */
$activeRange = $activeRange ?? '';
$errors = $refresh['errors'] ?? [];
$total = (int) $page['total'];
?>
<?php if (($refresh['attempted'] ?? 0) > 0): ?>
<div class="d-flex justify-content-end mb-3">
<span class="refresh-chip mono">
<span aria-hidden="true">↻</span>
<?= (int) $refresh['succeeded'] ?>/<?= (int) $refresh['attempted'] ?> feeds
· <?= (int) $refresh['new_items'] ?> new
</span>
</div>
<?php endif; ?>
<?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-tally <?= $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>
<script>
(function () {
var loader = document.getElementById('feed-loader');
if (loader !== null) {
loader.remove();
}
var count = document.getElementById('story-count');
if (count !== null) {
count.textContent = '<?= $total ?> stories';
}
})();
</script>
</body>
</html>
+25 -63
View File
@@ -8,8 +8,7 @@ use App\View;
/** @var array<int, string> $sources */
/** @var string|null $activeSource */
/** @var string|null $activeRange */
/** @var array<string, mixed> $page */
/** @var array<string, mixed> $refresh */
/** @var int $cachedTotal */
$tabs = ['' => 'All'];
foreach ($sources as $source) {
@@ -34,9 +33,6 @@ $linkFor = static function (string $source, string $range): string {
return $params === [] ? '?' : '?' . http_build_query($params);
};
$errors = $refresh['errors'] ?? [];
$total = (int) $page['total'];
?>
<!doctype html>
<html lang="en" data-bs-theme="dark">
@@ -44,6 +40,7 @@ $total = (int) $page['total'];
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RSS Feed Dashboard</title>
<noscript><style>#feed-loader{display:none}</style></noscript>
<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"
@@ -62,7 +59,7 @@ $total = (int) $page['total'];
<div class="d-flex align-items-center gap-3">
<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="mono" id="story-count"><?= $cachedTotal ?> stories</span>
<span class="sep">/</span>
<span class="mono">YouTube · Reddit · RT</span>
</div>
@@ -90,14 +87,6 @@ $total = (int) $page['total'];
</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">
@@ -113,53 +102,26 @@ $total = (int) $page['total'];
</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 id="feed-loader" class="row g-4" role="status">
<span class="visually-hidden">Loading feeds…</span>
<?php for ($placeholder = 0; $placeholder < 3; $placeholder++): ?>
<div class="col-12 col-md-6 col-xl-4">
<div class="card h-100 feed-card skeleton-card" aria-hidden="true">
<div class="skeleton-thumb"></div>
<div class="card-body d-flex flex-column">
<div class="d-flex align-items-center gap-2 mb-2">
<span class="skeleton-chip"></span>
<span class="skeleton-line w-50 mb-0"></span>
</div>
<span class="skeleton-line skeleton-title w-100"></span>
<span class="skeleton-line w-100"></span>
<span class="skeleton-line w-75"></span>
<div class="card-footer-meta mt-auto d-flex justify-content-between align-items-center pt-3">
<span class="skeleton-line w-25 mb-0"></span>
<span class="skeleton-line skeleton-timestamp"></span>
</div>
</div>
</div>
</div>
<?php endfor; ?>
</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-tally <?= $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>