Simplify Widgets tab: state button instead of checkbox, add drag-free up/down reordering

Drops the checkbox/tooltip/hint-paragraph combo for a single click-to-toggle
Active/Disabled badge (same pattern as EventsTab's Active/Paused). Widget
order is now admin-sortable via up/down arrows and persisted as
widgetOrder, with Sidebar.svelte rendering widgets in that exact sequence
instead of a fixed hardcoded order.
This commit is contained in:
Claude
2026-07-26 19:05:24 +00:00
parent 9ca5bf566d
commit 9e13e95f35
9 changed files with 109 additions and 82 deletions
+7 -4
View File
@@ -62,10 +62,13 @@ 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);
// Per-widget enable flags + display order — see the admin panel's consolidated
// "Widgets" tab. Weather/Stocks/PoE2's backend pollers are also gated on these
// flags (see scheduler.ts); Sidebar.svelte renders in exactly this order.
app.get('/api/widgets', async () => {
const { widgets, widgetOrder } = settingsDb.getSettings();
return { ...widgets, order: widgetOrder };
});
// Sidebar widgets — see WeatherTab/StocksTab/BookmarksTab in the admin panel.
app.get('/api/weather', async () => settingsDb.getSettings().weather);
+6
View File
@@ -188,6 +188,7 @@ export function migrate() {
widget_stocks_enabled INTEGER NOT NULL DEFAULT 1,
widget_bookmarks_enabled INTEGER NOT NULL DEFAULT 1,
widget_poe2_enabled INTEGER NOT NULL DEFAULT 1,
widget_order TEXT NOT NULL DEFAULT '["weather","stocks","poe2","bookmarks"]', -- JSON array, admin-sortable via the Widgets tab
weather_location_name TEXT,
weather_latitude REAL,
weather_longitude REAL,
@@ -363,6 +364,11 @@ export function migrate() {
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');
}
if (!hasColumn('global_settings', 'widget_order')) {
db.exec(
`ALTER TABLE global_settings ADD COLUMN widget_order TEXT NOT NULL DEFAULT '["weather","stocks","poe2","bookmarks"]'`
);
}
// 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.
+3
View File
@@ -21,6 +21,7 @@ function rowToSettings(row: any): GlobalSettings {
bookmarks: !!row.widget_bookmarks_enabled,
poe2: !!row.widget_poe2_enabled
},
widgetOrder: JSON.parse(row.widget_order),
retention: {
publishedArticleMaxAgeDays: row.published_article_max_age_days,
rawItemMaxAgeDays: row.raw_item_max_age_days,
@@ -79,6 +80,7 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
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,
widget_order=$widget_order,
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,
@@ -104,6 +106,7 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
$widget_stocks_enabled: merged.widgets.stocks ? 1 : 0,
$widget_bookmarks_enabled: merged.widgets.bookmarks ? 1 : 0,
$widget_poe2_enabled: merged.widgets.poe2 ? 1 : 0,
$widget_order: JSON.stringify(merged.widgetOrder),
$published_article_max_age_days: merged.retention.publishedArticleMaxAgeDays,
$raw_item_max_age_days: merged.retention.rawItemMaxAgeDays,
$storage_cap_enabled: merged.retention.storageCapEnabled ? 1 : 0,
+3 -1
View File
@@ -281,13 +281,15 @@ 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. */
/** Per-widget enable flags — see admin/settings' consolidated "Widgets" tab. Weather/Stocks/PoE2's backend pollers (scheduler.ts) are gated on these too, not just sidebar visibility; Bookmarks has no poller so its flag only affects the sidebar. */
widgets: {
weather: boolean;
stocks: boolean;
bookmarks: boolean;
poe2: boolean;
};
/** Sidebar widget display order, admin-sortable via the Widgets tab's up/down arrows — mirrored exactly by Sidebar.svelte. */
widgetOrder: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
retention: {
publishedArticleMaxAgeDays: number | null;
rawItemMaxAgeDays: number | null;
+1
View File
@@ -139,6 +139,7 @@ export interface AdminSettings {
fxtwitterBaseUrl: string;
telegramMediaMode: 'self-host' | 'proxy';
widgets: AdminWidgetsEnabled;
widgetOrder: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
retention: RetentionSettings;
categoryPriority: CategoryPriority[];
weather: AdminWeatherSettings;
@@ -1,26 +1,23 @@
<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 hides it from the
// sidebar AND stops its backend poller from making outbound requests (see
// scheduler.ts's widgets.* gate) — it just doesn't stop the admin from expanding
// this section to keep configuring it while it's off.
let {
title,
enabled,
onToggle,
// Weather/Stocks/PoE2 have a backend poller that scheduler.ts gates on this same
// flag (see scheduler.ts); Bookmarks doesn't poll anything, so disabling it only
// ever affects sidebar visibility — the tooltip shouldn't claim otherwise.
hasBackendPoller = true,
canMoveUp,
canMoveDown,
onMoveUp,
onMoveDown,
children
}: {
title: string;
enabled: boolean;
onToggle: () => void;
hasBackendPoller?: boolean;
canMoveUp: boolean;
canMoveDown: boolean;
onMoveUp: () => void;
onMoveDown: () => void;
children: Snippet;
} = $props();
@@ -29,23 +26,15 @@
<div class="section">
<div class="section-head">
<button class="icon-btn" onclick={onMoveUp} disabled={!canMoveUp} aria-label="Move up"></button>
<button class="icon-btn" onclick={onMoveDown} disabled={!canMoveDown} aria-label="Move down"></button>
<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">Off</span>{/if}
</button>
<label
class="enable-toggle"
title={hasBackendPoller
? enabled
? 'Disable — stops polling and hides from the sidebar'
: 'Enable — resumes polling and shows in the sidebar'
: enabled
? 'Disable — hides from the sidebar'
: 'Enable — shows in the sidebar'}
>
<input type="checkbox" checked={enabled} onchange={onToggle} />
</label>
<span class="badge" class:active={enabled} onclick={onToggle} role="button" tabindex="0">
{enabled ? 'Active' : 'Disabled'}
</span>
</div>
{#if expanded}
<div class="section-body">
@@ -64,10 +53,21 @@
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 8px;
padding: 12px 14px;
}
.icon-btn {
font-size: 11px;
padding: 2px 6px;
background: transparent;
border: none;
color: var(--text-secondary);
}
.icon-btn:disabled {
color: var(--text-muted);
opacity: 0.4;
cursor: default;
}
.head-btn {
display: flex;
align-items: center;
@@ -91,20 +91,18 @@
.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;
.badge {
font-size: 11px;
padding: 2px 10px;
border-radius: var(--radius);
background: var(--surface-2);
color: var(--text-muted);
cursor: pointer;
white-space: nowrap;
}
.enable-toggle {
display: flex;
align-items: center;
}
.enable-toggle input {
width: auto;
.badge.active {
background: var(--bg-accent);
color: var(--text-accent);
}
.section-body {
padding: 14px;
@@ -19,45 +19,51 @@
poe2Watchlist: AdminPoe2Entry[];
} = $props();
// Local copy so each checkbox flips immediately — same idiom as BookmarksTab's
// per-row "Private" toggle, just for widget visibility instead.
// Local copies so each toggle/reorder reflects immediately — same idiom as
// BookmarksTab's per-row "Private" toggle.
let widgets = $state({ ...settings.widgets });
let widgetOrder = $state([...settings.widgetOrder]);
const titles: Record<(typeof widgetOrder)[number], string> = {
weather: 'Weather',
stocks: 'Stocks',
bookmarks: 'Bookmarks',
poe2: 'PoE2'
};
async function toggle(key: keyof typeof widgets) {
widgets[key] = !widgets[key];
await updateSettings({ widgets });
}
async function move(index: number, dir: -1 | 1) {
const target = index + dir;
if (target < 0 || target >= widgetOrder.length) return;
const arr = [...widgetOrder];
[arr[index], arr[target]] = [arr[target], arr[index]];
widgetOrder = arr;
await updateSettings({ widgetOrder });
}
</script>
<p class="hint">
Each widget can be enabled or disabled independently. Disabling Weather, Stocks, or PoE2
hides it from the sidebar <strong>and</strong> stops its backend poller — no more outbound
requests until it's turned back on, at which point it polls again immediately rather than
waiting out its normal schedule. You can still edit a disabled widget's settings below; they
just won't fetch anything new until it's re-enabled. Bookmarks has no poller, so its toggle
only affects sidebar visibility.
</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')} hasBackendPoller={false}>
<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>
{#each widgetOrder as key, i (key)}
<WidgetSection
title={titles[key]}
enabled={widgets[key]}
onToggle={() => toggle(key)}
canMoveUp={i > 0}
canMoveDown={i < widgetOrder.length - 1}
onMoveUp={() => move(i, -1)}
onMoveDown={() => move(i, 1)}
>
{#if key === 'weather'}
<WeatherTab {settings} />
{:else if key === 'stocks'}
<StocksTab tickers={stockTickers} />
{:else if key === 'bookmarks'}
<BookmarksTab {bookmarks} />
{:else if key === 'poe2'}
<Poe2Tab {settings} watchlist={poe2Watchlist} />
{/if}
</WidgetSection>
{/each}
@@ -90,10 +90,17 @@
<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)">
{#if widgetsEnabled.weather}<WeatherWidget {weather} />{/if}
{#if widgetsEnabled.stocks}<StocksWidget {stocks} />{/if}
{#if widgetsEnabled.poe2}<Poe2Widget {poe2} />{/if}
{#if widgetsEnabled.bookmarks}<BookmarksWidget {bookmarks} />{/if}
{#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} />
{/if}
{/each}
</div>
</aside>
</div>
+2 -1
View File
@@ -173,10 +173,11 @@ export interface Poe2Data {
entries: Poe2WatchlistEntry[];
}
/** Per-widget sidebar visibility, admin-toggled from the consolidated "Widgets" tab. */
/** Per-widget sidebar visibility + display order, admin-set from the consolidated "Widgets" tab. */
export interface WidgetsEnabled {
weather: boolean;
stocks: boolean;
bookmarks: boolean;
poe2: boolean;
order: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
}