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:
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user