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);
|
return categories.filter((c) => !c.isPrivate);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Per-widget sidebar visibility — see the admin panel's consolidated "Widgets" tab.
|
// Per-widget enable flags + display order — see the admin panel's consolidated
|
||||||
// Each widget keeps polling/config regardless of this; it only gates whether the
|
// "Widgets" tab. Weather/Stocks/PoE2's backend pollers are also gated on these
|
||||||
// sidebar renders it at all.
|
// flags (see scheduler.ts); Sidebar.svelte renders in exactly this order.
|
||||||
app.get('/api/widgets', async () => settingsDb.getSettings().widgets);
|
app.get('/api/widgets', async () => {
|
||||||
|
const { widgets, widgetOrder } = settingsDb.getSettings();
|
||||||
|
return { ...widgets, order: widgetOrder };
|
||||||
|
});
|
||||||
|
|
||||||
// Sidebar widgets — see WeatherTab/StocksTab/BookmarksTab in the admin panel.
|
// Sidebar widgets — see WeatherTab/StocksTab/BookmarksTab in the admin panel.
|
||||||
app.get('/api/weather', async () => settingsDb.getSettings().weather);
|
app.get('/api/weather', async () => settingsDb.getSettings().weather);
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ export function migrate() {
|
|||||||
widget_stocks_enabled INTEGER NOT NULL DEFAULT 1,
|
widget_stocks_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
widget_bookmarks_enabled INTEGER NOT NULL DEFAULT 1,
|
widget_bookmarks_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
widget_poe2_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_location_name TEXT,
|
||||||
weather_latitude REAL,
|
weather_latitude REAL,
|
||||||
weather_longitude 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_bookmarks_enabled INTEGER NOT NULL DEFAULT 1');
|
||||||
db.exec('ALTER TABLE global_settings ADD COLUMN widget_poe2_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
|
// 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.
|
// 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,
|
bookmarks: !!row.widget_bookmarks_enabled,
|
||||||
poe2: !!row.widget_poe2_enabled
|
poe2: !!row.widget_poe2_enabled
|
||||||
},
|
},
|
||||||
|
widgetOrder: JSON.parse(row.widget_order),
|
||||||
retention: {
|
retention: {
|
||||||
publishedArticleMaxAgeDays: row.published_article_max_age_days,
|
publishedArticleMaxAgeDays: row.published_article_max_age_days,
|
||||||
rawItemMaxAgeDays: row.raw_item_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,
|
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_weather_enabled=$widget_weather_enabled, widget_stocks_enabled=$widget_stocks_enabled,
|
||||||
widget_bookmarks_enabled=$widget_bookmarks_enabled, widget_poe2_enabled=$widget_poe2_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,
|
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,
|
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,
|
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_stocks_enabled: merged.widgets.stocks ? 1 : 0,
|
||||||
$widget_bookmarks_enabled: merged.widgets.bookmarks ? 1 : 0,
|
$widget_bookmarks_enabled: merged.widgets.bookmarks ? 1 : 0,
|
||||||
$widget_poe2_enabled: merged.widgets.poe2 ? 1 : 0,
|
$widget_poe2_enabled: merged.widgets.poe2 ? 1 : 0,
|
||||||
|
$widget_order: JSON.stringify(merged.widgetOrder),
|
||||||
$published_article_max_age_days: merged.retention.publishedArticleMaxAgeDays,
|
$published_article_max_age_days: merged.retention.publishedArticleMaxAgeDays,
|
||||||
$raw_item_max_age_days: merged.retention.rawItemMaxAgeDays,
|
$raw_item_max_age_days: merged.retention.rawItemMaxAgeDays,
|
||||||
$storage_cap_enabled: merged.retention.storageCapEnabled ? 1 : 0,
|
$storage_cap_enabled: merged.retention.storageCapEnabled ? 1 : 0,
|
||||||
|
|||||||
@@ -281,13 +281,15 @@ export interface GlobalSettings {
|
|||||||
fxtwitterBaseUrl: string;
|
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. */
|
/** 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';
|
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: {
|
widgets: {
|
||||||
weather: boolean;
|
weather: boolean;
|
||||||
stocks: boolean;
|
stocks: boolean;
|
||||||
bookmarks: boolean;
|
bookmarks: boolean;
|
||||||
poe2: 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: {
|
retention: {
|
||||||
publishedArticleMaxAgeDays: number | null;
|
publishedArticleMaxAgeDays: number | null;
|
||||||
rawItemMaxAgeDays: number | null;
|
rawItemMaxAgeDays: number | null;
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export interface AdminSettings {
|
|||||||
fxtwitterBaseUrl: string;
|
fxtwitterBaseUrl: string;
|
||||||
telegramMediaMode: 'self-host' | 'proxy';
|
telegramMediaMode: 'self-host' | 'proxy';
|
||||||
widgets: AdminWidgetsEnabled;
|
widgets: AdminWidgetsEnabled;
|
||||||
|
widgetOrder: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
|
||||||
retention: RetentionSettings;
|
retention: RetentionSettings;
|
||||||
categoryPriority: CategoryPriority[];
|
categoryPriority: CategoryPriority[];
|
||||||
weather: AdminWeatherSettings;
|
weather: AdminWeatherSettings;
|
||||||
|
|||||||
@@ -1,26 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
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 {
|
let {
|
||||||
title,
|
title,
|
||||||
enabled,
|
enabled,
|
||||||
onToggle,
|
onToggle,
|
||||||
// Weather/Stocks/PoE2 have a backend poller that scheduler.ts gates on this same
|
canMoveUp,
|
||||||
// flag (see scheduler.ts); Bookmarks doesn't poll anything, so disabling it only
|
canMoveDown,
|
||||||
// ever affects sidebar visibility — the tooltip shouldn't claim otherwise.
|
onMoveUp,
|
||||||
hasBackendPoller = true,
|
onMoveDown,
|
||||||
children
|
children
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
hasBackendPoller?: boolean;
|
canMoveUp: boolean;
|
||||||
|
canMoveDown: boolean;
|
||||||
|
onMoveUp: () => void;
|
||||||
|
onMoveDown: () => void;
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -29,23 +26,15 @@
|
|||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-head">
|
<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}>
|
<button class="head-btn" onclick={() => (expanded = !expanded)} aria-expanded={expanded}>
|
||||||
<span class="chevron" class:open={expanded}>▸</span>
|
<span class="chevron" class:open={expanded}>▸</span>
|
||||||
<span class="title">{title}</span>
|
<span class="title">{title}</span>
|
||||||
{#if !enabled}<span class="disabled-tag">Off</span>{/if}
|
|
||||||
</button>
|
</button>
|
||||||
<label
|
<span class="badge" class:active={enabled} onclick={onToggle} role="button" tabindex="0">
|
||||||
class="enable-toggle"
|
{enabled ? 'Active' : 'Disabled'}
|
||||||
title={hasBackendPoller
|
</span>
|
||||||
? 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>
|
|
||||||
</div>
|
</div>
|
||||||
{#if expanded}
|
{#if expanded}
|
||||||
<div class="section-body">
|
<div class="section-body">
|
||||||
@@ -64,10 +53,21 @@
|
|||||||
.section-head {
|
.section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 8px;
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 14px;
|
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 {
|
.head-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -91,20 +91,18 @@
|
|||||||
.chevron.open {
|
.chevron.open {
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
.disabled-tag {
|
.badge {
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
font-weight: 400;
|
padding: 2px 10px;
|
||||||
color: var(--text-muted);
|
|
||||||
border: 0.5px solid var(--border);
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.enable-toggle {
|
.badge.active {
|
||||||
display: flex;
|
background: var(--bg-accent);
|
||||||
align-items: center;
|
color: var(--text-accent);
|
||||||
}
|
|
||||||
.enable-toggle input {
|
|
||||||
width: auto;
|
|
||||||
}
|
}
|
||||||
.section-body {
|
.section-body {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
|||||||
@@ -19,45 +19,51 @@
|
|||||||
poe2Watchlist: AdminPoe2Entry[];
|
poe2Watchlist: AdminPoe2Entry[];
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// Local copy so each checkbox flips immediately — same idiom as BookmarksTab's
|
// Local copies so each toggle/reorder reflects immediately — same idiom as
|
||||||
// per-row "Private" toggle, just for widget visibility instead.
|
// BookmarksTab's per-row "Private" toggle.
|
||||||
let widgets = $state({ ...settings.widgets });
|
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) {
|
async function toggle(key: keyof typeof widgets) {
|
||||||
widgets[key] = !widgets[key];
|
widgets[key] = !widgets[key];
|
||||||
await updateSettings({ widgets });
|
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>
|
</script>
|
||||||
|
|
||||||
<p class="hint">
|
{#each widgetOrder as key, i (key)}
|
||||||
Each widget can be enabled or disabled independently. Disabling Weather, Stocks, or PoE2
|
<WidgetSection
|
||||||
hides it from the sidebar <strong>and</strong> stops its backend poller — no more outbound
|
title={titles[key]}
|
||||||
requests until it's turned back on, at which point it polls again immediately rather than
|
enabled={widgets[key]}
|
||||||
waiting out its normal schedule. You can still edit a disabled widget's settings below; they
|
onToggle={() => toggle(key)}
|
||||||
just won't fetch anything new until it's re-enabled. Bookmarks has no poller, so its toggle
|
canMoveUp={i > 0}
|
||||||
only affects sidebar visibility.
|
canMoveDown={i < widgetOrder.length - 1}
|
||||||
</p>
|
onMoveUp={() => move(i, -1)}
|
||||||
|
onMoveDown={() => move(i, 1)}
|
||||||
<WidgetSection title="Weather" enabled={widgets.weather} onToggle={() => toggle('weather')}>
|
>
|
||||||
|
{#if key === 'weather'}
|
||||||
<WeatherTab {settings} />
|
<WeatherTab {settings} />
|
||||||
</WidgetSection>
|
{:else if key === 'stocks'}
|
||||||
|
|
||||||
<WidgetSection title="Stocks" enabled={widgets.stocks} onToggle={() => toggle('stocks')}>
|
|
||||||
<StocksTab tickers={stockTickers} />
|
<StocksTab tickers={stockTickers} />
|
||||||
</WidgetSection>
|
{:else if key === 'bookmarks'}
|
||||||
|
|
||||||
<WidgetSection title="Bookmarks" enabled={widgets.bookmarks} onToggle={() => toggle('bookmarks')} hasBackendPoller={false}>
|
|
||||||
<BookmarksTab {bookmarks} />
|
<BookmarksTab {bookmarks} />
|
||||||
</WidgetSection>
|
{:else if key === 'poe2'}
|
||||||
|
|
||||||
<WidgetSection title="PoE2" enabled={widgets.poe2} onToggle={() => toggle('poe2')}>
|
|
||||||
<Poe2Tab {settings} watchlist={poe2Watchlist} />
|
<Poe2Tab {settings} watchlist={poe2Watchlist} />
|
||||||
</WidgetSection>
|
{/if}
|
||||||
|
</WidgetSection>
|
||||||
<style>
|
{/each}
|
||||||
.hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin: 0 0 14px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -90,10 +90,17 @@
|
|||||||
<div class="sidebar-track" bind:this={trackEl} style:height="{trackHeight}px">
|
<div class="sidebar-track" bind:this={trackEl} style:height="{trackHeight}px">
|
||||||
<aside class="sidebar-viewport" style:height="{viewportHeight}px">
|
<aside class="sidebar-viewport" style:height="{viewportHeight}px">
|
||||||
<div class="sidebar-content" bind:this={contentEl} style:transform="translateY(-{progress}px)">
|
<div class="sidebar-content" bind:this={contentEl} style:transform="translateY(-{progress}px)">
|
||||||
{#if widgetsEnabled.weather}<WeatherWidget {weather} />{/if}
|
{#each widgetsEnabled.order as key (key)}
|
||||||
{#if widgetsEnabled.stocks}<StocksWidget {stocks} />{/if}
|
{#if key === 'weather' && widgetsEnabled.weather}
|
||||||
{#if widgetsEnabled.poe2}<Poe2Widget {poe2} />{/if}
|
<WeatherWidget {weather} />
|
||||||
{#if widgetsEnabled.bookmarks}<BookmarksWidget {bookmarks} />{/if}
|
{: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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -173,10 +173,11 @@ export interface Poe2Data {
|
|||||||
entries: Poe2WatchlistEntry[];
|
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 {
|
export interface WidgetsEnabled {
|
||||||
weather: boolean;
|
weather: boolean;
|
||||||
stocks: boolean;
|
stocks: boolean;
|
||||||
bookmarks: boolean;
|
bookmarks: boolean;
|
||||||
poe2: boolean;
|
poe2: boolean;
|
||||||
|
order: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user