Files
homefeed/frontend/src/routes/+layout.svelte
T
Claude d0bf6d18bc Auto-refresh sidebar data so a stale tab picks up backend polls
Verified the PoE2 24h-change computation itself is correct (simulated
24+ hours of real hourly polling against the actual recordRate/
rateAtOrBefore/markPolled functions — change24h starts populating
right on schedule at hour 24). The real problem: weather/stocks/poe2/
bookmarks are only fetched once when the layout first loads.
SvelteKit never re-runs a load() on its own — only on navigation or
explicit invalidation — so a browser tab left open never sees any of
those widgets update no matter how long the backend has been polling.

Named the layout's load() dependency ('app:sidebar') and invalidate
just that on a 5-minute timer, rather than invalidateAll() — which
would also re-run page-level loads and could reset things like the
home feed's own pagination state on every refresh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-26 00:00:11 +00:00

231 lines
7.6 KiB
Svelte

<script lang="ts">
import '../lib/styles/app.css';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { invalidateAll, invalidate } from '$app/navigation';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import PrivateAccessModal from '$lib/components/PrivateAccessModal.svelte';
import Sidebar from '$lib/components/sidebar/Sidebar.svelte';
import { logoutPrivateAccess } from '$lib/privateAccess';
import { slugify } from '$lib/format';
import type { LayoutData } from './$types';
let { children, data }: { children: any; data: LayoutData } = $props();
let showLoginModal = $state(false);
// Weather/Stocks/PoE2/Bookmarks are all backend-polled on their own cadence (15m-1h) —
// without this, a tab left open never sees those updates, since SvelteKit only re-runs
// a load() on navigation or explicit invalidation, never on a timer by itself. Scoped
// to this layout's own 'app:sidebar' dependency (see +layout.ts) rather than
// invalidateAll(), so it doesn't also re-run page-level loads — e.g. the home feed's
// own pagination state would otherwise reset every refresh.
onMount(() => {
const interval = setInterval(() => invalidate('app:sidebar'), 5 * 60_000);
return () => clearInterval(interval);
});
// Admin pages already use full page width for their own tab UI — the sidebar's utility
// widgets don't belong there, unlike every reader-facing route (home, category,
// article, event, more, weather).
const showSidebar = $derived(!$page.url.pathname.startsWith('/admin'));
async function handleLockClick() {
if (data.privateAccess.authenticated) {
await logoutPrivateAccess();
await invalidateAll();
} else {
showLoginModal = true;
}
}
async function handleLoginSuccess() {
showLoginModal = false;
await invalidateAll();
}
// "Top stories" is a real Category row (it drives synthesis queue priority) but
// isn't itself a filterable category — it always means "everything, chronological",
// i.e. the homepage. Every other admin-defined category gets its own /category/:slug
// page, unless it's flagged "spillover" (see MergeTab.svelte's category priority
// list) — those collapse into a single trailing "More »" tab instead, so the nav
// doesn't get too wide or wrap once there are more than a handful of categories.
//
// A tracked event is a displayed category too, just backed by a source+keyword
// filter instead of manual per-source category checkboxes, and periodically
// AI-recapped — see EventsTab.svelte. Active ones get their own /event/:id tab,
// appended after the regular categories.
const primaryCategories = $derived(data.categories.filter((c) => !c.isSpillover));
const spilloverCategories = $derived(data.categories.filter((c) => c.isSpillover));
const navItems = $derived([
...primaryCategories.map((cat) => ({
label: cat.name,
href: cat.name.toLowerCase() === 'top stories' ? '/' : `/category/${slugify(cat.name)}`
})),
...data.events.map((event) => ({ label: event.name, href: `/event/${event.id}` })),
...(spilloverCategories.length > 0 ? [{ label: 'More »', href: '/more' }] : [])
]);
function isActive(href: string): boolean {
if (href === '/') return $page.url.pathname === '/';
return $page.url.pathname.startsWith(href);
}
</script>
<header class="masthead">
<div class="page masthead-inner">
<div class="brand">
<span class="brand-name">Homefeed</span>
<span class="brand-tag">self-hosted</span>
</div>
<nav class="tabs">
{#each navItems as item}
<a class="tab" class:active={isActive(item.href)} href={item.href}>{item.label}</a>
{/each}
</nav>
<div class="controls">
<ThemeToggle />
{#if data.privateAccess?.configured}
<button
class="lock-btn"
onclick={handleLockClick}
aria-label={data.privateAccess.authenticated ? 'Log out of private categories' : 'Log in to private categories'}
title={data.privateAccess.authenticated ? 'Log out of private categories' : 'Log in to private categories'}
>
{#if data.privateAccess.authenticated}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V7a4 4 0 0 1 7.75-1.5" />
</svg>
{:else}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
{/if}
</button>
{/if}
{#if data.adminPanelEnabled}
<a class="cog" href="/admin/settings" aria-label="Admin settings" title="Admin settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
</a>
{/if}
</div>
</div>
</header>
{#if showLoginModal}
<PrivateAccessModal onClose={() => (showLoginModal = false)} onSuccess={handleLoginSuccess} />
{/if}
<main class="page" class:with-sidebar={showSidebar}>
<div class="main-col">
{@render children()}
</div>
{#if showSidebar}
<Sidebar weather={data.weather} stocks={data.stocks} bookmarks={data.bookmarks} poe2={data.poe2} />
{/if}
</main>
<style>
.masthead {
border-bottom: 2px solid var(--text-primary);
}
.masthead-inner {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding-top: 20px;
padding-bottom: 12px;
}
.brand {
display: flex;
align-items: baseline;
gap: 10px;
}
.brand-name {
font-family: var(--font-voice);
font-size: 28px;
font-weight: 500;
letter-spacing: -0.5px;
}
.brand-tag {
font-size: 11px;
color: var(--text-muted);
border: 0.5px solid var(--border);
padding: 2px 6px;
border-radius: var(--radius);
}
.tabs {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.tab {
font-size: 13px;
color: var(--text-secondary);
padding: 6px 12px;
border-radius: var(--radius);
}
.tab:hover {
text-decoration: none;
background: var(--surface-1);
}
.tab.active {
background: var(--pill-bg);
color: var(--pill-text);
}
.controls {
display: flex;
align-items: center;
gap: 12px;
}
.lock-btn {
display: flex;
align-items: center;
color: var(--text-secondary);
padding: 4px;
border-radius: var(--radius);
background: transparent;
border: none;
}
.lock-btn:hover {
background: var(--surface-1);
color: var(--text-primary);
}
.cog {
display: flex;
align-items: center;
color: var(--text-secondary);
padding: 4px;
border-radius: var(--radius);
}
.cog:hover {
text-decoration: none;
background: var(--surface-1);
color: var(--text-primary);
}
.page.with-sidebar {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 32px;
align-items: start;
}
.main-col {
min-width: 0; /* keeps wide content, e.g. tweet media grids, from forcing the track wider */
}
@media (max-width: 900px) {
.page.with-sidebar {
grid-template-columns: 1fr;
}
}
</style>