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