Files
homefeed/frontend/src/lib/components/sidebar/GenericWidgetCard.svelte
T
Claude 837aa77bfc 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
2026-07-27 01:16:47 +00:00

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>