837aa77bfc
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
48 lines
1.9 KiB
TypeScript
48 lines
1.9 KiB
TypeScript
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}` });
|
|
}
|
|
});
|
|
}
|
|
};
|