Files
homefeed/frontend/src/lib/components/admin/WeatherTab.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

253 lines
5.8 KiB
Svelte

<script lang="ts">
import type { AdminWeatherSettings } from '$lib/adminTypes';
import { updateWeatherConfig, geocodeLocation } from '$lib/adminApi';
import { timeAgo } from '$lib/format';
import SaveStatus from './SaveStatus.svelte';
let { config }: { config: AdminWeatherSettings } = $props();
let weather = $state({ ...config });
let status = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
let saveTimer: ReturnType<typeof setTimeout>;
let query = $state('');
let searching = $state(false);
let searchError = $state<string | null>(null);
let results = $state<Awaited<ReturnType<typeof geocodeLocation>>>([]);
function scheduleSave() {
status = 'saving';
clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
try {
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 {
status = 'error';
}
}, 500);
}
async function handleSearch() {
if (!query.trim()) return;
searching = true;
searchError = null;
try {
results = await geocodeLocation(query.trim());
if (results.length === 0) searchError = 'No matches found';
} catch {
searchError = 'Geocoding service unreachable';
} finally {
searching = false;
}
}
function selectResult(r: (typeof results)[number]) {
weather.locationName = r.admin1 ? `${r.name}, ${r.admin1}` : r.name;
weather.latitude = r.latitude;
weather.longitude = r.longitude;
results = [];
query = '';
scheduleSave();
}
const units: { label: string; value: 'celsius' | 'fahrenheit' }[] = [
{ label: '°F', value: 'fahrenheit' },
{ label: '°C', value: 'celsius' }
];
const windUnits: { label: string; value: 'mph' | 'kph' }[] = [
{ label: 'mph', value: 'mph' },
{ label: 'kph', value: 'kph' }
];
const pressureUnits: { label: string; value: 'inHg' | 'hPa' }[] = [
{ label: 'inHg', value: 'inHg' },
{ label: 'hPa', value: 'hPa' }
];
</script>
<div class="panel">
<div class="head">
<span class="panel-title">Location</span>
<SaveStatus {status} />
</div>
<p class="hint">Powers the sidebar weather widget and the /weather page — searched via Open-Meteo's free geocoding lookup.</p>
<div class="search-row">
<input
type="text"
bind:value={query}
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="City name, e.g. Chicago"
/>
<button onclick={handleSearch} disabled={searching}>{searching ? 'Searching…' : 'Search'}</button>
</div>
{#if searchError}
<p class="hint" style="color: var(--text-danger);">{searchError}</p>
{/if}
{#if results.length > 0}
<div class="results">
{#each results as r}
<button class="result-row" onclick={() => selectResult(r)}>
{r.name}{r.admin1 ? `, ${r.admin1}` : ''}{r.country ? ` ${r.country}` : ''}
</button>
{/each}
</div>
{/if}
<div class="current-row">
<span class="usage-label">
{#if weather.locationName}
Configured: {weather.locationName}
{:else}
No location configured yet
{/if}
</span>
</div>
<div class="field-label">Temperature</div>
<div class="pill-row">
{#each units as unit}
<button
class="pill"
class:active={weather.unit === unit.value}
onclick={() => {
weather.unit = unit.value;
scheduleSave();
}}
>
{unit.label}
</button>
{/each}
</div>
<div class="field-label">Wind speed</div>
<div class="pill-row">
{#each windUnits as unit}
<button
class="pill"
class:active={weather.windUnit === unit.value}
onclick={() => {
weather.windUnit = unit.value;
scheduleSave();
}}
>
{unit.label}
</button>
{/each}
</div>
<div class="field-label">Pressure</div>
<div class="pill-row">
{#each pressureUnits as unit}
<button
class="pill"
class:active={weather.pressureUnit === unit.value}
onclick={() => {
weather.pressureUnit = unit.value;
scheduleSave();
}}
>
{unit.label}
</button>
{/each}
</div>
<p class="hint" style="margin-top: 14px; margin-bottom: 0;">
{#if weather.current}
Currently showing: {Math.round(weather.current.temp)}° (feels like {Math.round(weather.current.feelsLike)}°) ·
{weather.current.conditionText} (updated {timeAgo(weather.updatedAt ?? '')})
{:else}
Not showing any data yet — configure a location above, it polls immediately.
{/if}
</p>
</div>
<style>
.panel {
background: var(--surface-1);
border-radius: 12px;
padding: 16px;
margin-bottom: 14px;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.panel-title {
font-size: 13px;
font-weight: 500;
}
.hint {
font-size: 12px;
color: var(--text-secondary);
margin: 4px 0 12px;
}
.search-row {
display: flex;
gap: 8px;
}
.search-row input {
flex: 1;
}
.results {
display: flex;
flex-direction: column;
margin-top: 8px;
border-radius: var(--radius);
overflow: hidden;
border: 0.5px solid var(--border);
}
.result-row {
text-align: left;
font-size: 12px;
padding: 8px 10px;
background: var(--surface-2);
border: none;
border-radius: 0;
}
.result-row:hover {
background: var(--bg-accent);
}
.current-row {
margin-top: 12px;
}
.usage-label {
font-size: 12px;
color: var(--text-muted);
}
.field-label {
font-size: 11px;
color: var(--text-muted);
margin: 12px 0 6px;
}
.pill-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.pill {
font-size: 12px;
padding: 6px 12px;
border-radius: var(--radius);
border: 0.5px solid var(--border);
background: var(--surface-2);
color: var(--text-secondary);
}
.pill.active {
background: var(--pill-bg);
color: var(--pill-text);
border-color: var(--pill-bg);
}
</style>