diff --git a/README.md b/README.md index 167bb4b..5d670b0 100644 --- a/README.md +++ b/README.md @@ -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/ diff --git a/assets/style.css b/assets/style.css index b04627f..d16f138 100644 --- a/assets/style.css +++ b/assets/style.css @@ -399,6 +399,65 @@ main { font-family: var(--mono); } +/* ---------- Feed loader ---------- */ + +/* Skeleton cards hold the feed's place while the first page is fetched. The + spine stays neutral: no source colour is known yet, so the sources keep + their chroma to themselves. */ +.skeleton-card { + --src: var(--line-2); + pointer-events: none; +} + +.skeleton-thumb, +.skeleton-chip, +.skeleton-line { + display: block; + background: linear-gradient(90deg, var(--surface-2) 25%, var(--line-2) 37%, var(--surface-2) 63%); + background-size: 400% 100%; + border-radius: 6px; + animation: skeleton-sweep 1.5s ease-in-out infinite; +} + +.skeleton-thumb { + width: 100%; + aspect-ratio: 16 / 9; + border-radius: 0; +} + +.skeleton-chip { + width: 46px; + height: 18px; + border-radius: 999px; + flex-shrink: 0; +} + +.skeleton-line { + height: 0.62rem; + margin-bottom: 0.5rem; +} + +.skeleton-title { + height: 1.02rem; + margin-bottom: 0.75rem; +} + +.skeleton-timestamp { + width: 3.25rem; + height: 0.72rem; + margin-bottom: 0; +} + +@keyframes skeleton-sweep { + from { + background-position: 100% 50%; + } + + to { + background-position: 0 50%; + } +} + /* ---------- Empty state ---------- */ .empty-state { diff --git a/index.php b/index.php index 1f79a91..37c8d93 100644 --- a/index.php +++ b/index.php @@ -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, diff --git a/templates/content.php b/templates/content.php new file mode 100644 index 0000000..4ee3b97 --- /dev/null +++ b/templates/content.php @@ -0,0 +1,88 @@ + $page */ +/** @var array $refresh */ + +$activeRange = $activeRange ?? ''; +$errors = $refresh['errors'] ?? []; +$total = (int) $page['total']; +?> + 0): ?> +
+ + + / feeds + · new + +
+ + + + + + +
+ $page['items']]) ?> +
+ +
+ + +

Nothing in the past

+

Try a wider time range to see more.

+ +

Nothing in the feed yet

+

Feeds may be rate limited right now — reload in a moment.

+ +
+ +
+ +
+ + + + + + + diff --git a/templates/layout.php b/templates/shell.php similarity index 58% rename from templates/layout.php rename to templates/shell.php index a9c8eb6..b1fba85 100644 --- a/templates/layout.php +++ b/templates/shell.php @@ -8,8 +8,7 @@ use App\View; /** @var array $sources */ /** @var string|null $activeSource */ /** @var string|null $activeRange */ -/** @var array $page */ -/** @var array $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']; ?> @@ -44,6 +40,7 @@ $total = (int) $page['total']; RSS Feed Dashboard +
- stories + stories / YouTube · Reddit · RT
@@ -90,14 +87,6 @@ $total = (int) $page['total']; - - 0): ?> - - - / feeds - · new - -
@@ -113,53 +102,26 @@ $total = (int) $page['total'];
- - - - -
- $page['items']]) ?> +
+ Loading feeds… + +
+ +
+
- -
- - -

Nothing in the past

-

Try a wider time range to see more.

- -

Nothing in the feed yet

-

Feeds may be rate limited right now — reload in a moment.

- -
- -
- -
- - - - - -