From cc955e9288c5f146d0d3a6f8b7e46da562116c96 Mon Sep 17 00:00:00 2001
From: SandObserver <260779319+SandObserver@users.noreply.github.com>
Date: Mon, 14 Sep 2026 16:16:01 -0400
Subject: [PATCH 1/2] Document OpenWeather as a weather widget service
Add OpenWeather to the weather service list and settings, name it in the
widgets index, and sync widget copies with the app.
---
public/js/admin-app-form.js | 4 +-
public/js/admin-auth.js | 2 +-
public/js/admin-color-control.js | 2 +-
public/js/admin-list.js | 4 +-
public/js/admin-settings.js | 2 +-
public/js/admin-shared.js | 24 ++++--
public/js/admin-widget-form.js | 4 +-
public/js/admin.js | 14 ++--
public/js/widget-config-form.js | 75 +++++-------------
public/widgets/weather/data.js | 94 ++++++++++++++++++-----
public/widgets/weather/i18n/de.json | 5 ++
public/widgets/weather/i18n/en.json | 5 ++
public/widgets/weather/i18n/es.json | 5 ++
public/widgets/weather/i18n/fa.json | 5 ++
public/widgets/weather/i18n/fr.json | 5 ++
public/widgets/weather/i18n/zh-Hans.json | 5 ++
public/widgets/weather/widget.json | 27 +++++++
src/content/docs/docs/widgets/index.mdx | 2 +-
src/content/docs/docs/widgets/weather.mdx | 12 ++-
19 files changed, 198 insertions(+), 98 deletions(-)
diff --git a/public/js/admin-app-form.js b/public/js/admin-app-form.js
index ec47e5b..dac5ad6 100644
--- a/public/js/admin-app-form.js
+++ b/public/js/admin-app-form.js
@@ -21,10 +21,10 @@ import {
reveal,
setTogDisabled,
swapContent,
-} from '/js/admin-shared.js?v=5cf446fa';
+} from '/js/admin-shared.js?v=fd784739';
import { createListbox } from '/js/listbox.js?v=a67e9c98';
import { MAX_LABELS } from '/js/badge-logic.js?v=b3c8b6c2';
-import { renderColorControl, BADGE_SWATCHES, BADGE_DEFAULT } from '/js/admin-color-control.js?v=47d9d808';
+import { renderColorControl, BADGE_SWATCHES, BADGE_DEFAULT } from '/js/admin-color-control.js?v=837961fa';
import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1';
import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86';
diff --git a/public/js/admin-auth.js b/public/js/admin-auth.js
index 858ecfe..5d083e1 100644
--- a/public/js/admin-auth.js
+++ b/public/js/admin-auth.js
@@ -1,4 +1,4 @@
-import { ag, ap } from '/js/admin-shared.js?v=5cf446fa';
+import { ag, ap } from '/js/admin-shared.js?v=fd784739';
import { t } from '/js/i18n.js?v=1f1ea9c1';
import { pwStrength } from '/js/password-strength.js?v=42f45ac7';
import { el, inp as inpById, qa } from '/js/utils.js?v=70cbc405';
diff --git a/public/js/admin-color-control.js b/public/js/admin-color-control.js
index a8a1f81..7486796 100644
--- a/public/js/admin-color-control.js
+++ b/public/js/admin-color-control.js
@@ -1,4 +1,4 @@
-import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=5cf446fa';
+import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=fd784739';
import { t } from '/js/i18n.js?v=1f1ea9c1';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { qa, q } from '/js/utils.js?v=70cbc405';
diff --git a/public/js/admin-list.js b/public/js/admin-list.js
index 1c77e48..17d0f06 100644
--- a/public/js/admin-list.js
+++ b/public/js/admin-list.js
@@ -15,11 +15,11 @@ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=5a5d655f';
import { snapshotItems } from '/js/admin-save-logic.js?v=4f71ef6c';
import { reorderItems } from '/js/admin-logic.js?v=69e57d35';
import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=85ce4de5';
-import { paintIcon } from '/js/admin-shared.js?v=5cf446fa';
+import { paintIcon } from '/js/admin-shared.js?v=fd784739';
import { clr as rc, el, qa, setUserText } from '/js/utils.js?v=70cbc405';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { t } from '/js/i18n.js?v=1f1ea9c1';
-import { sizeLabel } from '/js/admin-widget-form.js?v=6c778690';
+import { sizeLabel } from '/js/admin-widget-form.js?v=3b1b3546';
import { widgetGlyph } from '/js/widget-glyphs.js?v=12b0a947';
/** @type {{ openModal: (idx: number|null) => void,
diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js
index 8be4557..12634d2 100644
--- a/public/js/admin-settings.js
+++ b/public/js/admin-settings.js
@@ -1,4 +1,4 @@
-import { toast, ag, ap, reveal, swapContent } from '/js/admin-shared.js?v=5cf446fa';
+import { toast, ag, ap, reveal, swapContent } from '/js/admin-shared.js?v=fd784739';
import { pwStrength } from '/js/password-strength.js?v=42f45ac7';
import { t } from '/js/i18n.js?v=1f1ea9c1';
import {
diff --git a/public/js/admin-shared.js b/public/js/admin-shared.js
index b154ca3..004193b 100644
--- a/public/js/admin-shared.js
+++ b/public/js/admin-shared.js
@@ -209,11 +209,21 @@ export const PE_SVG =
@param {{ type?: string, placeholder?: string | (() => string),
onCommit?: (value: string) => void, root?: ParentNode }} [opts] */
export function initInlineEdit(rowId, inputId, { type = 'text', placeholder = '', onCommit, root = document } = {}) {
- const ph = () => (typeof placeholder === 'function' ? placeholder() : placeholder);
const byId = id => (root === document ? el(id) : root.querySelector('#' + CSS.escape(id)));
- const row = byId(rowId);
+ const row = /** @type {HTMLElement} */ (byId(rowId));
const inp = /** @type {HTMLInputElement} */ (byId(inputId));
if (!row || !inp) return;
+ wireInlineEdit(row, inp, { type, placeholder, onCommit });
+}
+
+/** The same row behaviour for elements a caller already holds. `row` needs an id.
+ `fill: false` keeps the input's own value on open. `render` replaces how the
+ committed value is shown.
+ @param {HTMLElement} row @param {HTMLInputElement} inp
+ @param {{ type?: string, placeholder?: string | (() => string), onCommit?: (value: string) => void,
+ fill?: boolean, render?: (valEl: Element, value: string) => void }} [opts] */
+export function wireInlineEdit(row, inp, { type = 'text', placeholder = '', onCommit, fill = true, render } = {}) {
+ const ph = () => (typeof placeholder === 'function' ? placeholder() : placeholder);
const valEl = q('.rv', row);
const pen = q('.pe', row);
if (!valEl || !pen) return;
@@ -227,7 +237,7 @@ export function initInlineEdit(rowId, inputId, { type = 'text', placeholder = ''
name, and several readers drop it once the field holds a value. */
const labelEl = q('.rl', row);
if (labelEl) {
- if (!labelEl.id) labelEl.id = `${rowId}-rl`;
+ if (!labelEl.id) labelEl.id = `${row.id}-rl`;
inp.setAttribute('aria-labelledby', labelEl.id);
/* The pencil opens this row, so it is named after this row. Taking the name
from the label keeps the two in one language, and in step when either
@@ -236,11 +246,13 @@ export function initInlineEdit(rowId, inputId, { type = 'text', placeholder = ''
}
row.insertBefore(inp, pen);
+ let before = '';
function open() {
if (row.classList.contains('editing')) return;
row.classList.add('editing');
inp.placeholder = ph();
- inp.value = valEl.classList.contains('is-ph') ? '' : valEl.textContent;
+ if (fill) inp.value = valEl.classList.contains('is-ph') ? '' : valEl.textContent;
+ before = inp.value;
inp.focus();
inp.select?.();
}
@@ -248,7 +260,8 @@ export function initInlineEdit(rowId, inputId, { type = 'text', placeholder = ''
if (!row.classList.contains('editing')) return;
row.classList.remove('editing');
const v = inp.value.trim();
- if (v) {
+ if (render) render(valEl, v);
+ else if (v) {
valEl.textContent = v;
valEl.classList.remove('is-ph');
} else {
@@ -270,6 +283,7 @@ export function initInlineEdit(rowId, inputId, { type = 'text', placeholder = ''
}
if (e.key === 'Escape') {
e.preventDefault();
+ inp.value = before;
row.classList.remove('editing');
}
},
diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js
index 4104a34..91e2b4e 100644
--- a/public/js/admin-widget-form.js
+++ b/public/js/admin-widget-form.js
@@ -1,7 +1,7 @@
import { state } from '/js/admin-state.js?v=5a5d655f';
-import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=5cf446fa';
+import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=fd784739';
import { createListbox } from '/js/listbox.js?v=a67e9c98';
-import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=785f41f6';
+import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=ad9e05ee';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=69e57d35';
import { t } from '/js/i18n.js?v=1f1ea9c1';
diff --git a/public/js/admin.js b/public/js/admin.js
index b36cd83..42c4d87 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -1,6 +1,6 @@
-import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=0ed92d16';
-import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=8f4673da';
-import { initList, render, syncFilterUI } from '/js/admin-list.js?v=d0669049';
+import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=a53ac0e8';
+import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=c70fb722';
+import { initList, render, syncFilterUI } from '/js/admin-list.js?v=b0923a32';
import { resolveAdminSection } from '/js/admin-logic.js?v=69e57d35';
import {
buildAppItem,
@@ -10,10 +10,10 @@ import {
snapshotItems,
upsertItem,
} from '/js/admin-save-logic.js?v=4f71ef6c';
-import { loadSettings, settingsDirty, showBgFields, showWallpaperFile } from '/js/admin-settings.js?v=81778efd';
-import { ag, ap, initInlineEdit, paintIcon, reveal, setReauthHandler, toast } from '/js/admin-shared.js?v=5cf446fa';
+import { loadSettings, settingsDirty, showBgFields, showWallpaperFile } from '/js/admin-settings.js?v=079a3d2d';
+import { ag, ap, initInlineEdit, paintIcon, reveal, setReauthHandler, toast } from '/js/admin-shared.js?v=fd784739';
import { collapsedFolders, filter, state } from '/js/admin-state.js?v=5a5d655f';
-import { buildWidgetForm } from '/js/admin-widget-form.js?v=6c778690';
+import { buildWidgetForm } from '/js/admin-widget-form.js?v=3b1b3546';
import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86';
import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0';
import { createListbox } from '/js/listbox.js?v=a67e9c98';
@@ -33,7 +33,7 @@ import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607';
import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=11fa1eff';
import { readMode, watchSystemTheme, writeMode } from '/js/theme.js?v=787bfdff';
import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=70cbc405';
-import { normalizeColorInput } from '/js/admin-color-control.js?v=47d9d808';
+import { normalizeColorInput } from '/js/admin-color-control.js?v=837961fa';
import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=6ebb564c';
import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6';
diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js
index 96a5d2c..8e99c9e 100644
--- a/public/js/widget-config-form.js
+++ b/public/js/widget-config-form.js
@@ -3,9 +3,9 @@
import { t } from '/js/i18n.js?v=1f1ea9c1';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
-import { reveal } from '/js/admin-shared.js?v=5cf446fa';
+import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=fd784739';
import { createListbox } from '/js/listbox.js?v=a67e9c98';
-import { renderColorControl } from '/js/admin-color-control.js?v=47d9d808';
+import { renderColorControl } from '/js/admin-color-control.js?v=837961fa';
import {
seedCarried,
applyOptionSet,
@@ -36,40 +36,17 @@ function _ieRow(field, value, inputType) {
const ph = field.placeholder || '';
const row = document.createElement('div');
row.className = 'row ie-row';
+ row.id = uniqueId('wcf-ie');
setHtml(
row,
html`${field.label}${_tag(field)}${has ? value : ph}`,
);
- const rv = row.querySelector('.rv'),
- inp = qi('.row-inp', row),
- pe = row.querySelector('.pe');
- function open() {
- row.classList.add('editing');
- inp.style.display = 'block';
- inp.focus();
- inp.select?.();
- }
- function commit() {
- row.classList.remove('editing');
- inp.style.display = 'none';
- const v = inp.value.trim();
- if (v) {
- rv.textContent = v;
- rv.classList.remove('is-ph');
- } else {
- rv.textContent = ph;
- rv.classList.add('is-ph');
- }
- inp.dispatchEvent(new Event('change', { bubbles: true }));
- }
- pe.addEventListener('click', open);
- rv.addEventListener('click', open);
- inp.addEventListener('blur', commit);
- inp.addEventListener('keydown', e => {
- if (e.key === 'Enter') {
- e.preventDefault();
- inp.blur();
- }
+ const inp = qi('.row-inp', row);
+ wireInlineEdit(row, inp, {
+ type: inputType,
+ placeholder: ph,
+ fill: false,
+ onCommit: () => inp.dispatchEvent(new Event('change', { bubbles: true })),
});
const get = () => {
const v = inp.value.trim();
@@ -96,33 +73,21 @@ function _ieRow(field, value, inputType) {
function _secret(field, isSet) {
const row = document.createElement('div');
row.className = 'row ie-row';
+ row.id = uniqueId('wcf-secret');
const display = isSet ? t('common.configured') : t('common.notSet');
setHtml(
row,
- html`${field.label}${_tag(field)}${display}`,
+ html`${field.label}${_tag(field)}${display}`,
);
- const rv = row.querySelector('.rv'),
- inp = qi('.row-inp', row),
- pe = row.querySelector('.pe');
- const open = () => {
- row.classList.add('editing');
- inp.style.display = 'block';
- inp.focus();
- };
- const commit = () => {
- row.classList.remove('editing');
- inp.style.display = 'none';
- rv.textContent = inp.value ? t('widgetCfg.newValueSet') : display;
- inp.dispatchEvent(new Event('change', { bubbles: true }));
- };
- pe.addEventListener('click', open);
- rv.addEventListener('click', open);
- inp.addEventListener('blur', commit);
- inp.addEventListener('keydown', e => {
- if (e.key === 'Enter') {
- e.preventDefault();
- inp.blur();
- }
+ const inp = qi('.row-inp', row);
+ wireInlineEdit(row, inp, {
+ type: 'password',
+ placeholder: isSet ? t('widgetCfg.replaceSecret') : field.placeholder || '',
+ fill: false,
+ render: (valEl, v) => {
+ valEl.textContent = v ? t('widgetCfg.newValueSet') : display;
+ },
+ onCommit: () => inp.dispatchEvent(new Event('change', { bubbles: true })),
});
const get = () => {
const v = inp.value.trim();
diff --git a/public/widgets/weather/data.js b/public/widgets/weather/data.js
index 4e76f37..89b40ef 100644
--- a/public/widgets/weather/data.js
+++ b/public/widgets/weather/data.js
@@ -1,5 +1,6 @@
-/* Open-Meteo, keyless. Returns { temp, units, code, isDay, condition, city },
- where code is the WMO weather code. */
+/* Open-Meteo (keyless) or OpenWeather (free API key). Returns
+ { temp, units, code, isDay, usedFeels, city }, where code is the WMO weather
+ code for both providers. The city search always uses Open-Meteo's geocoder. */
async function geocode(ctx) {
const q = String(ctx.config.cityQuery || ctx.config.city || '').trim();
@@ -16,23 +17,15 @@ async function geocode(ctx) {
return { options };
}
-module.exports = async function (ctx) {
- const { config, fetchJSON } = ctx;
- if (ctx.endpoint === 'geocode') return await geocode(ctx);
- const lat = config.lat,
- lon = config.lon;
- if (lat == null || lon == null || lat === '' || lon === '') ctx.fail('Location not set', { kind: ctx.KIND.INVALID });
- const units = config.units === 'f' ? 'f' : 'c';
- const tempUnit = units === 'f' ? 'fahrenheit' : 'celsius';
-
+async function openMeteo(ctx, lat, lon, units) {
const url =
'https://api.open-meteo.com/v1/forecast' +
`?latitude=${encodeURIComponent(lat)}&longitude=${encodeURIComponent(lon)}` +
'¤t=temperature_2m,apparent_temperature,weather_code,is_day' +
- `&daily=sunrise,sunset&temperature_unit=${tempUnit}` +
+ `&daily=sunrise,sunset&temperature_unit=${units === 'f' ? 'fahrenheit' : 'celsius'}` +
'&timezone=auto&forecast_days=1';
- const r = await fetchJSON(url, { timeout: 8000 });
+ const r = await ctx.fetchJSON(url, { timeout: 8000 });
if (r.status >= 400 || !r.data || !r.data.current) {
ctx.fail('Weather unavailable (' + r.status + ')');
}
@@ -45,18 +38,79 @@ module.exports = async function (ctx) {
const ss = new Date(r.data.daily.sunset[0]).getTime();
isDay = now >= sr && now < ss;
}
-
- const useFeels = config.feelsLike === true || config.feelsLike === 'true';
const real = cur.temperature_2m;
- const feels = cur.apparent_temperature != null ? cur.apparent_temperature : real;
- const shown = useFeels ? feels : real;
+ return {
+ real,
+ feels: cur.apparent_temperature != null ? cur.apparent_temperature : real,
+ code: cur.weather_code,
+ isDay,
+ };
+}
+/* OpenWeather condition id to the WMO code the page renders. */
+function wmoFromOpenWeather(id) {
+ if (id >= 200 && id < 300) return 95;
+ if (id >= 300 && id < 400) return 51;
+ if (id === 511) return 66;
+ if (id >= 520 && id < 600) return 80;
+ if (id >= 500 && id < 600) return 61;
+ if (id >= 620 && id < 700) return 85;
+ if (id >= 600 && id < 700) return 71;
+ if (id >= 700 && id < 800) return 45;
+ if (id === 800) return 0;
+ if (id === 801) return 1;
+ if (id === 802) return 2;
+ return 3;
+}
+
+async function openWeather(ctx, lat, lon, units) {
+ const key = String(ctx.config.owKey || '').trim();
+ if (!key) ctx.fail('Enter an OpenWeather API key.', { kind: ctx.KIND.INVALID });
+ const url =
+ 'https://api.openweathermap.org/data/2.5/weather' +
+ `?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}` +
+ `&units=${units === 'f' ? 'imperial' : 'metric'}&appid=${encodeURIComponent(key)}`;
+
+ const r = await ctx.fetchJSON(url, { timeout: 8000 });
+ if (r.status === 401) {
+ ctx.fail('OpenWeather rejected the API key. A new key can take a few hours to activate.', { kind: ctx.KIND.AUTH });
+ }
+ const d = r.data;
+ if (r.status >= 400 || !d || !d.main || !Array.isArray(d.weather) || !d.weather[0]) {
+ ctx.fail('Weather unavailable (' + r.status + ')');
+ }
+
+ const w = d.weather[0];
+ let isDay;
+ if (typeof w.icon === 'string' && /[dn]$/.test(w.icon)) isDay = w.icon.endsWith('d');
+ else isDay = d.sys && d.dt ? d.dt >= d.sys.sunrise && d.dt < d.sys.sunset : true;
+ const real = d.main.temp;
+ return {
+ real,
+ feels: d.main.feels_like != null ? d.main.feels_like : real,
+ code: wmoFromOpenWeather(w.id),
+ isDay,
+ };
+}
+
+module.exports = async function (ctx) {
+ const { config } = ctx;
+ if (ctx.endpoint === 'geocode') return await geocode(ctx);
+ const lat = config.lat,
+ lon = config.lon;
+ if (lat == null || lon == null || lat === '' || lon === '') ctx.fail('Location not set', { kind: ctx.KIND.INVALID });
+ const units = config.units === 'f' ? 'f' : 'c';
+
+ const read = config.provider === 'openweather' ? openWeather : openMeteo;
+ const w = await read(ctx, lat, lon, units);
+
+ const useFeels = config.feelsLike === true || config.feelsLike === 'true';
return {
- temp: Math.round(shown),
+ temp: Math.round(useFeels ? w.feels : w.real),
usedFeels: useFeels,
units: units,
- code: cur.weather_code,
- isDay: isDay,
+ code: w.code,
+ isDay: w.isDay,
city: config.city || '',
};
};
diff --git a/public/widgets/weather/i18n/de.json b/public/widgets/weather/i18n/de.json
index fe53eff..1dcfe56 100644
--- a/public/widgets/weather/i18n/de.json
+++ b/public/widgets/weather/i18n/de.json
@@ -1,5 +1,10 @@
{
"widget.label": "Wetter",
+ "provider.label": "Dienst",
+ "provider.opt.openmeteo": "Open-Meteo",
+ "provider.opt.openweather": "OpenWeather",
+ "owKey.label": "API-Schlüssel",
+ "owKey.hint": "Kostenloser Schlüssel von openweathermap.org. Ein neuer Schlüssel kann einige Stunden bis zur Aktivierung brauchen.",
"cityQuery.label": "Suche",
"cityQuery.placeholder": "z. B. Ottawa",
"city.label": "Ort",
diff --git a/public/widgets/weather/i18n/en.json b/public/widgets/weather/i18n/en.json
index 49b9170..bfe9c6c 100644
--- a/public/widgets/weather/i18n/en.json
+++ b/public/widgets/weather/i18n/en.json
@@ -1,5 +1,10 @@
{
"widget.label": "Weather",
+ "provider.label": "Service",
+ "provider.opt.openmeteo": "Open-Meteo",
+ "provider.opt.openweather": "OpenWeather",
+ "owKey.label": "API key",
+ "owKey.hint": "Free key from openweathermap.org. A new key can take a few hours to activate.",
"cityQuery.label": "Search",
"cityQuery.placeholder": "e.g. Ottawa",
"city.label": "Location",
diff --git a/public/widgets/weather/i18n/es.json b/public/widgets/weather/i18n/es.json
index c232911..84e1319 100644
--- a/public/widgets/weather/i18n/es.json
+++ b/public/widgets/weather/i18n/es.json
@@ -1,5 +1,10 @@
{
"widget.label": "Tiempo",
+ "provider.label": "Servicio",
+ "provider.opt.openmeteo": "Open-Meteo",
+ "provider.opt.openweather": "OpenWeather",
+ "owKey.label": "Clave de API",
+ "owKey.hint": "Clave gratuita de openweathermap.org. Una clave nueva puede tardar unas horas en activarse.",
"cityQuery.label": "Buscar",
"cityQuery.placeholder": "p. ej. Ottawa",
"city.label": "Ubicación",
diff --git a/public/widgets/weather/i18n/fa.json b/public/widgets/weather/i18n/fa.json
index 72a58ca..7a577d9 100644
--- a/public/widgets/weather/i18n/fa.json
+++ b/public/widgets/weather/i18n/fa.json
@@ -1,5 +1,10 @@
{
"widget.label": "آبوهوا",
+ "provider.label": "سرویس",
+ "provider.opt.openmeteo": "Open-Meteo",
+ "provider.opt.openweather": "OpenWeather",
+ "owKey.label": "کلید API",
+ "owKey.hint": "کلید رایگان را از openweathermap.org بگیرید. فعال شدن کلید تازه ممکن است چند ساعت طول بکشد.",
"cityQuery.label": "جستجو",
"cityQuery.placeholder": "مثلاً تهران",
"city.label": "موقعیت",
diff --git a/public/widgets/weather/i18n/fr.json b/public/widgets/weather/i18n/fr.json
index e1be307..25c5991 100644
--- a/public/widgets/weather/i18n/fr.json
+++ b/public/widgets/weather/i18n/fr.json
@@ -1,5 +1,10 @@
{
"widget.label": "Météo",
+ "provider.label": "Service",
+ "provider.opt.openmeteo": "Open-Meteo",
+ "provider.opt.openweather": "OpenWeather",
+ "owKey.label": "Clé d’API",
+ "owKey.hint": "Clé gratuite sur openweathermap.org. Une nouvelle clé peut mettre quelques heures à s’activer.",
"cityQuery.label": "Rechercher",
"cityQuery.placeholder": "ex. Ottawa",
"city.label": "Lieu",
diff --git a/public/widgets/weather/i18n/zh-Hans.json b/public/widgets/weather/i18n/zh-Hans.json
index f594503..ca68e60 100644
--- a/public/widgets/weather/i18n/zh-Hans.json
+++ b/public/widgets/weather/i18n/zh-Hans.json
@@ -1,5 +1,10 @@
{
"widget.label": "天气",
+ "provider.label": "服务",
+ "provider.opt.openmeteo": "Open-Meteo",
+ "provider.opt.openweather": "OpenWeather",
+ "owKey.label": "API 密钥",
+ "owKey.hint": "在 openweathermap.org 免费获取密钥。新密钥可能需要几个小时才能生效。",
"cityQuery.label": "搜索",
"cityQuery.placeholder": "例如 Ottawa",
"city.label": "位置",
diff --git a/public/widgets/weather/widget.json b/public/widgets/weather/widget.json
index 3b78017..7911049 100644
--- a/public/widgets/weather/widget.json
+++ b/public/widgets/weather/widget.json
@@ -6,6 +6,33 @@
"small"
],
"fields": [
+ {
+ "key": "provider",
+ "type": "select",
+ "variant": "pills",
+ "label": "provider.label",
+ "default": "openmeteo",
+ "options": [
+ {
+ "value": "openmeteo",
+ "label": "provider.opt.openmeteo"
+ },
+ {
+ "value": "openweather",
+ "label": "provider.opt.openweather"
+ }
+ ]
+ },
+ {
+ "key": "owKey",
+ "type": "secret",
+ "label": "owKey.label",
+ "hint": "owKey.hint",
+ "showIf": {
+ "field": "provider",
+ "equals": "openweather"
+ }
+ },
{
"key": "cityQuery",
"type": "text",
diff --git a/src/content/docs/docs/widgets/index.mdx b/src/content/docs/docs/widgets/index.mdx
index c8f2b0b..2b7595c 100644
--- a/src/content/docs/docs/widgets/index.mdx
+++ b/src/content/docs/docs/widgets/index.mdx
@@ -33,7 +33,7 @@ Widgets reorder in the same list as apps and folders. See [Apps and folders](/do
{ href: '/docs/widgets/github/', name: 'GitHub', text: 'Pull requests or a contribution graph.', value: 'GitHub' },
{ href: '/docs/widgets/now-playing/', name: 'Now Playing', text: 'What is playing, with progress.', value: 'Plex, Jellyfin, Emby, Navidrome' },
{ href: '/docs/widgets/system-summary/', name: 'System summary', text: 'CPU, memory, disk, temperature and network.', value: 'This machine, Glances, Beszel, Unraid' },
- { href: '/docs/widgets/weather/', name: 'Weather', text: 'Current conditions for one place.', value: 'Open-Meteo' },
+ { href: '/docs/widgets/weather/', name: 'Weather', text: 'Current conditions for one place.', value: 'Open-Meteo, OpenWeather' },
]} />
## Credentials
diff --git a/src/content/docs/docs/widgets/weather.mdx b/src/content/docs/docs/widgets/weather.mdx
index 1d1571e..6188141 100644
--- a/src/content/docs/docs/widgets/weather.mdx
+++ b/src/content/docs/docs/widgets/weather.mdx
@@ -1,6 +1,6 @@
---
title: Weather widget
-description: Current conditions for any city from Open-Meteo, with no account or API key, on your Stackyard dashboard.
+description: Current conditions for any city from Open-Meteo, with no account, or from OpenWeather with a free API key, on your Stackyard dashboard.
---
import WidgetGroup from '../../../../components/WidgetGroup.astro';
@@ -29,9 +29,19 @@ Small only.
{ label: 'open-meteo.com', href: 'https://open-meteo.com/' },
],
},
+ {
+ name: 'OpenWeather',
+ kind: 'Weather API',
+ needs: 'API key',
+ fields: [['API key', 'Free from openweathermap.org. A new key can take a few hours to activate. Stored as a secret.']],
+ notes: ['Location search still uses Open-Meteo, so it needs no key.'],
+ links: [{ label: 'openweathermap.org', href: 'https://openweathermap.org/' }],
+ },
]} />
Date: Mon, 14 Sep 2026 16:17:56 -0400
Subject: [PATCH 2/2] Add OpenWeather service logo
---
public/icons/services/openweather-dark.svg | 1 +
public/icons/services/openweather-light.svg | 1 +
src/content/docs/docs/widgets/weather.mdx | 2 +-
3 files changed, 3 insertions(+), 1 deletion(-)
create mode 100644 public/icons/services/openweather-dark.svg
create mode 100644 public/icons/services/openweather-light.svg
diff --git a/public/icons/services/openweather-dark.svg b/public/icons/services/openweather-dark.svg
new file mode 100644
index 0000000..d5aeeda
--- /dev/null
+++ b/public/icons/services/openweather-dark.svg
@@ -0,0 +1 @@
+
diff --git a/public/icons/services/openweather-light.svg b/public/icons/services/openweather-light.svg
new file mode 100644
index 0000000..02346fb
--- /dev/null
+++ b/public/icons/services/openweather-light.svg
@@ -0,0 +1 @@
+
diff --git a/src/content/docs/docs/widgets/weather.mdx b/src/content/docs/docs/widgets/weather.mdx
index 6188141..f9e131c 100644
--- a/src/content/docs/docs/widgets/weather.mdx
+++ b/src/content/docs/docs/widgets/weather.mdx
@@ -30,7 +30,7 @@ Small only.
],
},
{
- name: 'OpenWeather',
+ name: 'OpenWeather', icon: 'openweather',
kind: 'Weather API',
needs: 'API key',
fields: [['API key', 'Free from openweathermap.org. A new key can take a few hours to activate. Stored as a secret.']],