Files
homefeed/frontend/src/lib/components/sidebar/Sidebar.svelte
T
Claude 55622d0a1a Add 1/2/3-column layout option to the Bookmarks widget
The bookmarks list can now render as 1 (default), 2, or 3 columns
instead of always being a single-column list, admin-configurable via
a pill picker on the Widgets tab. Column count lives in widget_kv
(same idiom as weather's config) and rides along in the public
GET /api/widget/bookmarks response so the sidebar picks it up without
a separate request.
2026-08-01 23:55:18 +00:00

132 lines
4.4 KiB
Svelte

<script lang="ts">
import { tick } from 'svelte';
import type { Weather, StockTicker, BookmarksFeed, Poe2Data, WidgetsEnabled } from '$lib/types';
import WeatherWidget from './WeatherWidget.svelte';
import StocksWidget from './StocksWidget.svelte';
import BookmarksWidget from './BookmarksWidget.svelte';
import Poe2Widget from './Poe2Widget.svelte';
import GenericWidgetCard from './GenericWidgetCard.svelte';
import DynamicWidgetSlot from './DynamicWidgetSlot.svelte';
let {
weather,
stocks,
bookmarks,
poe2,
widgetsEnabled
}: {
weather: Weather;
stocks: StockTicker[];
bookmarks: BookmarksFeed;
poe2: Poe2Data;
widgetsEnabled: WidgetsEnabled;
} = $props();
// Weather + Stocks + PoE2 + Bookmarks stacked can be taller than the viewport. Plain
// `position: sticky` alone can only pin a box at a constant offset — it can't reveal
// content taller than that box without either an internal scrollbar (a second,
// separate scroll region — janky) or shifting the content within the box as the page
// scrolls. This does the latter: `.sidebar-track` is a plain (non-positioned) spacer
// sized to the widgets' full natural height, so the page reserves exactly enough
// scroll room; `.sidebar-viewport` is the sticky, viewport-capped, clipped box; and
// `.sidebar-content` is translated upward inside it in lockstep with how far the page
// has scrolled past the point where the sidebar started sticking — one continuous
// scroll gesture over the whole page drives it, not a separate widget-local scrollbar.
const TOP_MARGIN = 20;
const BOTTOM_MARGIN = 20;
let trackEl: HTMLElement | undefined = $state();
let contentEl: HTMLElement | undefined = $state();
let trackHeight = $state(0);
let viewportHeight = $state(0);
let progress = $state(0);
function update() {
if (!trackEl || !contentEl) return;
const naturalHeight = contentEl.offsetHeight;
const cappedHeight = Math.min(naturalHeight, window.innerHeight - TOP_MARGIN - BOTTOM_MARGIN);
const revealRange = naturalHeight - cappedHeight;
trackHeight = naturalHeight;
viewportHeight = cappedHeight;
if (revealRange <= 0) {
progress = 0;
return;
}
// trackEl is never itself positioned/sticky, so its rect always reflects genuine
// scroll progress — how far its top has moved past TOP_MARGIN is exactly how much
// extra scrolling has happened since the sidebar started sticking.
const extraScroll = TOP_MARGIN - trackEl.getBoundingClientRect().top;
progress = Math.max(0, Math.min(revealRange, extraScroll));
}
async function remeasure() {
await tick();
update();
}
$effect(() => {
// Widget data changing the sidebar's natural height needs a remeasure, not just a
// scroll-position update. widgetsEnabled changes it too — a disabled widget is
// removed from the flow entirely, not just emptied.
void weather;
void stocks;
void bookmarks;
void poe2;
void widgetsEnabled;
remeasure();
});
$effect(() => {
window.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', remeasure);
return () => {
window.removeEventListener('scroll', update);
window.removeEventListener('resize', remeasure);
};
});
</script>
<div class="sidebar-track" bind:this={trackEl} style:height="{trackHeight}px">
<aside class="sidebar-viewport" style:height="{viewportHeight}px">
<div class="sidebar-content" bind:this={contentEl} style:transform="translateY(-{progress}px)">
{#each widgetsEnabled.order as key (key)}
{#if key === 'weather' && widgetsEnabled.weather}
<WeatherWidget {weather} />
{:else if key === 'stocks' && widgetsEnabled.stocks}
<StocksWidget {stocks} />
{:else if key === 'poe2' && widgetsEnabled.poe2}
<Poe2Widget {poe2} />
{:else if key === 'bookmarks' && widgetsEnabled.bookmarks}
<BookmarksWidget bookmarks={bookmarks.items} columns={bookmarks.columns} />
{/if}
{/each}
{#each widgetsEnabled.pluggable as w (w.id)}
{#if w.frontendEntry}
<DynamicWidgetSlot id={w.id} displayName={w.displayName} frontendEntry={w.frontendEntry} />
{:else}
<GenericWidgetCard id={w.id} displayName={w.displayName} />
{/if}
{/each}
</div>
</aside>
</div>
<style>
.sidebar-track {
position: relative;
}
.sidebar-viewport {
position: sticky;
top: 20px;
overflow: hidden;
}
.sidebar-content {
display: flex;
flex-direction: column;
gap: 16px;
}
</style>