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
+12 -5
View File
@@ -19,6 +19,9 @@ No framework, no build step. PHP + SQLite for caching, Bootstrap 5 for the UI.
selection** from the browser (`manage.php`, password protected): add
channels by `@handle`, disable or remove feeds — no config editing needed.
- Paginates with a **"Load more stories"** button (AJAX, 20 per page).
- Streams the page shell (header, filters and a skeleton loader) to the browser
**before** the feeds are fetched, so something paints within milliseconds on
every open.
- Caches everything in **SQLite** so page loads never wait on the network; feeds
are refreshed on demand when stale (default TTL 30 min).
@@ -217,13 +220,16 @@ index.php api/items.php
FeedService::page(source, page, pageSize, since) ← read path
└─ FeedRepository::getItems()/countItems() SQL over the SQLite cache
│
└─ View::render('layout' | 'cards') server-rendered HTML
└─ View::render('cards') server-rendered HTML
(api/items.php returns that HTML inside a JSON envelope)
```
**Request lifecycle (page load):** bootstrap → refresh stale feeds → validate
`source`/`range` → query one page → render `templates/layout.php` which includes
`templates/cards.php`.
**Request lifecycle (page load):** bootstrap → sync configured feeds → render
`templates/shell.php` (head, header, filters, skeleton loader) and **flush it**
→ refresh stale feeds → query one page → append the rendered
`templates/content.php` (which includes `templates/cards.php`) and drop the
loader. The shell paints while the refresh is still running; the loader never
outlives the fetch — the content chunk removes it as soon as it arrives.
**Request lifecycle (load more):** `assets/app.js` calls `api/items.php` with the
next `page` (plus current `source` and `range`), receives
@@ -283,7 +289,8 @@ button when `has_more` is false.
│ ├── ManageAuth.php manage.php gate: password, CSRF, flash messages
│ └── View.php Escaping + render() + labels/colors/time formatting
├── templates/
│ ├── layout.php Full page shell, filters, grid, empty state, Load more
│ ├── shell.php Streamed first: head, header, filters, feed loader
│ ├── content.php Streamed after the refresh: grid, empty state, Load more
│ ├── manage.php Feed management page (login, add forms, feed list)
│ └── cards.php One feed card partial (used by page + API)
├── assets/