Full widget parity, /api/widget/<id> route namespace, live sidebar rendering

Weather, Stocks, and Bookmarks move into backend/src/widgets/<id>/ alongside
PoE2, each a full WidgetPlugin rather than a thin wrapper: their tables are
renamed to the widget_<id>_ convention (stock_tickers, bookmarks) or moved off
global_settings entirely into the generic widget_kv store (weather's
location/unit config and forecast cache). Every widget's routes move under a
consistent /api/widget/<id> (public) and /api/admin/widget/<id> (admin)
namespace, replacing the previous flat /api/weather, /api/admin/poe2/browse,
etc. The registry-management routes (list/upload/enable/delete any widget)
stay at /api/admin/widgets since they address the collection, not one
widget's own data.

This also closes the gap where an uploaded widget had backend data plumbing
but no visible presence anywhere: a widget's poll() can now publish to a
generic GET /api/widget/:id/report feed (registered once, works for any id
with zero per-widget route registration, so it's live immediately after
upload with no restart); the sidebar renders it via a new GenericWidgetCard,
or via a new DynamicWidgetSlot that dynamic-imports an optional pre-built
vanilla-JS frontend bundle the widget can ship (served from a new
/widget-assets/:id/* route) — plain browser import(), not blocked by
SvelteKit's ahead-of-time Svelte compilation the way raw .svelte source would
be. A widget's own custom API routes still need a restart to register
(a hard Fastify limitation), but its data/poll/report and any custom frontend
UI now work fully live. The admin Widgets tab gained an upload form and a
list of installed pluggable widgets with enable/delete.

Verified against a copy of the real dev DB (all three renames + the weather
config/cache migration fire once and are idempotent on a second boot) and
through a real browser: uploaded a widget with both a report-driven poll and
a custom frontend bundle, confirmed it renders live in the sidebar with no
backend restart, then deleted it and confirmed it disappears along with all
of its data (table/kv rows/on-disk files).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
Claude
2026-07-27 01:16:47 +00:00
parent bf0cb11070
commit 837aa77bfc
37 changed files with 1119 additions and 410 deletions
+20 -8
View File
@@ -37,14 +37,6 @@ export async function registerAdminRoutes(app: FastifyInstance) {
}
const before = settingsDb.getSettings();
const settings = withStorageUsed(settingsDb.updateSettings(body));
if (body.weather) {
// Poll immediately rather than waiting for the next scheduler tick (up to 45
// minutes) — the admin just changed the location/unit and expects to see it reflected.
loadedWidgets
.get('weather')
?.poll?.run()
.catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`));
}
if (body.widgets) {
// Re-enabling a widget (see the Widgets tab) should show fresh data right away
// instead of waiting out its normal cadence — scheduler.ts skips polling
@@ -246,6 +238,26 @@ export async function registerAdminRoutes(app: FastifyInstance) {
return reply.code(201).send({ id: result.id });
});
app.patch('/api/admin/widgets/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const { enabled } = req.body as { enabled?: boolean };
const widget = installedWidgetsDb.getInstalled(id);
if (!widget) return reply.code(404).send({ error: 'not found' });
if (typeof enabled === 'boolean') {
installedWidgetsDb.setEnabled(id, enabled);
// Re-enabling should show fresh data right away rather than waiting out the
// widget's own poll interval — same "immediate poll on enable" behavior the
// 4 built-ins get via PATCH /api/admin/settings above.
if (enabled && !widget.enabled) {
loadedWidgets
.get(id)
?.poll?.run()
.catch((err) => logger.error(id, `Immediate poll failed: ${err.message}`));
}
}
return installedWidgetsDb.getInstalled(id);
});
app.delete('/api/admin/widgets/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const widget = installedWidgetsDb.getInstalled(id);
+28 -6
View File
@@ -4,6 +4,9 @@ import * as tagsDb from '../storage/db/tags.js';
import * as eventsDb from '../storage/db/events.js';
import * as categoriesDb from '../storage/db/categories.js';
import * as settingsDb from '../storage/db/settings.js';
import * as installedWidgetsDb from '../storage/db/installedWidgets.js';
import { getKv } from '../storage/db/widgetKv.js';
import type { WidgetReport } from '../widgets/report.js';
import { hasPrivateAccess } from './privateAccess.js';
export async function registerPublicRoutes(app: FastifyInstance) {
@@ -59,15 +62,34 @@ export async function registerPublicRoutes(app: FastifyInstance) {
return categories.filter((c) => !c.isPrivate);
});
// 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.
// Per-widget enable flags + display order for the 4 built-ins — 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. The
// `pluggable` array lists enabled *uploaded* widgets separately (their ids aren't part
// of the closed weather|stocks|bookmarks|poe2 union `order` uses) — see
// widgets/registry.ts, Sidebar.svelte's GenericWidgetCard/DynamicWidgetSlot.
app.get('/api/widgets', async () => {
const { widgets, widgetOrder } = settingsDb.getSettings();
return { ...widgets, order: widgetOrder };
const pluggable = installedWidgetsDb
.listInstalled()
.filter((w) => w.source === 'uploaded' && w.enabled)
.map((w) => ({ id: w.id, displayName: w.displayName, frontendEntry: w.frontendEntry }));
return { ...widgets, order: widgetOrder, pluggable };
});
// Per-widget public routes (GET /api/weather, /api/stocks, /api/bookmarks, /api/poe2)
// are registered by each widget's own plugin — see widgets/registry.ts's generic
// Generic live-data feed any widget (built-in or uploaded) can publish to via
// setKv(id, 'report', ...) in its own poll.run() — see widgets/report.ts. Registered
// once here rather than per-widget, so it works for a widget uploaded after this
// process started, with zero new route registration (Fastify refuses routes added
// after app.listen(), so this is what makes "poll live -> sidebar shows it live" work
// for an uploaded widget without a restart).
app.get('/api/widget/:id/report', async (req, reply) => {
const { id } = req.params as { id: string };
if (!installedWidgetsDb.getInstalled(id)?.enabled) return reply.code(404).send();
return { data: getKv<WidgetReport>(id, 'report') };
});
// Per-widget public routes (GET /api/widget/weather, /stocks, /bookmarks, /poe2) are
// registered by each widget's own plugin — see widgets/registry.ts's generic
// registerPublicRoutes loop in index.ts.
}
-47
View File
@@ -1,47 +0,0 @@
import type { WidgetPlugin } from '../widgets/types.js';
import * as bookmarksDb from '../storage/db/bookmarks.js';
import { hasPrivateAccess } from '../api/privateAccess.js';
// Built-in sidebar "Bookmarks" widget — thin wrapper so it funnels into the same
// route-registration loop as pluggable widgets (see widgets/registry.ts); no poll (purely
// admin-curated links) and no migrate()/uninstall() since its schema isn't moving. Not
// going through the upload/delete lifecycle — see widgets/types.ts.
export const bookmarksPlugin: WidgetPlugin = {
id: 'bookmarks',
displayName: 'Bookmarks',
version: '1.0.0',
registerPublicRoutes(app) {
app.get('/api/bookmarks', async (req) => {
const bookmarks = bookmarksDb.listBookmarks();
if (hasPrivateAccess(req)) return bookmarks;
return bookmarks.filter((b) => !b.isPrivate);
});
},
registerAdminRoutes(app) {
app.get('/api/admin/bookmarks', async () => bookmarksDb.listBookmarks());
app.post('/api/admin/bookmarks', async (req, reply) => {
const { name, url, isPrivate } = req.body as { name?: string; url?: string; isPrivate?: boolean };
if (!name || !name.trim() || !url || !url.trim()) {
return reply.code(400).send({ error: 'name and url are required' });
}
const created = bookmarksDb.createBookmark(name.trim(), url.trim(), !!isPrivate);
return reply.code(201).send(created);
});
app.patch('/api/admin/bookmarks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const updated = bookmarksDb.updateBookmark(id, req.body as any);
if (!updated) return reply.code(404).send({ error: 'not found' });
return updated;
});
app.delete('/api/admin/bookmarks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
bookmarksDb.deleteBookmark(id);
return reply.code(204).send();
});
}
};
+21
View File
@@ -19,6 +19,7 @@ import { loadAllWidgets, loadedWidgets } from './widgets/registry.js';
const PORT = Number(process.env.PORT) || 4000;
const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'http://localhost:5173';
const MEDIA_DIR = process.env.MEDIA_DIR || './data/media';
const WIDGETS_INSTALLED_DIR = process.env.WIDGETS_INSTALLED_DIR || './data/widgets-installed';
function printApiKeyBanner() {
const line = '='.repeat(64);
@@ -103,6 +104,26 @@ async function main() {
return reply.send(fs.createReadStream(filePath));
});
// A widget's optional pre-built frontend bundle (see widgets/manifest.ts's
// frontendEntry) — one generic wildcard route rather than one per widget, so it works
// for a widget uploaded after this process started, with no restart (unlike a
// widget's own custom API routes, which do need one — see widgets/install.ts).
// Explicit Content-Type is required here (unlike /media/:filename above) — browsers
// reject a dynamically-imported module whose response isn't served as a JS MIME
// type. The wildcard also lets a bundle's own relative imports (e.g. `import
// './helper.mjs'`) resolve automatically, since the browser requests those against
// this same route.
app.get('/widget-assets/:id/*', async (req, reply) => {
const { id } = req.params as { id: string };
const rel = (req.params as { '*': string })['*'];
if (rel.includes('..')) return reply.code(400).send();
const filePath = path.join(WIDGETS_INSTALLED_DIR, id, rel);
if (!fs.existsSync(filePath)) return reply.code(404).send();
if (rel.endsWith('.mjs') || rel.endsWith('.js')) reply.type('text/javascript');
else if (rel.endsWith('.css')) reply.type('text/css');
return reply.send(fs.createReadStream(filePath));
});
// Static "/media/proxy" and "/media/telegram-proxy" take priority over the
// "/media/:filename" param route above regardless of registration order
// (find-my-way, Fastify's router, always prefers a static segment over a parametric
-54
View File
@@ -1,54 +0,0 @@
import type { WidgetPlugin } from '../widgets/types.js';
import * as stocksDb from '../storage/db/stocks.js';
import { logger } from '../storage/db/logs.js';
import { pollStocksNow } from './poller.js';
// Built-in sidebar "Stocks" widget — thin wrapper so it funnels into the same
// scheduler/route-registration loop as pluggable widgets (see widgets/registry.ts); its
// tickers stay in the dedicated stock_tickers table exactly as before. Not going through
// the upload/delete lifecycle — see widgets/types.ts; no migrate()/uninstall() since its
// schema isn't moving.
export const stocksPlugin: WidgetPlugin = {
id: 'stocks',
displayName: 'Stocks',
version: '1.0.0',
poll: {
// Per admin spec — stock prices move faster than weather.
intervalMs: 15 * 60_000,
run: pollStocksNow
},
registerPublicRoutes(app) {
app.get('/api/stocks', async () => stocksDb.listStockTickers());
},
registerAdminRoutes(app) {
app.get('/api/admin/stocks', async () => stocksDb.listStockTickers());
app.post('/api/admin/stocks', async (req, reply) => {
const { label, symbol } = req.body as { label?: string; symbol?: string };
if (!label || !label.trim() || !symbol || !symbol.trim()) {
return reply.code(400).send({ error: 'label and symbol are required' });
}
const created = stocksDb.createStockTicker(label.trim(), symbol.trim());
// Poll immediately rather than waiting for the next tick (up to 15 minutes) — cheap,
// and refreshes every existing ticker's price too.
pollStocksNow().catch((err) => logger.error('stocks', `Immediate poll failed: ${err.message}`));
return reply.code(201).send(created);
});
app.patch('/api/admin/stocks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const updated = stocksDb.updateStockTicker(id, req.body as any);
if (!updated) return reply.code(404).send({ error: 'not found' });
return updated;
});
app.delete('/api/admin/stocks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
stocksDb.deleteStockTicker(id);
return reply.code(204).send();
});
}
};
+54 -43
View File
@@ -45,6 +45,20 @@ export function migrate() {
db.exec('DROP INDEX IF EXISTS idx_poe2_rate_history_watchlist;');
}
// Stocks and Bookmarks moved into the pluggable-widget system too (backend/src/widgets/
// stocks/, widgets/bookmarks/) — same plain-rename treatment as PoE2 above, now owned by
// each widget's own plugin.migrate() instead of this file's CREATE TABLE block.
const hasOldStockTickersTable = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='stock_tickers'`).get();
if (hasOldStockTickersTable) {
db.exec('ALTER TABLE stock_tickers RENAME TO widget_stocks_tickers;');
}
// "bookmarks" is generic enough to collide with something else entirely — check for the
// old bookmarks shape specifically (its distinctive is_private column) before renaming.
const oldBookmarksCols = db.prepare(`PRAGMA table_info(bookmarks)`).all() as { name: string }[];
if (oldBookmarksCols.some((c) => c.name === 'is_private')) {
db.exec('ALTER TABLE bookmarks RENAME TO widget_bookmarks_items;');
}
db.exec(`
CREATE TABLE IF NOT EXISTS sources (
id TEXT PRIMARY KEY,
@@ -220,21 +234,6 @@ export function migrate() {
poe2_updated_at TEXT
);
-- Sidebar "Stocks" widget — polled every 15 minutes from Yahoo Finance (see
-- stocks/poller.ts). Price/change/poll-state live directly on the row, same as
-- sources.last_polled_at, rather than a separate quote-cache table.
CREATE TABLE IF NOT EXISTS stock_tickers (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
symbol TEXT NOT NULL, -- Yahoo symbol syntax, e.g. "^DJI", "AAPL", "BTC-USD"
priority_rank INTEGER NOT NULL,
last_price REAL,
last_change_percent REAL,
last_polled_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL
);
-- Generic config/cache store for pluggable widgets (see widgets/types.ts,
-- storage/db/widgetKv.ts) — lets a widget stay fully prunable by widget_id alone on
-- uninstall without a bespoke table for simple key/value state.
@@ -259,20 +258,10 @@ export function migrate() {
priority_rank INTEGER NOT NULL,
version TEXT NOT NULL DEFAULT '1.0.0',
owned_tables TEXT NOT NULL DEFAULT '[]', -- JSON array, self-reported by the plugin — used by the uninstall safety-net sweep
frontend_entry TEXT, -- relative path to an optional pre-built browser JS bundle, served from /widget-assets/<id>/*
installed_at TEXT NOT NULL
);
-- Sidebar "Bookmarks" widget — admin-curated links, each independently hidden/public
-- via is_private (same private-access lock feature as categories.is_private).
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
priority_rank INTEGER NOT NULL,
is_private INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
-- Singleton row (see storage/crypto.ts) — encrypted Telegram API credentials and
-- the resulting login session. Deliberately its own table, not part of
-- global_settings, so these encrypted blobs never ride along in the generic
@@ -382,22 +371,8 @@ export function migrate() {
`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.
const tickerCount = db.prepare('SELECT COUNT(*) as c FROM stock_tickers').get() as { c: number };
if (tickerCount.c === 0) {
const defaults: [string, string][] = [
['Dow Jones', '^DJI'],
['S&P 500', '^GSPC'],
['Bitcoin', 'BTC-USD']
];
const stmt = db.prepare(
'INSERT INTO stock_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)'
);
defaults.forEach(([label, symbol], i) => {
stmt.run(`stk-${symbol.replace(/[^a-z0-9]+/gi, '-')}`, label, symbol, i + 1, new Date().toISOString());
});
if (!hasColumn('installed_widgets', 'frontend_entry')) {
db.exec('ALTER TABLE installed_widgets ADD COLUMN frontend_entry TEXT');
}
// Seed default categories if none exist yet. "News" sits right under "Top stories" —
@@ -439,7 +414,12 @@ export function migrate() {
? JSON.parse(settingsRow.widget_order ?? '["weather","stocks","poe2","bookmarks"]')
: ['weather', 'stocks', 'poe2', 'bookmarks'];
const displayNames: Record<string, string> = { weather: 'Weather', stocks: 'Stocks', bookmarks: 'Bookmarks', poe2: 'PoE2' };
const codePaths: Record<string, string> = { weather: 'weather', stocks: 'stocks', bookmarks: 'bookmarks', poe2: 'widgets/poe2' };
const codePaths: Record<string, string> = {
weather: 'widgets/weather',
stocks: 'widgets/stocks',
bookmarks: 'widgets/bookmarks',
poe2: 'widgets/poe2'
};
const ownedTables: Record<string, string[]> = { poe2: ['widget_poe2_watchlist', 'widget_poe2_rate_history'] };
const enabledOf = (id: string) => (settingsRow ? !!settingsRow[`widget_${id}_enabled`] : true);
const insertWidget = db.prepare(
@@ -480,4 +460,35 @@ export function migrate() {
new Date().toISOString()
);
}
// Weather's config (admin-set location/units) and cache (last-polled forecast) move off
// their bare global_settings columns into two widget_kv entries — same "every widget
// owns its own data" consistency as PoE2's league cache above. Old columns left in
// place, unread.
const weatherRow = db.prepare('SELECT * FROM global_settings WHERE id = 1').get() as any;
const hasWeatherConfigKv = db.prepare("SELECT 1 FROM widget_kv WHERE widget_id = 'weather' AND key = 'config'").get();
if (weatherRow?.weather_location_name && !hasWeatherConfigKv) {
const nowIso = new Date().toISOString();
db.prepare(`INSERT INTO widget_kv (widget_id, key, value, updated_at) VALUES ('weather', 'config', ?, ?)`).run(
JSON.stringify({
locationName: weatherRow.weather_location_name,
latitude: weatherRow.weather_latitude,
longitude: weatherRow.weather_longitude,
unit: weatherRow.weather_unit,
windUnit: weatherRow.weather_wind_unit,
pressureUnit: weatherRow.weather_pressure_unit
}),
nowIso
);
db.prepare(`INSERT INTO widget_kv (widget_id, key, value, updated_at) VALUES ('weather', 'cache', ?, ?)`).run(
JSON.stringify({
current: weatherRow.weather_current ? JSON.parse(weatherRow.weather_current) : null,
hourly: JSON.parse(weatherRow.weather_hourly ?? '[]'),
daily: JSON.parse(weatherRow.weather_daily ?? '[]'),
alerts: JSON.parse(weatherRow.weather_alerts ?? '[]'),
updatedAt: weatherRow.weather_updated_at
}),
nowIso
);
}
}
+5 -2
View File
@@ -11,6 +11,7 @@ function rowToWidget(row: any): InstalledWidget {
priorityRank: row.priority_rank,
version: row.version,
ownedTables: JSON.parse(row.owned_tables),
frontendEntry: row.frontend_entry ?? null,
installedAt: row.installed_at
};
}
@@ -33,13 +34,14 @@ export function insertWidget(widget: {
priorityRank?: number;
version?: string;
ownedTables?: string[];
frontendEntry?: string | null;
}): InstalledWidget {
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM installed_widgets').get() as { m: number };
const priorityRank = widget.priorityRank ?? maxRank.m + 1;
const installedAt = new Date().toISOString();
db.prepare(
`INSERT INTO installed_widgets (id, display_name, source, code_path, enabled, priority_rank, version, owned_tables, installed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO installed_widgets (id, display_name, source, code_path, enabled, priority_rank, version, owned_tables, frontend_entry, installed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
widget.id,
widget.displayName,
@@ -49,6 +51,7 @@ export function insertWidget(widget: {
priorityRank,
widget.version ?? '1.0.0',
JSON.stringify(widget.ownedTables ?? []),
widget.frontendEntry ?? null,
installedAt
);
return getInstalled(widget.id)!;
+6 -46
View File
@@ -1,16 +1,9 @@
import { db } from './index.js';
import type { GlobalSettings } from './types.js';
import * as installedWidgetsDb from './installedWidgets.js';
import { getKv, setKv } from './widgetKv.js';
const BUILTIN_WIDGET_IDS = ['weather', 'stocks', 'bookmarks', 'poe2'] as const;
interface Poe2LeagueCache {
leagueId: string | null;
leagueName: string | null;
updatedAt: string | null;
}
// widgets/widgetOrder are computed from the installed_widgets registry (see
// storage/db/installedWidgets.ts, widgets/registry.ts) rather than stored as their own
// global_settings columns — the registry is the single source of truth for enable state
@@ -32,8 +25,10 @@ function widgetsAndOrder(): Pick<GlobalSettings, 'widgets' | 'widgetOrder'> {
return { widgets, widgetOrder };
}
// Every widget's own config/data now lives behind its own /api/widget/<id> routes (see
// widgets/weather/db.ts, widgets/poe2/poll.ts's league cache, etc.) — this settings blob
// is just the scalar pipeline knobs plus the 4 builtins' enable/order flags.
function rowToSettings(row: any): GlobalSettings {
const poe2Cache = getKv<Poe2LeagueCache>('poe2', 'leagueCache') ?? { leagueId: null, leagueName: null, updatedAt: null };
return {
mergeStrictness: row.merge_strictness,
holdBeforePublishMinutes: row.hold_before_publish_minutes,
@@ -55,22 +50,7 @@ function rowToSettings(row: any): GlobalSettings {
storageCapEnabled: !!row.storage_cap_enabled,
storageCapValue: row.storage_cap_value,
storageCapUnit: row.storage_cap_unit
},
weather: {
locationName: row.weather_location_name,
latitude: row.weather_latitude,
longitude: row.weather_longitude,
unit: row.weather_unit,
windUnit: row.weather_wind_unit,
pressureUnit: row.weather_pressure_unit,
// Unlike retention, this is genuinely absent pre-first-poll (and pre-location-config) — null-safe parse.
current: row.weather_current ? JSON.parse(row.weather_current) : null,
hourly: JSON.parse(row.weather_hourly),
daily: JSON.parse(row.weather_daily),
alerts: JSON.parse(row.weather_alerts),
updatedAt: row.weather_updated_at
},
poe2: poe2Cache
}
};
}
@@ -86,8 +66,6 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
...patch,
retention: { ...current.retention, ...(patch.retention ?? {}) },
selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) },
weather: { ...current.weather, ...(patch.weather ?? {}) },
poe2: { ...current.poe2, ...(patch.poe2 ?? {}) },
widgets: { ...current.widgets, ...(patch.widgets ?? {}) }
};
@@ -99,9 +77,6 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
if (patch.widgetOrder) {
installedWidgetsDb.reorder(patch.widgetOrder);
}
if (patch.poe2) {
setKv('poe2', 'leagueCache', merged.poe2);
}
// Named params (rather than positional `?`) so this list can be reordered or
// extended without the column list and the bound-values list silently drifting
@@ -116,11 +91,7 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
nitter_media_mode=$nitter_media_mode, fxtwitter_base_url=$fxtwitter_base_url, nitter_instance_url=$nitter_instance_url,
telegram_media_mode=$telegram_media_mode,
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,
weather_unit=$weather_unit, weather_wind_unit=$weather_wind_unit, weather_pressure_unit=$weather_pressure_unit,
weather_current=$weather_current, weather_hourly=$weather_hourly, weather_daily=$weather_daily,
weather_alerts=$weather_alerts, weather_updated_at=$weather_updated_at
storage_cap_enabled=$storage_cap_enabled, storage_cap_value=$storage_cap_value, storage_cap_unit=$storage_cap_unit
WHERE id = 1`
).run({
$merge_strictness: merged.mergeStrictness,
@@ -140,18 +111,7 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
$raw_item_max_age_days: merged.retention.rawItemMaxAgeDays,
$storage_cap_enabled: merged.retention.storageCapEnabled ? 1 : 0,
$storage_cap_value: merged.retention.storageCapValue,
$storage_cap_unit: merged.retention.storageCapUnit,
$weather_location_name: merged.weather.locationName,
$weather_latitude: merged.weather.latitude,
$weather_longitude: merged.weather.longitude,
$weather_unit: merged.weather.unit,
$weather_wind_unit: merged.weather.windUnit,
$weather_pressure_unit: merged.weather.pressureUnit,
$weather_current: merged.weather.current ? JSON.stringify(merged.weather.current) : null,
$weather_hourly: JSON.stringify(merged.weather.hourly),
$weather_daily: JSON.stringify(merged.weather.daily),
$weather_alerts: JSON.stringify(merged.weather.alerts),
$weather_updated_at: merged.weather.updatedAt
$storage_cap_unit: merged.retention.storageCapUnit
});
return getSettings();
}
+2 -67
View File
@@ -208,21 +208,6 @@ export interface Category {
isSpillover: boolean;
}
export interface WeatherHourEntry {
time: string;
temp: number;
conditionText: string;
icon: string;
}
export interface WeatherDayEntry {
date: string;
tempMax: number;
tempMin: number;
conditionText: string;
icon: string;
}
export interface StockTicker {
id: string;
label: string;
@@ -268,6 +253,8 @@ export interface InstalledWidget {
version: string;
/** Self-reported by the plugin (WidgetPlugin.ownedTables) at install/load time — used by the uninstall safety-net sweep. */
ownedTables: string[];
/** Relative path (within the widget's install dir) to an optional pre-built browser JS bundle, served from /widget-assets/<id>/* and dynamic-import()ed by the sidebar's DynamicWidgetSlot. Null for a widget with no custom frontend (falls back to the generic report card). */
frontendEntry: string | null;
installedAt: string;
}
@@ -314,56 +301,4 @@ export interface GlobalSettings {
storageCapValue: number;
storageCapUnit: 'MB' | 'GB';
};
/** Sidebar "Weather" widget config + cache — see weather/poller.ts. Singleton, since there's only ever one configured location. */
weather: {
locationName: string | null;
latitude: number | null;
longitude: number | null;
unit: 'celsius' | 'fahrenheit';
windUnit: 'mph' | 'kph';
pressureUnit: 'inHg' | 'hPa';
current: {
temp: number;
/** Apparent temperature (Open-Meteo's own heat-index/wind-chill blend) — "Feels like". */
feelsLike: number;
conditionText: string;
icon: string;
/** Percent, 0-100. */
humidity: number;
/** Percent, 0-100 — the current hour's forecast precipitation probability (there's no true instantaneous "chance of rain" measurement). */
precipitationChance: number;
/** Already in the admin's configured windUnit. */
windSpeed: number;
/** 8-point compass abbreviation, e.g. "NW". */
windDirection: string;
/** Already in the admin's configured pressureUnit. */
pressure: number;
sunrise: string;
sunset: string;
} | null;
hourly: WeatherHourEntry[];
daily: WeatherDayEntry[];
/** Active NWS alerts (flash flood, hurricane, blizzard, etc.) for the configured location — US-only, empty elsewhere. See weather/client.ts's fetchActiveAlerts. */
alerts: WeatherAlert[];
updatedAt: string | null;
};
/**
* Sidebar "PoE2" widget cache — see poe2/poller.ts. No admin-set config (unlike weather):
* the league is always auto-detected as the current challenge league, so this is purely
* a cache of what the last poll learned. Watchlist entries themselves live in the
* poe2_watchlist table, not here — same split as stock_tickers vs. this settings row.
*/
poe2: {
leagueId: string | null;
leagueName: string | null;
updatedAt: string | null;
};
}
export interface WeatherAlert {
id: string;
event: string;
headline: string;
severity: string;
expires: string;
}
-36
View File
@@ -1,36 +0,0 @@
import type { WidgetPlugin } from '../widgets/types.js';
import * as settingsDb from '../storage/db/settings.js';
import { geocodeLocation } from './client.js';
import { pollWeatherNow } from './poller.js';
// Built-in sidebar "Weather" widget — thin wrapper so it funnels into the same
// scheduler/route-registration loop as pluggable widgets (see widgets/registry.ts), while
// its config/cache stay on global_settings' weather_* columns exactly as before. Not going
// through the upload/delete lifecycle — see widgets/types.ts and the plan's "built-in vs
// pluggable" split; no migrate()/uninstall() since its schema isn't moving.
export const weatherPlugin: WidgetPlugin = {
id: 'weather',
displayName: 'Weather',
version: '1.0.0',
poll: {
intervalMs: 45 * 60_000,
run: pollWeatherNow
},
registerPublicRoutes(app) {
app.get('/api/weather', async () => settingsDb.getSettings().weather);
},
registerAdminRoutes(app) {
app.get('/api/admin/weather/geocode', async (req, reply) => {
const { query } = req.query as { query?: string };
if (!query || !query.trim()) return reply.code(400).send({ error: 'query required' });
try {
return await geocodeLocation(query.trim());
} catch (err) {
return reply.code(502).send({ error: `Geocoding service unreachable: ${(err as Error).message}` });
}
});
}
};
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto';
import { db } from './index.js';
import type { Bookmark } from './types.js';
import { db } from '../../storage/db/index.js';
import type { Bookmark } from '../../storage/db/types.js';
function rowToBookmark(row: any): Bookmark {
return {
@@ -14,33 +14,33 @@ function rowToBookmark(row: any): Bookmark {
}
// Always returns every bookmark, private or not — filtering for unauthenticated visitors
// happens at the route layer (GET /api/bookmarks), same as categoriesDb.listCategories().
// happens at the route layer (GET /api/widget/bookmarks), same as categoriesDb.listCategories().
export function listBookmarks(): Bookmark[] {
const rows = db.prepare('SELECT * FROM bookmarks ORDER BY priority_rank').all();
const rows = db.prepare('SELECT * FROM widget_bookmarks_items ORDER BY priority_rank').all();
return rows.map(rowToBookmark);
}
export function createBookmark(name: string, url: string, isPrivate = false): Bookmark {
const id = `bm-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`;
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM bookmarks').get() as { m: number };
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM widget_bookmarks_items').get() as { m: number };
const createdAt = new Date().toISOString();
db.prepare(
'INSERT INTO bookmarks (id, name, url, priority_rank, is_private, created_at) VALUES (?, ?, ?, ?, ?, ?)'
'INSERT INTO widget_bookmarks_items (id, name, url, priority_rank, is_private, created_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(id, name, url, maxRank.m + 1, isPrivate ? 1 : 0, createdAt);
return { id, name, url, priorityRank: maxRank.m + 1, isPrivate, createdAt };
}
export function updateBookmark(id: string, patch: { name?: string; url?: string; isPrivate?: boolean }): Bookmark | null {
const existing = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(id);
const existing = db.prepare('SELECT * FROM widget_bookmarks_items WHERE id = ?').get(id);
if (!existing) return null;
const current = rowToBookmark(existing);
const merged = { ...current, ...patch };
db.prepare('UPDATE bookmarks SET name = ?, url = ?, is_private = ? WHERE id = ?').run(
db.prepare('UPDATE widget_bookmarks_items SET name = ?, url = ?, is_private = ? WHERE id = ?').run(
merged.name, merged.url, merged.isPrivate ? 1 : 0, id
);
return merged;
}
export function deleteBookmark(id: string) {
db.prepare('DELETE FROM bookmarks WHERE id = ?').run(id);
db.prepare('DELETE FROM widget_bookmarks_items WHERE id = ?').run(id);
}
+68
View File
@@ -0,0 +1,68 @@
import type { DatabaseSync } from 'node:sqlite';
import type { WidgetPlugin } from '../types.js';
import * as bookmarksDb from './db.js';
import { hasPrivateAccess } from '../../api/privateAccess.js';
const OWNED_TABLES = ['widget_bookmarks_items'];
// Sidebar "Bookmarks" widget — admin-curated links, each independently hidden/public via
// is_private (same private-access lock feature as categories.is_private). No poll (purely
// admin-curated, no external fetch). Built-in and non-deletable, but otherwise a full
// WidgetPlugin like an uploaded one.
export const bookmarksPlugin: WidgetPlugin = {
id: 'bookmarks',
displayName: 'Bookmarks',
version: '1.0.0',
ownedTables: OWNED_TABLES,
migrate(db: DatabaseSync) {
db.exec(`
CREATE TABLE IF NOT EXISTS widget_bookmarks_items (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
priority_rank INTEGER NOT NULL,
is_private INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
`);
},
registerPublicRoutes(app) {
app.get('/api/widget/bookmarks', async (req) => {
const bookmarks = bookmarksDb.listBookmarks();
if (hasPrivateAccess(req)) return bookmarks;
return bookmarks.filter((b) => !b.isPrivate);
});
},
registerAdminRoutes(app) {
app.get('/api/admin/widget/bookmarks', async () => bookmarksDb.listBookmarks());
app.post('/api/admin/widget/bookmarks', async (req, reply) => {
const { name, url, isPrivate } = req.body as { name?: string; url?: string; isPrivate?: boolean };
if (!name || !name.trim() || !url || !url.trim()) {
return reply.code(400).send({ error: 'name and url are required' });
}
const created = bookmarksDb.createBookmark(name.trim(), url.trim(), !!isPrivate);
return reply.code(201).send(created);
});
app.patch('/api/admin/widget/bookmarks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const updated = bookmarksDb.updateBookmark(id, req.body as any);
if (!updated) return reply.code(404).send({ error: 'not found' });
return updated;
});
app.delete('/api/admin/widget/bookmarks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
bookmarksDb.deleteBookmark(id);
return reply.code(204).send();
});
},
uninstall(db: DatabaseSync) {
db.exec('DROP TABLE IF EXISTS widget_bookmarks_items;');
}
};
+2 -1
View File
@@ -51,7 +51,8 @@ export async function installUploadedWidget(manifest: unknown, files: unknown):
source: 'uploaded',
codePath: dir,
version: m.version,
ownedTables: plugin.ownedTables ?? []
ownedTables: plugin.ownedTables ?? [],
frontendEntry: m.frontendEntry ?? null
});
startWidgetPolling(plugin);
+8
View File
@@ -8,6 +8,8 @@ export interface WidgetManifest {
version: string;
/** Relative path within `files` to the ESM entry point, e.g. "index.mjs" — a default export satisfying WidgetPlugin. */
entry: string;
/** Optional relative path within `files` to a pre-built browser JS bundle (plain vanilla JS, not raw Svelte source — see widgets/registry.ts's frontend-loading notes) exporting a default `{ mount(container, ctx) }`. Served from /widget-assets/<id>/* and dynamic-import()ed by the sidebar's DynamicWidgetSlot. Omit for a widget with no custom UI — it falls back to the generic report card. */
frontendEntry?: string;
}
// Upload body shape: { manifest, files: { "index.mjs": "<source text>", ... } } — plain
@@ -21,11 +23,17 @@ export function validateManifest(manifest: unknown, files: unknown): string | nu
if (typeof m.displayName !== 'string' || !m.displayName.trim()) return 'manifest.displayName is required';
if (typeof m.version !== 'string' || !m.version.trim()) return 'manifest.version is required';
if (typeof m.entry !== 'string' || !m.entry.trim()) return 'manifest.entry is required';
if (m.frontendEntry !== undefined && (typeof m.frontendEntry !== 'string' || !m.frontendEntry.trim())) {
return 'manifest.frontendEntry must be a non-empty string when present';
}
if (!files || typeof files !== 'object' || Array.isArray(files)) return 'files must be a non-empty object';
const entries = Object.entries(files as Record<string, unknown>);
if (entries.length === 0) return 'files must be a non-empty object';
if (!(m.entry in (files as Record<string, unknown>))) return `entry "${m.entry as string}" not found in files`;
if (m.frontendEntry !== undefined && !(m.frontendEntry in (files as Record<string, unknown>))) {
return `frontendEntry "${m.frontendEntry as string}" not found in files`;
}
let totalBytes = 0;
for (const [relPath, content] of entries) {
+5 -5
View File
@@ -52,7 +52,7 @@ export const poe2Plugin: WidgetPlugin = {
},
registerPublicRoutes(app) {
app.get('/api/poe2', async () => {
app.get('/api/widget/poe2', async () => {
const { leagueName, updatedAt } = getLeagueCache();
return { leagueName, updatedAt, entries: poe2Db.listWatchlist() };
});
@@ -60,7 +60,7 @@ export const poe2Plugin: WidgetPlugin = {
registerAdminRoutes(app) {
// League is always auto-detected, never admin-set.
app.get('/api/admin/poe2/browse', async (_req, reply) => {
app.get('/api/admin/widget/poe2/browse', async (_req, reply) => {
try {
const league = await fetchCurrentLeague();
return await browseCurrencies(league.id);
@@ -69,9 +69,9 @@ export const poe2Plugin: WidgetPlugin = {
}
});
app.get('/api/admin/poe2/watchlist', async () => poe2Db.listWatchlist());
app.get('/api/admin/widget/poe2/watchlist', async () => poe2Db.listWatchlist());
app.post('/api/admin/poe2/watchlist', async (req, reply) => {
app.post('/api/admin/widget/poe2/watchlist', async (req, reply) => {
const { base, quote } = req.body as {
base?: { currencyId?: string; name?: string };
quote?: { currencyId?: string; name?: string };
@@ -92,7 +92,7 @@ export const poe2Plugin: WidgetPlugin = {
return reply.code(201).send(created);
});
app.delete('/api/admin/poe2/watchlist/:id', async (req, reply) => {
app.delete('/api/admin/widget/poe2/watchlist/:id', async (req, reply) => {
const { id } = req.params as { id: string };
poe2Db.removeWatchlistEntry(id);
return reply.code(204).send();
+3 -3
View File
@@ -6,9 +6,9 @@ import * as installedWidgetsDb from '../storage/db/installedWidgets.js';
import { logger } from '../storage/db/logs.js';
import type { WidgetPlugin } from './types.js';
import { sweepOrphanedWidgetData } from './sweep.js';
import { weatherPlugin } from '../weather/plugin.js';
import { stocksPlugin } from '../stocks/plugin.js';
import { bookmarksPlugin } from '../bookmarks/plugin.js';
import { weatherPlugin } from './weather/plugin.js';
import { stocksPlugin } from './stocks/plugin.js';
import { bookmarksPlugin } from './bookmarks/plugin.js';
import { poe2Plugin } from './poe2/plugin.js';
// Both built-in and uploaded widgets funnel into this one map — scheduler dispatch and
+13
View File
@@ -0,0 +1,13 @@
/**
* Generic live-data shape a widget's poll.run() can publish via
* setKv(id, 'report', report) (see storage/db/widgetKv.ts) for the sidebar's generic
* report card to render — see api/public.ts's GET /api/widget/:id/report, registered once
* at startup so it works for any widget id including ones uploaded after boot, with zero
* per-widget route registration (sidesteps Fastify's "no routes after listen()" limit).
*/
export interface WidgetReport {
title: string;
headline?: { value: string; delta?: string } | null;
rows?: { label: string; value: string }[];
updatedAt: string | null;
}
+54
View File
@@ -0,0 +1,54 @@
import { randomUUID } from 'node:crypto';
import { db } from '../../storage/db/index.js';
import type { StockTicker } from '../../storage/db/types.js';
function rowToTicker(row: any): StockTicker {
return {
id: row.id,
label: row.label,
symbol: row.symbol,
priorityRank: row.priority_rank,
lastPrice: row.last_price,
lastChangePercent: row.last_change_percent,
lastPolledAt: row.last_polled_at,
lastError: row.last_error,
createdAt: row.created_at
};
}
export function listStockTickers(): StockTicker[] {
const rows = db.prepare('SELECT * FROM widget_stocks_tickers ORDER BY priority_rank').all();
return rows.map(rowToTicker);
}
export function createStockTicker(label: string, symbol: string): StockTicker {
const id = `stk-${symbol.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`;
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM widget_stocks_tickers').get() as { m: number };
const createdAt = new Date().toISOString();
db.prepare(
'INSERT INTO widget_stocks_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)'
).run(id, label, symbol, maxRank.m + 1, createdAt);
return {
id, label, symbol, priorityRank: maxRank.m + 1,
lastPrice: null, lastChangePercent: null, lastPolledAt: null, lastError: null, createdAt
};
}
export function updateStockTicker(id: string, patch: { label?: string; symbol?: string }): StockTicker | null {
const existing = db.prepare('SELECT * FROM widget_stocks_tickers WHERE id = ?').get(id);
if (!existing) return null;
const current = rowToTicker(existing);
const merged = { ...current, ...patch };
db.prepare('UPDATE widget_stocks_tickers SET label = ?, symbol = ? WHERE id = ?').run(merged.label, merged.symbol, id);
return { ...merged };
}
export function deleteStockTicker(id: string) {
db.prepare('DELETE FROM widget_stocks_tickers WHERE id = ?').run(id);
}
export function markStockPolled(id: string, price: number | null, changePercent: number | null, error: string | null) {
db.prepare(
'UPDATE widget_stocks_tickers SET last_price = ?, last_change_percent = ?, last_polled_at = ?, last_error = ? WHERE id = ?'
).run(price, changePercent, new Date().toISOString(), error, id);
}
+94
View File
@@ -0,0 +1,94 @@
import type { DatabaseSync } from 'node:sqlite';
import type { WidgetPlugin } from '../types.js';
import { logger } from '../../storage/db/logs.js';
import * as stocksDb from './db.js';
import { pollStocksNow } from './poll.js';
const OWNED_TABLES = ['widget_stocks_tickers'];
// Sidebar "Stocks" widget — polled every 15 minutes from Yahoo Finance (see poll.ts).
// Price/change/poll-state live directly on its own table, same as sources.last_polled_at,
// rather than a separate quote-cache table. Built-in and non-deletable, but otherwise a
// full WidgetPlugin like an uploaded one.
export const stocksPlugin: WidgetPlugin = {
id: 'stocks',
displayName: 'Stocks',
version: '1.0.0',
ownedTables: OWNED_TABLES,
migrate(db: DatabaseSync) {
db.exec(`
CREATE TABLE IF NOT EXISTS widget_stocks_tickers (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
symbol TEXT NOT NULL,
priority_rank INTEGER NOT NULL,
last_price REAL,
last_change_percent REAL,
last_polled_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL
);
`);
// Seed a handful of sensible default tickers so the widget isn't empty on a fresh
// install — the admin can remove/replace any of them.
const tickerCount = db.prepare('SELECT COUNT(*) as c FROM widget_stocks_tickers').get() as { c: number };
if (tickerCount.c === 0) {
const defaults: [string, string][] = [
['Dow Jones', '^DJI'],
['S&P 500', '^GSPC'],
['Bitcoin', 'BTC-USD']
];
const stmt = db.prepare(
'INSERT INTO widget_stocks_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)'
);
defaults.forEach(([label, symbol], i) => {
stmt.run(`stk-${symbol.replace(/[^a-z0-9]+/gi, '-')}`, label, symbol, i + 1, new Date().toISOString());
});
}
},
poll: {
// Per admin spec — stock prices move faster than weather.
intervalMs: 15 * 60_000,
run: pollStocksNow
},
registerPublicRoutes(app) {
app.get('/api/widget/stocks', async () => stocksDb.listStockTickers());
},
registerAdminRoutes(app) {
app.get('/api/admin/widget/stocks', async () => stocksDb.listStockTickers());
app.post('/api/admin/widget/stocks', async (req, reply) => {
const { label, symbol } = req.body as { label?: string; symbol?: string };
if (!label || !label.trim() || !symbol || !symbol.trim()) {
return reply.code(400).send({ error: 'label and symbol are required' });
}
const created = stocksDb.createStockTicker(label.trim(), symbol.trim());
// Poll immediately rather than waiting for the next tick (up to 15 minutes) — cheap,
// and refreshes every existing ticker's price too.
pollStocksNow().catch((err) => logger.error('stocks', `Immediate poll failed: ${err.message}`));
return reply.code(201).send(created);
});
app.patch('/api/admin/widget/stocks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const updated = stocksDb.updateStockTicker(id, req.body as any);
if (!updated) return reply.code(404).send({ error: 'not found' });
return updated;
});
app.delete('/api/admin/widget/stocks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
stocksDb.deleteStockTicker(id);
return reply.code(204).send();
});
},
uninstall(db: DatabaseSync) {
db.exec('DROP TABLE IF EXISTS widget_stocks_tickers;');
}
};
@@ -1,11 +1,11 @@
import * as stocksDb from '../storage/db/stocks.js';
import { logger } from '../storage/db/logs.js';
import * as stocksDb from './db.js';
import { logger } from '../../storage/db/logs.js';
import { fetchQuotes } from './client.js';
// Called on a schedule (see queue/scheduler.ts) and immediately after the admin adds a
// ticker (see api/admin.ts) — one request per configured ticker (see client.ts for why
// there's no batch endpoint here). A symbol Yahoo can't resolve gets its own lastError,
// it never aborts the rest of the batch.
// Called on a schedule (see queue/scheduler.ts, via plugin.poll) and immediately after the
// admin adds a ticker (see plugin.ts's admin routes) — one request per configured ticker
// (see client.ts for why there's no batch endpoint here). A symbol Yahoo can't resolve
// gets its own lastError, it never aborts the rest of the batch.
export async function pollStocksNow(): Promise<void> {
const tickers = stocksDb.listStockTickers();
if (tickers.length === 0) return;
+103
View File
@@ -0,0 +1,103 @@
import { getKv, setKv } from '../../storage/db/widgetKv.js';
export interface WeatherHourEntry {
time: string;
temp: number;
conditionText: string;
icon: string;
}
export interface WeatherDayEntry {
date: string;
tempMax: number;
tempMin: number;
conditionText: string;
icon: string;
}
export interface WeatherAlert {
id: string;
event: string;
headline: string;
severity: string;
expires: string;
}
export interface WeatherConfig {
locationName: string | null;
latitude: number | null;
longitude: number | null;
unit: 'celsius' | 'fahrenheit';
windUnit: 'mph' | 'kph';
pressureUnit: 'inHg' | 'hPa';
}
export interface WeatherCache {
current: {
temp: number;
/** Apparent temperature (Open-Meteo's own heat-index/wind-chill blend) — "Feels like". */
feelsLike: number;
conditionText: string;
icon: string;
/** Percent, 0-100. */
humidity: number;
/** Percent, 0-100 — the current hour's forecast precipitation probability (there's no true instantaneous "chance of rain" measurement). */
precipitationChance: number;
/** Already in the admin's configured windUnit. */
windSpeed: number;
/** 8-point compass abbreviation, e.g. "NW". */
windDirection: string;
/** Already in the admin's configured pressureUnit. */
pressure: number;
sunrise: string;
sunset: string;
} | null;
hourly: WeatherHourEntry[];
daily: WeatherDayEntry[];
/** Active NWS alerts (flash flood, hurricane, blizzard, etc.) for the configured location — US-only, empty elsewhere. See client.ts's fetchActiveAlerts. */
alerts: WeatherAlert[];
updatedAt: string | null;
}
const WIDGET_ID = 'weather';
const DEFAULT_CONFIG: WeatherConfig = {
locationName: null,
latitude: null,
longitude: null,
unit: 'fahrenheit',
windUnit: 'mph',
pressureUnit: 'inHg'
};
const DEFAULT_CACHE: WeatherCache = {
current: null,
hourly: [],
daily: [],
alerts: [],
updatedAt: null
};
// Config (admin-settable: location/units) and cache (poll-computed forecast) are stored as
// two separate widget_kv keys — mirrors the singleton-row split stock_tickers/bookmarks
// tables already had from global_settings, just via the generic kv store instead of a
// bespoke table (this widget has no data shaped like rows, so no dedicated table is needed).
export function getConfig(): WeatherConfig {
return getKv<WeatherConfig>(WIDGET_ID, 'config') ?? DEFAULT_CONFIG;
}
export function setConfig(patch: Partial<WeatherConfig>): WeatherConfig {
const merged = { ...getConfig(), ...patch };
setKv(WIDGET_ID, 'config', merged);
return merged;
}
export function getCache(): WeatherCache {
return getKv<WeatherCache>(WIDGET_ID, 'cache') ?? DEFAULT_CACHE;
}
export function setCache(patch: Partial<WeatherCache>): WeatherCache {
const merged = { ...getCache(), ...patch };
setKv(WIDGET_ID, 'cache', merged);
return merged;
}
+47
View File
@@ -0,0 +1,47 @@
import type { WidgetPlugin } from '../types.js';
import { logger } from '../../storage/db/logs.js';
import { geocodeLocation } from './client.js';
import * as weatherDb from './db.js';
import { pollWeatherNow } from './poll.js';
// Sidebar "Weather" widget — config (location/units) and cache (forecast) live entirely in
// widget_kv (see db.ts); no bespoke table needed. Built-in and non-deletable (source:
// 'builtin' in installed_widgets), but otherwise a full WidgetPlugin like an uploaded one.
export const weatherPlugin: WidgetPlugin = {
id: 'weather',
displayName: 'Weather',
version: '1.0.0',
poll: {
intervalMs: 45 * 60_000,
run: pollWeatherNow
},
registerPublicRoutes(app) {
app.get('/api/widget/weather', async () => ({ ...weatherDb.getConfig(), ...weatherDb.getCache() }));
},
registerAdminRoutes(app) {
// Returns config + cache together (same shape as the public route) so the admin
// tab can show "currently showing X" status alongside the location/unit form.
app.get('/api/admin/widget/weather', async () => ({ ...weatherDb.getConfig(), ...weatherDb.getCache() }));
app.patch('/api/admin/widget/weather', async (req) => {
weatherDb.setConfig(req.body as any);
// Poll immediately rather than waiting for the next scheduler tick (up to 45
// minutes) — the admin just changed the location/unit and expects to see it reflected.
pollWeatherNow().catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`));
return { ...weatherDb.getConfig(), ...weatherDb.getCache() };
});
app.get('/api/admin/widget/weather/geocode', async (req, reply) => {
const { query } = req.query as { query?: string };
if (!query || !query.trim()) return reply.code(400).send({ error: 'query required' });
try {
return await geocodeLocation(query.trim());
} catch (err) {
return reply.code(502).send({ error: `Geocoding service unreachable: ${(err as Error).message}` });
}
});
}
};
@@ -1,26 +1,29 @@
import * as settingsDb from '../storage/db/settings.js';
import { logger } from '../storage/db/logs.js';
import { logger } from '../../storage/db/logs.js';
import { fetchForecast, fetchActiveAlerts } from './client.js';
import * as weatherDb from './db.js';
import type { WeatherCache } from './db.js';
// Called on a schedule (see queue/scheduler.ts) and immediately after the admin changes
// the weather location/units (see api/admin.ts) — writes straight into global_settings'
// weather_* columns via settingsDb, same singleton-row approach as retention.
// Called on a schedule (see queue/scheduler.ts, via plugin.poll) and immediately after the
// admin changes the weather location/units (see plugin.ts's admin routes) — writes into
// the widget's own widget_kv cache entry via weatherDb, same singleton-cache approach as
// before, just no longer riding on global_settings.
export async function pollWeatherNow(): Promise<void> {
const { weather } = settingsDb.getSettings();
if (weather.latitude === null || weather.longitude === null) {
const config = weatherDb.getConfig();
if (config.latitude === null || config.longitude === null) {
// No location configured yet — not an error, just nothing to do.
return;
}
let forecastUpdate: Partial<typeof weather> = {};
const cache = weatherDb.getCache();
let forecastUpdate: Partial<WeatherCache> = {};
let forecastSucceeded = false;
try {
const { current, hourly, daily } = await fetchForecast(
weather.latitude,
weather.longitude,
weather.unit,
weather.windUnit,
weather.pressureUnit
config.latitude,
config.longitude,
config.unit,
config.windUnit,
config.pressureUnit
);
forecastUpdate = { current, hourly, daily };
forecastSucceeded = true;
@@ -33,20 +36,17 @@ export async function pollWeatherNow(): Promise<void> {
// reliably (and expectedly) for every non-US location. A failure here shouldn't
// touch the forecast update above, and unlike a stale forecast, a stale alert that's
// since expired is worse to keep showing than none at all — clear to empty on failure.
let alerts = weather.alerts;
let alerts = cache.alerts;
try {
alerts = await fetchActiveAlerts(weather.latitude, weather.longitude);
alerts = await fetchActiveAlerts(config.latitude, config.longitude);
} catch (err) {
alerts = [];
logger.warn('weather', `Alerts poll failed (expected outside the US): ${(err as Error).message}`);
}
settingsDb.updateSettings({
weather: {
...weather,
...forecastUpdate,
alerts,
updatedAt: forecastSucceeded ? new Date().toISOString() : weather.updatedAt
}
weatherDb.setCache({
...forecastUpdate,
alerts,
updatedAt: forecastSucceeded ? new Date().toISOString() : cache.updatedAt
});
}
+40 -17
View File
@@ -13,7 +13,10 @@ import type {
AdminStockTicker,
AdminBookmark,
Poe2BrowseEntry,
AdminPoe2Entry
AdminPoe2Entry,
AdminWeatherSettings,
InstalledWidget,
WidgetUploadManifest
} from './adminTypes';
async function request<T>(path: string, options: RequestInit = {}, fetchFn: typeof fetch = fetch): Promise<T> {
@@ -170,47 +173,53 @@ export const getLogs = (filters: { level?: 'info' | 'warn' | 'error'; limit?: nu
return request<LogEntry[]>(`/api/admin/logs${qs ? `?${qs}` : ''}`, {}, fetchFn);
};
// Weather — config/cache lives on AdminSettings.weather (see updateSettings above); this
// is just the geocoding lookup used to resolve a typed city name to lat/lon.
// Weather — config/cache now live behind the widget's own dedicated admin route (see
// backend/src/widgets/weather/plugin.ts) rather than riding along on AdminSettings.
export const getWeatherConfig = (fetchFn?: typeof fetch) =>
request<AdminWeatherSettings>('/api/admin/widget/weather', {}, fetchFn);
export const updateWeatherConfig = (patch: Partial<AdminWeatherSettings>, fetchFn?: typeof fetch) =>
request<AdminWeatherSettings>('/api/admin/widget/weather', { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
export const geocodeLocation = (query: string, fetchFn?: typeof fetch) =>
request<GeocodeResult[]>(`/api/admin/weather/geocode?query=${encodeURIComponent(query)}`, {}, fetchFn);
request<GeocodeResult[]>(`/api/admin/widget/weather/geocode?query=${encodeURIComponent(query)}`, {}, fetchFn);
// Stocks
export const getStockTickers = (fetchFn?: typeof fetch) =>
request<AdminStockTicker[]>('/api/admin/stocks', {}, fetchFn);
request<AdminStockTicker[]>('/api/admin/widget/stocks', {}, fetchFn);
export const addStockTicker = (label: string, symbol: string, fetchFn?: typeof fetch) =>
request<AdminStockTicker>('/api/admin/stocks', { method: 'POST', body: JSON.stringify({ label, symbol }) }, fetchFn);
request<AdminStockTicker>('/api/admin/widget/stocks', { method: 'POST', body: JSON.stringify({ label, symbol }) }, fetchFn);
export const updateStockTicker = (id: string, patch: { label?: string; symbol?: string }, fetchFn?: typeof fetch) =>
request<AdminStockTicker>(`/api/admin/stocks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
request<AdminStockTicker>(`/api/admin/widget/stocks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
export const deleteStockTicker = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/api/admin/stocks/${id}`, { method: 'DELETE' }, fetchFn);
request<void>(`/api/admin/widget/stocks/${id}`, { method: 'DELETE' }, fetchFn);
// Bookmarks
export const getAdminBookmarks = (fetchFn?: typeof fetch) =>
request<AdminBookmark[]>('/api/admin/bookmarks', {}, fetchFn);
request<AdminBookmark[]>('/api/admin/widget/bookmarks', {}, fetchFn);
export const addBookmark = (name: string, url: string, isPrivate = false, fetchFn?: typeof fetch) =>
request<AdminBookmark>(
'/api/admin/bookmarks',
'/api/admin/widget/bookmarks',
{ method: 'POST', body: JSON.stringify({ name, url, isPrivate }) },
fetchFn
);
export const updateBookmark = (id: string, patch: { name?: string; url?: string; isPrivate?: boolean }, fetchFn?: typeof fetch) =>
request<AdminBookmark>(`/api/admin/bookmarks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
request<AdminBookmark>(`/api/admin/widget/bookmarks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
export const deleteBookmark = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/api/admin/bookmarks/${id}`, { method: 'DELETE' }, fetchFn);
request<void>(`/api/admin/widget/bookmarks/${id}`, { method: 'DELETE' }, fetchFn);
// PoE2 — league is always auto-detected, never admin-set (see poe2/poller.ts).
// PoE2 — league is always auto-detected, never admin-set (see widgets/poe2/poll.ts).
export const browsePoe2Currencies = (fetchFn?: typeof fetch) =>
request<Poe2BrowseEntry[]>('/api/admin/poe2/browse', {}, fetchFn);
request<Poe2BrowseEntry[]>('/api/admin/widget/poe2/browse', {}, fetchFn);
export const getPoe2Watchlist = (fetchFn?: typeof fetch) =>
request<AdminPoe2Entry[]>('/api/admin/poe2/watchlist', {}, fetchFn);
request<AdminPoe2Entry[]>('/api/admin/widget/poe2/watchlist', {}, fetchFn);
export const addPoe2WatchlistEntry = (
base: { currencyId: string; name: string },
@@ -218,10 +227,24 @@ export const addPoe2WatchlistEntry = (
fetchFn?: typeof fetch
) =>
request<AdminPoe2Entry>(
'/api/admin/poe2/watchlist',
'/api/admin/widget/poe2/watchlist',
{ method: 'POST', body: JSON.stringify({ base, quote }) },
fetchFn
);
export const removePoe2WatchlistEntry = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/api/admin/poe2/watchlist/${id}`, { method: 'DELETE' }, fetchFn);
request<void>(`/api/admin/widget/poe2/watchlist/${id}`, { method: 'DELETE' }, fetchFn);
// Pluggable widgets (upload/list/enable/delete) — see backend/src/widgets/install.ts,
// uninstall.ts. Built-in widgets (source: 'builtin') 400 on deleteWidget.
export const listWidgets = (fetchFn?: typeof fetch) =>
request<InstalledWidget[]>('/api/admin/widgets', {}, fetchFn);
export const installWidget = (manifest: WidgetUploadManifest, files: Record<string, string>, fetchFn?: typeof fetch) =>
request<{ id: string }>('/api/admin/widgets', { method: 'POST', body: JSON.stringify({ manifest, files }) }, fetchFn);
export const setWidgetEnabled = (id: string, enabled: boolean, fetchFn?: typeof fetch) =>
request<InstalledWidget>(`/api/admin/widgets/${id}`, { method: 'PATCH', body: JSON.stringify({ enabled }) }, fetchFn);
export const deleteWidget = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/api/admin/widgets/${id}`, { method: 'DELETE' }, fetchFn);
+21 -8
View File
@@ -112,12 +112,6 @@ export interface AdminPoe2Entry {
lastError: string | null;
}
export interface AdminPoe2Settings {
leagueId: string | null;
leagueName: string | null;
updatedAt: string | null;
}
export interface AdminWidgetsEnabled {
weather: boolean;
stocks: boolean;
@@ -125,6 +119,27 @@ export interface AdminWidgetsEnabled {
poe2: boolean;
}
/** A row from the installed_widgets registry — see backend/src/storage/db/installedWidgets.ts. */
export interface InstalledWidget {
id: string;
displayName: string;
source: 'builtin' | 'uploaded';
enabled: boolean;
priorityRank: number;
version: string;
frontendEntry: string | null;
installedAt: string;
}
/** Body for POST /api/admin/widgets — see backend/src/widgets/manifest.ts. */
export interface WidgetUploadManifest {
id: string;
displayName: string;
version: string;
entry: string;
frontendEntry?: string;
}
export interface AdminSettings {
mergeStrictness: 1 | 2 | 3 | 4 | 5;
holdBeforePublishMinutes: number;
@@ -143,8 +158,6 @@ export interface AdminSettings {
widgetOrder: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
retention: RetentionSettings;
categoryPriority: CategoryPriority[];
weather: AdminWeatherSettings;
poe2: AdminPoe2Settings;
}
export interface AdminSource {
+4 -4
View File
@@ -43,19 +43,19 @@ export function getCategories(fetchFn?: typeof fetch): Promise<Category[]> {
}
export function getWeather(fetchFn?: typeof fetch): Promise<Weather> {
return get<Weather>('/api/weather', fetchFn);
return get<Weather>('/api/widget/weather', fetchFn);
}
export function getStocks(fetchFn?: typeof fetch): Promise<StockTicker[]> {
return get<StockTicker[]>('/api/stocks', fetchFn);
return get<StockTicker[]>('/api/widget/stocks', fetchFn);
}
export function getBookmarks(fetchFn?: typeof fetch): Promise<Bookmark[]> {
return get<Bookmark[]>('/api/bookmarks', fetchFn);
return get<Bookmark[]>('/api/widget/bookmarks', fetchFn);
}
export function getPoe2(fetchFn?: typeof fetch): Promise<Poe2Data> {
return get<Poe2Data>('/api/poe2', fetchFn);
return get<Poe2Data>('/api/widget/poe2', fetchFn);
}
export function getWidgetsEnabled(fetchFn?: typeof fetch): Promise<WidgetsEnabled> {
@@ -1,9 +1,10 @@
<script lang="ts">
import type { AdminSettings, AdminPoe2Entry, Poe2BrowseEntry } from '$lib/adminTypes';
import type { AdminPoe2Entry, Poe2BrowseEntry } from '$lib/adminTypes';
import type { Poe2Data } from '$lib/types';
import { browsePoe2Currencies, addPoe2WatchlistEntry, removePoe2WatchlistEntry } from '$lib/adminApi';
import { formatPoeValue, invertChangePercent } from '$lib/format';
let { settings, watchlist: initial }: { settings: AdminSettings; watchlist: AdminPoe2Entry[] } = $props();
let { poe2, watchlist: initial }: { poe2: Poe2Data; watchlist: AdminPoe2Entry[] } = $props();
let watchlist = $state([...initial]);
let showAdd = $state(false);
@@ -79,8 +80,8 @@
<button class="add-btn" onclick={openAdd}>+ Add pair</button>
</div>
<p class="hint" style="margin: -6px 0 12px;">
{#if settings.poe2.leagueName}
Tracking {settings.poe2.leagueName} · change is over the last 24h.
{#if poe2.leagueName}
Tracking {poe2.leagueName} · change is over the last 24h.
{:else}
League not detected yet — check back after the next poll (every hour).
{/if}
@@ -1,12 +1,12 @@
<script lang="ts">
import type { AdminSettings } from '$lib/adminTypes';
import { updateSettings, geocodeLocation } from '$lib/adminApi';
import type { AdminWeatherSettings } from '$lib/adminTypes';
import { updateWeatherConfig, geocodeLocation } from '$lib/adminApi';
import { timeAgo } from '$lib/format';
import SaveStatus from './SaveStatus.svelte';
let { settings }: { settings: AdminSettings } = $props();
let { config }: { config: AdminWeatherSettings } = $props();
let weather = $state({ ...settings.weather });
let weather = $state({ ...config });
let status = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
let saveTimer: ReturnType<typeof setTimeout>;
@@ -20,7 +20,14 @@
clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
try {
await updateSettings({ weather });
weather = await updateWeatherConfig({
locationName: weather.locationName,
latitude: weather.latitude,
longitude: weather.longitude,
unit: weather.unit,
windUnit: weather.windUnit,
pressureUnit: weather.pressureUnit
});
status = 'saved';
setTimeout(() => (status = 'idle'), 1500);
} catch {
@@ -1,6 +1,15 @@
<script lang="ts">
import type { AdminSettings, AdminStockTicker, AdminBookmark, AdminPoe2Entry } from '$lib/adminTypes';
import { updateSettings } from '$lib/adminApi';
import type {
AdminSettings,
AdminStockTicker,
AdminBookmark,
AdminPoe2Entry,
AdminWeatherSettings,
InstalledWidget,
WidgetUploadManifest
} from '$lib/adminTypes';
import type { Poe2Data } from '$lib/types';
import { updateSettings, listWidgets, installWidget, setWidgetEnabled, deleteWidget } from '$lib/adminApi';
import WidgetSection from './WidgetSection.svelte';
import WeatherTab from './WeatherTab.svelte';
import StocksTab from './StocksTab.svelte';
@@ -11,12 +20,18 @@
settings,
stockTickers,
bookmarks,
poe2Watchlist
poe2Watchlist,
weatherConfig,
poe2,
installedWidgets
}: {
settings: AdminSettings;
stockTickers: AdminStockTicker[];
bookmarks: AdminBookmark[];
poe2Watchlist: AdminPoe2Entry[];
weatherConfig: AdminWeatherSettings;
poe2: Poe2Data;
installedWidgets: InstalledWidget[];
} = $props();
// Local copies so each toggle/reorder reflects immediately — same idiom as
@@ -44,6 +59,68 @@
widgetOrder = arr;
await updateSettings({ widgetOrder });
}
// --- Pluggable (uploaded) widgets — see backend/src/widgets/install.ts, uninstall.ts.
// Built-ins never appear here (source: 'builtin'); the delete route rejects them anyway.
let pluggable = $state(installedWidgets.filter((w) => w.source === 'uploaded'));
let showUpload = $state(false);
let uploadId = $state('');
let uploadName = $state('');
let uploadVersion = $state('1.0.0');
let backendFile = $state<File | null>(null);
let frontendFile = $state<File | null>(null);
let uploading = $state(false);
let uploadError = $state<string | null>(null);
async function refreshPluggable() {
pluggable = (await listWidgets()).filter((w) => w.source === 'uploaded');
}
async function handleUpload() {
if (!uploadId.trim() || !uploadName.trim() || !backendFile) {
uploadError = 'id, display name, and a backend .mjs file are required';
return;
}
uploading = true;
uploadError = null;
try {
const files: Record<string, string> = { 'index.mjs': await backendFile.text() };
const manifest: WidgetUploadManifest = {
id: uploadId.trim(),
displayName: uploadName.trim(),
version: uploadVersion.trim() || '1.0.0',
entry: 'index.mjs'
};
if (frontendFile) {
files['frontend.mjs'] = await frontendFile.text();
manifest.frontendEntry = 'frontend.mjs';
}
await installWidget(manifest, files);
await refreshPluggable();
showUpload = false;
uploadId = '';
uploadName = '';
uploadVersion = '1.0.0';
backendFile = null;
frontendFile = null;
} catch (err) {
uploadError = (err as Error).message;
} finally {
uploading = false;
}
}
async function togglePluggable(w: InstalledWidget) {
const updated = await setWidgetEnabled(w.id, !w.enabled);
pluggable = pluggable.map((x) => (x.id === w.id ? updated : x));
}
async function handleDeletePluggable(id: string) {
if (!confirm('Delete this widget? This removes all of its data and cannot be undone.')) return;
await deleteWidget(id);
pluggable = pluggable.filter((w) => w.id !== id);
}
</script>
{#each widgetOrder as key, i (key)}
@@ -57,13 +134,139 @@
onMoveDown={() => move(i, 1)}
>
{#if key === 'weather'}
<WeatherTab {settings} />
<WeatherTab config={weatherConfig} />
{:else if key === 'stocks'}
<StocksTab tickers={stockTickers} />
{:else if key === 'bookmarks'}
<BookmarksTab {bookmarks} />
{:else if key === 'poe2'}
<Poe2Tab {settings} watchlist={poe2Watchlist} />
<Poe2Tab {poe2} watchlist={poe2Watchlist} />
{/if}
</WidgetSection>
{/each}
<div class="pluggable">
<div class="pluggable-head">
<span class="section-title">Pluggable widgets</span>
<button class="upload-toggle" onclick={() => (showUpload = !showUpload)}>{showUpload ? 'Cancel' : '+ Upload'}</button>
</div>
{#if showUpload}
<div class="upload-form">
<input type="text" placeholder="id (a-z0-9-)" bind:value={uploadId} />
<input type="text" placeholder="Display name" bind:value={uploadName} />
<input type="text" placeholder="Version" bind:value={uploadVersion} />
<label class="file-field">
<span>Backend entry (.mjs, required)</span>
<input type="file" accept=".mjs,.js" onchange={(e) => (backendFile = e.currentTarget.files?.[0] ?? null)} />
</label>
<label class="file-field">
<span>Frontend entry (.mjs, optional)</span>
<input type="file" accept=".mjs,.js" onchange={(e) => (frontendFile = e.currentTarget.files?.[0] ?? null)} />
</label>
{#if uploadError}<p class="hint" style="color: var(--text-danger);">{uploadError}</p>{/if}
<button onclick={handleUpload} disabled={uploading}>{uploading ? 'Uploading…' : 'Install'}</button>
</div>
{/if}
{#if pluggable.length === 0}
<p class="hint">No uploaded widgets installed.</p>
{:else}
<div class="list">
{#each pluggable as w (w.id)}
<div class="row">
<span class="row-name">{w.displayName}</span>
<span class="badge" class:active={w.enabled} onclick={() => togglePluggable(w)} role="button" tabindex="0">
{w.enabled ? 'Active' : 'Disabled'}
</span>
<button class="icon-btn danger" onclick={() => handleDeletePluggable(w.id)} title="Delete"></button>
</div>
{/each}
</div>
{/if}
</div>
<style>
.pluggable {
background: var(--surface-1);
border-radius: 12px;
padding: 14px;
margin-top: 4px;
}
.pluggable-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.section-title {
font-size: 13px;
font-weight: 500;
}
.upload-toggle {
font-size: 12px;
padding: 4px 10px;
}
.upload-form {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--surface-2);
border-radius: var(--radius);
padding: 12px;
margin-bottom: 12px;
}
.file-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: var(--text-muted);
}
.hint {
font-size: 12px;
color: var(--text-muted);
margin: 0;
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: flex;
align-items: center;
gap: 10px;
background: var(--surface-2);
border-radius: var(--radius);
padding: 8px 12px;
}
.row-name {
font-size: 13px;
flex: 1;
min-width: 0;
}
.badge {
font-size: 11px;
padding: 2px 10px;
border-radius: var(--radius);
background: var(--surface-1);
color: var(--text-muted);
cursor: pointer;
white-space: nowrap;
}
.badge.active {
background: var(--bg-accent);
color: var(--text-accent);
}
.icon-btn {
font-size: 12px;
padding: 3px 6px;
background: transparent;
border: none;
color: var(--text-secondary);
}
.icon-btn.danger:hover {
color: var(--text-danger);
}
</style>
@@ -0,0 +1,67 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getBackendUrl } from '$lib/config';
// Renders an uploaded widget's custom UI — a pre-built, framework-agnostic JS bundle
// (not raw Svelte source, which can't be compiled in the browser without shipping a
// compiler) served from /widget-assets/<id>/* and loaded via an ordinary dynamic
// import(), which is why this works live for a widget uploaded after the page's own
// bundle was built — no Fastify route registration is involved at all, unlike the
// widget's own custom *API* routes, which do need a backend restart (see
// widgets/install.ts's notes on that).
let { id, displayName, frontendEntry }: { id: string; displayName: string; frontendEntry: string } = $props();
let container: HTMLDivElement | undefined = $state();
let error = $state<string | null>(null);
let cleanup: (() => void) | void;
onMount(async () => {
try {
const url = `${getBackendUrl()}/widget-assets/${id}/${frontendEntry}`;
// @vite-ignore — the URL is only known at runtime (a widget uploaded after this
// page's own bundle was built), so Vite can't statically analyze this import.
const mod = await import(/* @vite-ignore */ url);
if (typeof mod.default?.mount !== 'function') {
throw new Error('module has no default-exported mount(container, ctx)');
}
if (container) {
cleanup = mod.default.mount(container, { id, displayName, apiBase: getBackendUrl() });
}
} catch (err) {
error = (err as Error).message;
}
});
onDestroy(() => cleanup?.());
</script>
<div class="widget">
{#if error}
<div class="head"><span class="title">{displayName}</span></div>
<p class="empty">Failed to load: {error}</p>
{/if}
<div bind:this={container}></div>
</div>
<style>
.widget {
background: var(--surface-1);
border-radius: 12px;
padding: 14px;
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.title {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.empty {
font-size: 12px;
color: var(--text-muted);
margin: 8px 0 0;
}
</style>
@@ -0,0 +1,124 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getBackendUrl } from '$lib/config';
import type { WidgetReport } from '$lib/types';
// Renders any uploaded widget that has no custom frontend bundle — fetches whatever its
// own poll.run() published via setKv(id, 'report', ...) (see GET /api/widget/:id/report,
// registered once generically on the backend so this works for a widget uploaded after
// the server started, with no restart). Self-contained refresh interval since this
// component isn't wired into +layout.ts's load-based invalidation — matches the layout's
// own 5-minute sidebar refresh cadence.
let { id, displayName }: { id: string; displayName: string } = $props();
let report = $state<WidgetReport | null>(null);
let loaded = $state(false);
let timer: ReturnType<typeof setInterval>;
async function fetchReport() {
try {
const res = await fetch(`${getBackendUrl()}/api/widget/${id}/report`);
if (res.ok) {
const body = (await res.json()) as { data: WidgetReport | null };
report = body.data;
}
} catch {
// Leave the last-known report showing — a stale card beats a blank one.
} finally {
loaded = true;
}
}
onMount(() => {
fetchReport();
timer = setInterval(fetchReport, 5 * 60_000);
});
onDestroy(() => clearInterval(timer));
</script>
<div class="widget">
<div class="head">
<span class="title">{report?.title ?? displayName}</span>
</div>
{#if report?.headline}
<div class="headline">
<span class="value">{report.headline.value}</span>
{#if report.headline.delta}<span class="delta">{report.headline.delta}</span>{/if}
</div>
{/if}
{#if report?.rows && report.rows.length > 0}
<div class="list">
{#each report.rows as row, i (i)}
<div class="row">
<span class="label">{row.label}</span>
<span class="value">{row.value}</span>
</div>
{/each}
</div>
{:else if !report?.headline}
<p class="empty">{loaded ? 'No data yet' : 'Loading…'}</p>
{/if}
</div>
<style>
.widget {
background: var(--surface-1);
border-radius: 12px;
padding: 14px;
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.title {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.headline {
display: flex;
align-items: baseline;
gap: 8px;
margin-top: 8px;
}
.headline .value {
font-size: 18px;
font-weight: 500;
}
.delta {
font-size: 12px;
color: var(--text-secondary);
}
.list {
display: flex;
flex-direction: column;
margin-top: 8px;
}
.row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
padding: 6px 0;
border-top: 0.5px solid var(--border);
}
.row:first-child {
border-top: none;
}
.label {
font-size: 13px;
}
.value {
font-size: 12px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
color: var(--text-secondary);
}
.empty {
font-size: 12px;
color: var(--text-muted);
margin: 8px 0 0;
}
</style>
@@ -5,6 +5,8 @@
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,
@@ -101,6 +103,13 @@
<BookmarksWidget {bookmarks} />
{/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>
+18
View File
@@ -173,6 +173,22 @@ export interface Poe2Data {
entries: Poe2WatchlistEntry[];
}
/** An uploaded (non-core) widget the sidebar renders generically — see GenericWidgetCard.svelte / DynamicWidgetSlot.svelte. */
export interface PluggableWidgetSummary {
id: string;
displayName: string;
/** Relative path under /widget-assets/<id>/ to a custom mount() bundle — null means render the generic report card instead. */
frontendEntry: string | null;
}
/** Generic live-data shape a widget publishes via its own poll — see GET /api/widget/:id/report. */
export interface WidgetReport {
title: string;
headline?: { value: string; delta?: string } | null;
rows?: { label: string; value: string }[];
updatedAt: string | null;
}
/** Per-widget sidebar visibility + display order, admin-set from the consolidated "Widgets" tab. */
export interface WidgetsEnabled {
weather: boolean;
@@ -180,4 +196,6 @@ export interface WidgetsEnabled {
bookmarks: boolean;
poe2: boolean;
order: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
/** Enabled uploaded widgets, in their own priority order — rendered after the 4 built-ins (see Sidebar.svelte). */
pluggable: PluggableWidgetSummary[];
}
@@ -44,7 +44,15 @@
{:else if active === 'events'}
<EventsTab events={data.events} sources={data.sources} />
{:else if active === 'widgets'}
<WidgetsTab settings={data.settings} stockTickers={data.stockTickers} bookmarks={data.bookmarks} poe2Watchlist={data.poe2Watchlist} />
<WidgetsTab
settings={data.settings}
stockTickers={data.stockTickers}
bookmarks={data.bookmarks}
poe2Watchlist={data.poe2Watchlist}
weatherConfig={data.weatherConfig}
poe2={data.poe2}
installedWidgets={data.installedWidgets}
/>
{:else if active === 'connections'}
<ConnectionsTab settings={data.settings} aiStatus={data.aiStatus} telegramStatus={data.telegramStatus} />
{:else if active === 'logs'}
+32 -11
View File
@@ -10,23 +10,30 @@ import {
getLogs,
getStockTickers,
getAdminBookmarks,
getPoe2Watchlist
getPoe2Watchlist,
getWeatherConfig,
listWidgets
} from '$lib/adminApi';
import { getPoe2 } from '$lib/api';
import type { ModelCatalog, AiStatus, TelegramStatus } from '$lib/adminTypes';
const EMPTY_MODELS: ModelCatalog = { embedding: [], image: [], synthesis: [] };
export const load: PageLoad = async ({ fetch }) => {
try {
const [settings, sources, events, logs, stockTickers, bookmarks, poe2Watchlist] = await Promise.all([
getSettings(fetch),
getSources(fetch),
getEvents(fetch),
getLogs({}, fetch),
getStockTickers(fetch),
getAdminBookmarks(fetch),
getPoe2Watchlist(fetch)
]);
const [settings, sources, events, logs, stockTickers, bookmarks, poe2Watchlist, weatherConfig, poe2, installedWidgets] =
await Promise.all([
getSettings(fetch),
getSources(fetch),
getEvents(fetch),
getLogs({}, fetch),
getStockTickers(fetch),
getAdminBookmarks(fetch),
getPoe2Watchlist(fetch),
getWeatherConfig(fetch),
getPoe2(fetch),
listWidgets(fetch)
]);
// The AI service (Ollama) may not be running yet — that shouldn't take down the
// whole settings page, just leave the Models/Connections tabs showing "unreachable".
@@ -42,7 +49,21 @@ export const load: PageLoad = async ({ fetch }) => {
() => ({ credentialsConfigured: false, connected: false, phone: null })
);
return { settings, sources, events, models, aiStatus, telegramStatus, logs, stockTickers, bookmarks, poe2Watchlist };
return {
settings,
sources,
events,
models,
aiStatus,
telegramStatus,
logs,
stockTickers,
bookmarks,
poe2Watchlist,
weatherConfig,
poe2,
installedWidgets
};
} catch (err) {
if ((err as { status?: number }).status === 401) {
throw redirect(302, '/admin/login?redirectTo=/admin/settings');