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
+32 -6
View File
@@ -6,13 +6,11 @@ declare(strict_types=1);
$app = require __DIR__ . '/src/bootstrap.php';
$config = $app['config'];
$repository = $app['repository'];
$service = $app['service'];
$refresh = $service->refreshStale();
$sources = $service->sources();
$activeSource = $_GET['source'] ?? null;
if (!is_string($activeSource) || !in_array($activeSource, $sources, true)) {
if (!is_string($activeSource) || $activeSource === '') {
$activeSource = null;
}
@@ -21,13 +19,41 @@ $activeRange = $_GET['range'] ?? '';
if (!is_string($activeRange) || !array_key_exists($activeRange, $ranges)) {
$activeRange = '';
}
$since = $activeRange !== '' ? gmdate('c', time() - (int) $ranges[$activeRange]) : null;
$pageSize = (int) $config['page_size'];
$page = $service->page($activeSource, 1, (int) $config['page_size'], $since);
// Register the configured feeds before anything renders so the filter tabs are
// complete on a fresh database; refreshStale() repeats the same sync cheaply.
$repository->syncFeeds($app['feeds']);
echo App\View::render('layout', [
$sources = $service->sources();
if ($activeSource !== null && !in_array($activeSource, $sources, true)) {
$activeSource = null;
}
// Send the shell (header, filters and the feed loader) before refreshing, so
// the loader is on screen while the feeds are being fetched. Nothing may hold
// those bytes back.
ini_set('zlib.output_compression', '0');
while (ob_get_level() > 0 && ob_end_flush()) {
// Drop any active output buffers so the shell reaches the browser now.
}
echo App\View::render('shell', [
'config' => $config,
'sources' => $sources,
'activeSource' => $activeSource,
'activeRange' => $activeRange,
'cachedTotal' => $repository->countItems($activeSource, $since),
]);
flush();
$refresh = $service->refreshStale();
$page = $service->page($activeSource, 1, $pageSize, $since);
echo App\View::render('content', [
'activeSource' => $activeSource,
'activeRange' => $activeRange,
'page' => $page,