Consolidate Weather/Stocks/Bookmarks/PoE2 into a single admin "Widgets" tab

Each widget now has an independent enable/disable checkbox that gates its
visibility in the sidebar (new global_settings.widgets columns + GET /api/widgets),
and is collapsed by default behind a WidgetSection wrapper so the tab stays
manageable as more widgets get added. The four existing tab components
(WeatherTab/StocksTab/BookmarksTab/Poe2Tab) are reused unmodified as each
section's expanded content.
This commit is contained in:
Claude
2026-07-26 17:09:26 +00:00
parent 24bf5afb07
commit e6ac6cd061
13 changed files with 251 additions and 30 deletions
+5
View File
@@ -60,6 +60,11 @@ export async function registerPublicRoutes(app: FastifyInstance) {
return categories.filter((c) => !c.isPrivate);
});
// Per-widget sidebar visibility — see the admin panel's consolidated "Widgets" tab.
// Each widget keeps polling/config regardless of this; it only gates whether the
// sidebar renders it at all.
app.get('/api/widgets', async () => settingsDb.getSettings().widgets);
// Sidebar widgets — see WeatherTab/StocksTab/BookmarksTab in the admin panel.
app.get('/api/weather', async () => settingsDb.getSettings().weather);
+10
View File
@@ -185,6 +185,10 @@ export function migrate() {
nitter_media_mode TEXT NOT NULL DEFAULT 'proxy', -- self-host | proxy | direct
fxtwitter_base_url TEXT NOT NULL DEFAULT 'https://api.fxtwitter.com',
telegram_media_mode TEXT NOT NULL DEFAULT 'self-host', -- self-host | proxy (no "direct" — Telegram has no public hotlinkable media URL)
widget_weather_enabled INTEGER NOT NULL DEFAULT 1,
widget_stocks_enabled INTEGER NOT NULL DEFAULT 1,
widget_bookmarks_enabled INTEGER NOT NULL DEFAULT 1,
widget_poe2_enabled INTEGER NOT NULL DEFAULT 1,
weather_location_name TEXT,
weather_latitude REAL,
weather_longitude REAL,
@@ -348,6 +352,12 @@ export function migrate() {
db.exec('ALTER TABLE global_settings ADD COLUMN poe2_league_name TEXT');
db.exec('ALTER TABLE global_settings ADD COLUMN poe2_updated_at TEXT');
}
if (!hasColumn('global_settings', 'widget_weather_enabled')) {
db.exec('ALTER TABLE global_settings ADD COLUMN widget_weather_enabled INTEGER NOT NULL DEFAULT 1');
db.exec('ALTER TABLE global_settings ADD COLUMN widget_stocks_enabled INTEGER NOT NULL DEFAULT 1');
db.exec('ALTER TABLE global_settings ADD COLUMN widget_bookmarks_enabled INTEGER NOT NULL DEFAULT 1');
db.exec('ALTER TABLE global_settings ADD COLUMN widget_poe2_enabled INTEGER NOT NULL DEFAULT 1');
}
// Seed a handful of sensible default tickers so the Stocks widget isn't empty on a
// fresh install — the admin can remove/replace any of them via the Stocks tab.
+14 -1
View File
@@ -16,6 +16,12 @@ function rowToSettings(row: any): GlobalSettings {
nitterMediaMode: row.nitter_media_mode,
fxtwitterBaseUrl: row.fxtwitter_base_url,
telegramMediaMode: row.telegram_media_mode,
widgets: {
weather: !!row.widget_weather_enabled,
stocks: !!row.widget_stocks_enabled,
bookmarks: !!row.widget_bookmarks_enabled,
poe2: !!row.widget_poe2_enabled
},
retention: {
publishedArticleMaxAgeDays: row.published_article_max_age_days,
rawItemMaxAgeDays: row.raw_item_max_age_days,
@@ -58,7 +64,8 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
retention: { ...current.retention, ...(patch.retention ?? {}) },
selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) },
weather: { ...current.weather, ...(patch.weather ?? {}) },
poe2: { ...current.poe2, ...(patch.poe2 ?? {}) }
poe2: { ...current.poe2, ...(patch.poe2 ?? {}) },
widgets: { ...current.widgets, ...(patch.widgets ?? {}) }
};
// Named params (rather than positional `?`) so this list can be reordered or
// extended without the column list and the bound-values list silently drifting
@@ -71,6 +78,8 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
follow_up_min_hours_since_last=$follow_up_min_hours_since_last, follow_up_min_new_sources=$follow_up_min_new_sources,
ai_service_host=$ai_service_host, ai_service_port=$ai_service_port, selected_models=$selected_models,
nitter_media_mode=$nitter_media_mode, fxtwitter_base_url=$fxtwitter_base_url, telegram_media_mode=$telegram_media_mode,
widget_weather_enabled=$widget_weather_enabled, widget_stocks_enabled=$widget_stocks_enabled,
widget_bookmarks_enabled=$widget_bookmarks_enabled, widget_poe2_enabled=$widget_poe2_enabled,
published_article_max_age_days=$published_article_max_age_days, raw_item_max_age_days=$raw_item_max_age_days,
storage_cap_enabled=$storage_cap_enabled, storage_cap_value=$storage_cap_value, storage_cap_unit=$storage_cap_unit,
weather_location_name=$weather_location_name, weather_latitude=$weather_latitude, weather_longitude=$weather_longitude,
@@ -93,6 +102,10 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
$nitter_media_mode: merged.nitterMediaMode,
$fxtwitter_base_url: merged.fxtwitterBaseUrl,
$telegram_media_mode: merged.telegramMediaMode,
$widget_weather_enabled: merged.widgets.weather ? 1 : 0,
$widget_stocks_enabled: merged.widgets.stocks ? 1 : 0,
$widget_bookmarks_enabled: merged.widgets.bookmarks ? 1 : 0,
$widget_poe2_enabled: merged.widgets.poe2 ? 1 : 0,
$published_article_max_age_days: merged.retention.publishedArticleMaxAgeDays,
$raw_item_max_age_days: merged.retention.rawItemMaxAgeDays,
$storage_cap_enabled: merged.retention.storageCapEnabled ? 1 : 0,
+7
View File
@@ -280,6 +280,13 @@ export interface GlobalSettings {
fxtwitterBaseUrl: string;
/** How Telegram message media (attached photos/videos, channel avatars) is served — see pipeline/publish.ts's resolveTelegramMedia. No "direct" option: Telegram has no public hotlinkable media URL, bytes only come from the authenticated MTProto session. */
telegramMediaMode: 'self-host' | 'proxy';
/** Per-widget sidebar visibility — see admin/settings' consolidated "Widgets" tab. Each widget keeps polling/config regardless (disabling doesn't pause its poller), this only gates whether GET /api/widgets tells the sidebar to render it. */
widgets: {
weather: boolean;
stocks: boolean;
bookmarks: boolean;
poe2: boolean;
};
retention: {
publishedArticleMaxAgeDays: number | null;
rawItemMaxAgeDays: number | null;
+8
View File
@@ -118,6 +118,13 @@ export interface AdminPoe2Settings {
updatedAt: string | null;
}
export interface AdminWidgetsEnabled {
weather: boolean;
stocks: boolean;
bookmarks: boolean;
poe2: boolean;
}
export interface AdminSettings {
mergeStrictness: 1 | 2 | 3 | 4 | 5;
defaultPollIntervalMinutes: number;
@@ -132,6 +139,7 @@ export interface AdminSettings {
nitterMediaMode: 'self-host' | 'proxy' | 'direct';
fxtwitterBaseUrl: string;
telegramMediaMode: 'self-host' | 'proxy';
widgets: AdminWidgetsEnabled;
retention: RetentionSettings;
categoryPriority: CategoryPriority[];
weather: AdminWeatherSettings;
+5 -1
View File
@@ -1,5 +1,5 @@
import { getBackendUrl } from './config';
import type { MergedArticle, Tag, TrackedEventPublic, Category, Weather, StockTicker, Bookmark, Poe2Data } from './types';
import type { MergedArticle, Tag, TrackedEventPublic, Category, Weather, StockTicker, Bookmark, Poe2Data, WidgetsEnabled } from './types';
async function get<T>(path: string, fetchFn: typeof fetch = fetch): Promise<T> {
// credentials: 'include' so the private-access cookie (see lib/privateAccess.ts)
@@ -57,3 +57,7 @@ export function getBookmarks(fetchFn?: typeof fetch): Promise<Bookmark[]> {
export function getPoe2(fetchFn?: typeof fetch): Promise<Poe2Data> {
return get<Poe2Data>('/api/poe2', fetchFn);
}
export function getWidgetsEnabled(fetchFn?: typeof fetch): Promise<WidgetsEnabled> {
return get<WidgetsEnabled>('/api/widgets', fetchFn);
}
@@ -0,0 +1,103 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// Minimized by default — Weather/Stocks/Bookmarks/PoE2 stacked at full height would
// make the consolidated "Widgets" tab unwieldy as more get added over time. Enabled
// state is independent of expanded state: disabling a widget only hides it from the
// sidebar (see Sidebar.svelte's widgetsEnabled gate), it doesn't stop the admin from
// expanding this section to keep configuring it.
let {
title,
enabled,
onToggle,
children
}: {
title: string;
enabled: boolean;
onToggle: () => void;
children: Snippet;
} = $props();
let expanded = $state(false);
</script>
<div class="section">
<div class="section-head">
<button class="head-btn" onclick={() => (expanded = !expanded)} aria-expanded={expanded}>
<span class="chevron" class:open={expanded}>▸</span>
<span class="title">{title}</span>
{#if !enabled}<span class="disabled-tag">Hidden</span>{/if}
</button>
<label class="enable-toggle" title={enabled ? 'Hide from sidebar' : 'Show in sidebar'}>
<input type="checkbox" checked={enabled} onchange={onToggle} />
</label>
</div>
{#if expanded}
<div class="section-body">
{@render children()}
</div>
{/if}
</div>
<style>
.section {
background: var(--surface-1);
border-radius: 12px;
margin-bottom: 12px;
overflow: hidden;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
}
.head-btn {
display: flex;
align-items: center;
gap: 8px;
background: transparent;
border: none;
padding: 0;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
flex: 1;
min-width: 0;
text-align: left;
}
.chevron {
display: inline-block;
font-size: 10px;
color: var(--text-muted);
transition: transform 0.15s ease;
}
.chevron.open {
transform: rotate(90deg);
}
.disabled-tag {
font-size: 10px;
font-weight: 400;
color: var(--text-muted);
border: 0.5px solid var(--border);
padding: 1px 6px;
border-radius: var(--radius);
}
.enable-toggle {
display: flex;
align-items: center;
}
.enable-toggle input {
width: auto;
}
.section-body {
padding: 14px;
padding-top: 0;
border-top: 0.5px solid var(--border);
margin-top: 0;
}
.section-body > :global(*:first-child) {
margin-top: 14px;
}
</style>
@@ -0,0 +1,59 @@
<script lang="ts">
import type { AdminSettings, AdminStockTicker, AdminBookmark, AdminPoe2Entry } from '$lib/adminTypes';
import { updateSettings } from '$lib/adminApi';
import WidgetSection from './WidgetSection.svelte';
import WeatherTab from './WeatherTab.svelte';
import StocksTab from './StocksTab.svelte';
import BookmarksTab from './BookmarksTab.svelte';
import Poe2Tab from './Poe2Tab.svelte';
let {
settings,
stockTickers,
bookmarks,
poe2Watchlist
}: {
settings: AdminSettings;
stockTickers: AdminStockTicker[];
bookmarks: AdminBookmark[];
poe2Watchlist: AdminPoe2Entry[];
} = $props();
// Local copy so each checkbox flips immediately — same idiom as BookmarksTab's
// per-row "Private" toggle, just for widget visibility instead.
let widgets = $state({ ...settings.widgets });
async function toggle(key: keyof typeof widgets) {
widgets[key] = !widgets[key];
await updateSettings({ widgets });
}
</script>
<p class="hint">
Each widget can be shown or hidden from the sidebar independently. Hiding one only affects
whether it's visible on the site — its own settings and data below keep working either way.
</p>
<WidgetSection title="Weather" enabled={widgets.weather} onToggle={() => toggle('weather')}>
<WeatherTab {settings} />
</WidgetSection>
<WidgetSection title="Stocks" enabled={widgets.stocks} onToggle={() => toggle('stocks')}>
<StocksTab tickers={stockTickers} />
</WidgetSection>
<WidgetSection title="Bookmarks" enabled={widgets.bookmarks} onToggle={() => toggle('bookmarks')}>
<BookmarksTab {bookmarks} />
</WidgetSection>
<WidgetSection title="PoE2" enabled={widgets.poe2} onToggle={() => toggle('poe2')}>
<Poe2Tab {settings} watchlist={poe2Watchlist} />
</WidgetSection>
<style>
.hint {
font-size: 12px;
color: var(--text-secondary);
margin: 0 0 14px;
}
</style>
@@ -1,12 +1,24 @@
<script lang="ts">
import { tick } from 'svelte';
import type { Weather, StockTicker, Bookmark, Poe2Data } from '$lib/types';
import type { Weather, StockTicker, Bookmark, 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';
let { weather, stocks, bookmarks, poe2 }: { weather: Weather; stocks: StockTicker[]; bookmarks: Bookmark[]; poe2: Poe2Data } = $props();
let {
weather,
stocks,
bookmarks,
poe2,
widgetsEnabled
}: {
weather: Weather;
stocks: StockTicker[];
bookmarks: Bookmark[];
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
@@ -55,11 +67,13 @@
$effect(() => {
// Widget data changing the sidebar's natural height needs a remeasure, not just a
// scroll-position update.
// 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();
});
@@ -76,10 +90,10 @@
<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)">
<WeatherWidget {weather} />
<StocksWidget {stocks} />
<Poe2Widget {poe2} />
<BookmarksWidget {bookmarks} />
{#if widgetsEnabled.weather}<WeatherWidget {weather} />{/if}
{#if widgetsEnabled.stocks}<StocksWidget {stocks} />{/if}
{#if widgetsEnabled.poe2}<Poe2Widget {poe2} />{/if}
{#if widgetsEnabled.bookmarks}<BookmarksWidget {bookmarks} />{/if}
</div>
</aside>
</div>
+8
View File
@@ -171,3 +171,11 @@ export interface Poe2Data {
updatedAt: string | null;
entries: Poe2WatchlistEntry[];
}
/** Per-widget sidebar visibility, admin-toggled from the consolidated "Widgets" tab. */
export interface WidgetsEnabled {
weather: boolean;
stocks: boolean;
bookmarks: boolean;
poe2: boolean;
}
+1 -1
View File
@@ -129,7 +129,7 @@
{@render children()}
</div>
{#if showSidebar}
<Sidebar weather={data.weather} stocks={data.stocks} bookmarks={data.bookmarks} poe2={data.poe2} />
<Sidebar weather={data.weather} stocks={data.stocks} bookmarks={data.bookmarks} poe2={data.poe2} widgetsEnabled={data.widgetsEnabled} />
{/if}
</main>
+6 -4
View File
@@ -1,5 +1,5 @@
import type { LayoutLoad } from './$types';
import { getCategories, getEvents, getWeather, getStocks, getBookmarks, getPoe2 } from '$lib/api';
import { getCategories, getEvents, getWeather, getStocks, getBookmarks, getPoe2, getWidgetsEnabled } from '$lib/api';
import { getPrivateAccessStatus } from '$lib/privateAccess';
// Named so the layout can be re-fetched on its own (see +layout.svelte's periodic
@@ -7,14 +7,15 @@ import { getPrivateAccessStatus } from '$lib/privateAccess';
// own pagination state, which a blanket invalidateAll() would reset every refresh.
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
depends('app:sidebar');
const [categories, events, privateAccess, weather, stocks, bookmarks, poe2] = await Promise.all([
const [categories, events, privateAccess, weather, stocks, bookmarks, poe2, widgetsEnabled] = await Promise.all([
getCategories(fetch),
getEvents(fetch),
getPrivateAccessStatus(fetch),
getWeather(fetch),
getStocks(fetch),
getBookmarks(fetch),
getPoe2(fetch)
getPoe2(fetch),
getWidgetsEnabled(fetch)
]);
// Tracked events are a displayed category like any other (see MergeTab/EventsTab) —
// only active ones show up as browsable, same as a paused/disabled category wouldn't.
@@ -26,6 +27,7 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
weather,
stocks,
bookmarks,
poe2
poe2,
widgetsEnabled
};
};
@@ -5,10 +5,7 @@
import ModelsTab from '$lib/components/admin/ModelsTab.svelte';
import RetentionTab from '$lib/components/admin/RetentionTab.svelte';
import EventsTab from '$lib/components/admin/EventsTab.svelte';
import WeatherTab from '$lib/components/admin/WeatherTab.svelte';
import StocksTab from '$lib/components/admin/StocksTab.svelte';
import BookmarksTab from '$lib/components/admin/BookmarksTab.svelte';
import Poe2Tab from '$lib/components/admin/Poe2Tab.svelte';
import WidgetsTab from '$lib/components/admin/WidgetsTab.svelte';
import ConnectionsTab from '$lib/components/admin/ConnectionsTab.svelte';
import LogsTab from '$lib/components/admin/LogsTab.svelte';
@@ -20,10 +17,7 @@
{ id: 'models', label: 'Models' },
{ id: 'retention', label: 'Retention' },
{ id: 'events', label: 'Tracked events' },
{ id: 'weather', label: 'Weather' },
{ id: 'stocks', label: 'Stocks' },
{ id: 'bookmarks', label: 'Bookmarks' },
{ id: 'poe2', label: 'PoE2' },
{ id: 'widgets', label: 'Widgets' },
{ id: 'connections', label: 'Connections' },
{ id: 'logs', label: 'Logs' }
];
@@ -53,14 +47,8 @@
<RetentionTab settings={data.settings} />
{:else if active === 'events'}
<EventsTab events={data.events} sources={data.sources} />
{:else if active === 'weather'}
<WeatherTab settings={data.settings} />
{:else if active === 'stocks'}
<StocksTab tickers={data.stockTickers} />
{:else if active === 'bookmarks'}
<BookmarksTab bookmarks={data.bookmarks} />
{:else if active === 'poe2'}
<Poe2Tab settings={data.settings} watchlist={data.poe2Watchlist} />
{:else if active === 'widgets'}
<WidgetsTab settings={data.settings} stockTickers={data.stockTickers} bookmarks={data.bookmarks} poe2Watchlist={data.poe2Watchlist} />
{:else if active === 'connections'}
<ConnectionsTab settings={data.settings} aiStatus={data.aiStatus} telegramStatus={data.telegramStatus} />
{:else if active === 'logs'}