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
125 lines
2.9 KiB
Svelte
125 lines
2.9 KiB
Svelte
<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>
|