feat: add weather source entity and weather-reactive CSS source type
Some checks failed
Lint & Test / test (push) Failing after 34s
Some checks failed
Lint & Test / test (push) Failing after 34s
New standalone WeatherSource entity with pluggable provider architecture (Open-Meteo v1, free, no API key). Full CRUD, test endpoint, browser geolocation, IconSelect provider picker, CardSection with test/clone/edit. WeatherColorStripStream maps WMO weather codes to ambient color palettes with temperature hue shifting and thunderstorm flash effects. Ref-counted WeatherManager polls API and caches data per source. CSS editor integration: weather type with EntitySelect source picker, speed and temperature influence sliders. Backup/restore support. i18n for en/ru/zh.
This commit is contained in:
@@ -26,6 +26,7 @@ const _colorStripTypeIcons = {
|
||||
notification: _svg(P.bellRing),
|
||||
daylight: _svg(P.sun),
|
||||
candlelight: _svg(P.flame),
|
||||
weather: _svg(P.cloudSun),
|
||||
processed: _svg(P.sparkles),
|
||||
};
|
||||
const _valueSourceTypeIcons = {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { DataCache } from './cache.ts';
|
||||
import type {
|
||||
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
||||
ValueSource, AudioSource, PictureSource, ScenePreset,
|
||||
SyncClock, Automation, Display, FilterDef, EngineInfo,
|
||||
SyncClock, WeatherSource, Automation, Display, FilterDef, EngineInfo,
|
||||
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
||||
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
||||
} from '../types.ts';
|
||||
@@ -225,6 +225,7 @@ export let _cachedValueSources: ValueSource[] = [];
|
||||
|
||||
// Sync clocks
|
||||
export let _cachedSyncClocks: SyncClock[] = [];
|
||||
export let _cachedWeatherSources: WeatherSource[] = [];
|
||||
|
||||
// Automations
|
||||
export let _automationsCache: Automation[] | null = null;
|
||||
@@ -282,6 +283,12 @@ export const syncClocksCache = new DataCache<SyncClock[]>({
|
||||
});
|
||||
syncClocksCache.subscribe(v => { _cachedSyncClocks = v; });
|
||||
|
||||
export const weatherSourcesCache = new DataCache<WeatherSource[]>({
|
||||
endpoint: '/weather-sources',
|
||||
extractData: json => json.sources || [],
|
||||
});
|
||||
weatherSourcesCache.subscribe(v => { _cachedWeatherSources = v; });
|
||||
|
||||
export const filtersCache = new DataCache<FilterDef[]>({
|
||||
endpoint: '/filters',
|
||||
extractData: json => json.filters || [],
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { _cachedSyncClocks, _cachedCSPTemplates, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, GradientEntity } from '../core/state.ts';
|
||||
import { _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, weatherSourcesCache, GradientEntity } from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
@@ -127,7 +127,7 @@ let _processedTemplateEntitySelect: any = null;
|
||||
const CSS_TYPE_KEYS = [
|
||||
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
|
||||
'effect', 'composite', 'mapped', 'audio',
|
||||
'api_input', 'notification', 'daylight', 'candlelight', 'processed',
|
||||
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed',
|
||||
];
|
||||
|
||||
function _buildCSSTypeItems() {
|
||||
@@ -172,6 +172,7 @@ const CSS_SECTION_MAP: Record<string, string> = {
|
||||
'notification': 'css-editor-notification-section',
|
||||
'daylight': 'css-editor-daylight-section',
|
||||
'candlelight': 'css-editor-candlelight-section',
|
||||
'weather': 'css-editor-weather-section',
|
||||
'processed': 'css-editor-processed-section',
|
||||
};
|
||||
|
||||
@@ -184,6 +185,7 @@ const CSS_TYPE_SETUP: Record<string, () => void> = {
|
||||
gradient: () => { _ensureGradientPresetIconSelect(); _ensureGradientEasingIconSelect(); requestAnimationFrame(() => gradientRenderAll()); },
|
||||
notification: () => { ensureNotificationEffectIconSelect(); ensureNotificationFilterModeIconSelect(); },
|
||||
candlelight: () => _ensureCandleTypeIconSelect(),
|
||||
weather: () => { weatherSourcesCache.fetch().then(() => _populateWeatherSourceDropdown()); },
|
||||
composite: () => compositeRenderList(),
|
||||
mapped: () => _mappedRenderList(),
|
||||
};
|
||||
@@ -238,7 +240,7 @@ export function onCSSTypeChange() {
|
||||
hasLedCount.includes(type) ? '' : 'none';
|
||||
|
||||
// Sync clock — shown for animated types
|
||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight'];
|
||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather'];
|
||||
(document.getElementById('css-editor-clock-group') as HTMLElement).style.display = clockTypes.includes(type) ? '' : 'none';
|
||||
if (clockTypes.includes(type)) _populateClockDropdown();
|
||||
|
||||
@@ -272,6 +274,29 @@ export function onCSSClockChange() {
|
||||
// No-op: speed sliders removed; speed is now clock-only
|
||||
}
|
||||
|
||||
let _weatherSourceEntitySelect: any = null;
|
||||
|
||||
function _populateWeatherSourceDropdown() {
|
||||
const sources = _cachedWeatherSources || [];
|
||||
const sel = document.getElementById('css-editor-weather-source') as HTMLSelectElement;
|
||||
const prev = sel.value;
|
||||
sel.innerHTML = `<option value="">\u2014</option>` +
|
||||
sources.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
|
||||
sel.value = prev || '';
|
||||
if (_weatherSourceEntitySelect) _weatherSourceEntitySelect.destroy();
|
||||
if (sources.length > 0) {
|
||||
_weatherSourceEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => (_cachedWeatherSources || []).map(s => ({
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
icon: getColorStripIcon('weather'),
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _populateProcessedSelectors() {
|
||||
const editingId = (document.getElementById('css-editor-id') as HTMLInputElement).value;
|
||||
const allSources = (colorStripSourcesCache.data || []) as any[];
|
||||
@@ -927,7 +952,7 @@ type CardPropsRenderer = (source: ColorStripSource, opts: {
|
||||
|
||||
const NON_PICTURE_TYPES = new Set([
|
||||
'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped',
|
||||
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'processed',
|
||||
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed',
|
||||
]);
|
||||
|
||||
const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||
@@ -1055,6 +1080,17 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||
${clockBadge}
|
||||
`;
|
||||
},
|
||||
weather: (source, { clockBadge }) => {
|
||||
const speedVal = (source.speed ?? 1.0).toFixed(1);
|
||||
const tempInfl = (source.temperature_influence ?? 0.5).toFixed(1);
|
||||
const wsName = (_cachedWeatherSources || []).find((w: any) => w.id === source.weather_source_id)?.name || '—';
|
||||
return `
|
||||
<span class="stream-card-prop">${getColorStripIcon('weather')} ${escapeHtml(wsName)}</span>
|
||||
<span class="stream-card-prop">⏩ ${speedVal}x</span>
|
||||
<span class="stream-card-prop">🌡 ${tempInfl}</span>
|
||||
${clockBadge}
|
||||
`;
|
||||
},
|
||||
processed: (source) => {
|
||||
const inputSrc = ((colorStripSourcesCache.data || []) as any[]).find(s => s.id === source.input_source_id);
|
||||
const inputName = inputSrc?.name || source.input_source_id || '—';
|
||||
@@ -1479,6 +1515,39 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
};
|
||||
},
|
||||
},
|
||||
weather: {
|
||||
async load(css) {
|
||||
await weatherSourcesCache.fetch();
|
||||
_populateWeatherSourceDropdown();
|
||||
(document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = css.weather_source_id || '';
|
||||
(document.getElementById('css-editor-weather-speed') as HTMLInputElement).value = css.speed ?? 1.0;
|
||||
(document.getElementById('css-editor-weather-speed-val') as HTMLElement).textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
|
||||
(document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value = css.temperature_influence ?? 0.5;
|
||||
(document.getElementById('css-editor-weather-temp-val') as HTMLElement).textContent = parseFloat(css.temperature_influence ?? 0.5).toFixed(2);
|
||||
},
|
||||
async reset() {
|
||||
await weatherSourcesCache.fetch();
|
||||
_populateWeatherSourceDropdown();
|
||||
(document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = '';
|
||||
(document.getElementById('css-editor-weather-speed') as HTMLInputElement).value = 1.0 as any;
|
||||
(document.getElementById('css-editor-weather-speed-val') as HTMLElement).textContent = '1.0';
|
||||
(document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value = 0.5 as any;
|
||||
(document.getElementById('css-editor-weather-temp-val') as HTMLElement).textContent = '0.50';
|
||||
},
|
||||
getPayload(name) {
|
||||
const wsId = (document.getElementById('css-editor-weather-source') as HTMLSelectElement).value;
|
||||
if (!wsId) {
|
||||
cssEditorModal.showError(t('color_strip.weather.error.no_source'));
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
weather_source_id: wsId,
|
||||
speed: parseFloat((document.getElementById('css-editor-weather-speed') as HTMLInputElement).value),
|
||||
temperature_influence: parseFloat((document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value),
|
||||
};
|
||||
},
|
||||
},
|
||||
processed: {
|
||||
async load(css) {
|
||||
await csptCache.fetch();
|
||||
@@ -1726,7 +1795,7 @@ export async function saveCSSEditor() {
|
||||
if (!cssId) payload.source_type = knownType ? sourceType : 'picture';
|
||||
|
||||
// Attach clock_id for animated types
|
||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight'];
|
||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather'];
|
||||
if (clockTypes.includes(sourceType)) {
|
||||
const clockVal = (document.getElementById('css-editor-clock') as HTMLInputElement).value;
|
||||
payload.clock_id = clockVal || null;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
_cachedAudioSources,
|
||||
_cachedValueSources,
|
||||
_cachedSyncClocks,
|
||||
_cachedWeatherSources,
|
||||
_cachedAudioTemplates,
|
||||
_cachedCSPTemplates,
|
||||
_csptModalFilters, set_csptModalFilters,
|
||||
@@ -34,7 +35,7 @@ import {
|
||||
_sourcesLoading, set_sourcesLoading,
|
||||
apiKey,
|
||||
streamsCache, ppTemplatesCache, captureTemplatesCache,
|
||||
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, filtersCache,
|
||||
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, filtersCache,
|
||||
colorStripSourcesCache,
|
||||
csptCache, stripFiltersCache,
|
||||
gradientsCache, GradientEntity,
|
||||
@@ -49,6 +50,7 @@ import { TreeNav } from '../core/tree-nav.ts';
|
||||
import { updateSubTabHash } from './tabs.ts';
|
||||
import { createValueSourceCard } from './value-sources.ts';
|
||||
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
|
||||
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
|
||||
import { createColorStripCard } from './color-strips.ts';
|
||||
import { initAudioSourceDelegation } from './audio-sources.ts';
|
||||
import {
|
||||
@@ -94,6 +96,7 @@ const _audioTemplateDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', ic
|
||||
const _colorStripDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-sources', colorStripSourcesCache, 'color_strip.deleted') }];
|
||||
const _valueSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('value-sources', valueSourcesCache, 'value_source.deleted') }];
|
||||
const _syncClockDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('sync-clocks', syncClocksCache, 'sync_clock.deleted') }];
|
||||
const _weatherSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('weather-sources', weatherSourcesCache, 'weather_source.deleted') }];
|
||||
const _csptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-processing-templates', csptCache, 'templates.deleted') }];
|
||||
|
||||
// ── Card section instances ──
|
||||
@@ -109,6 +112,7 @@ const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_t
|
||||
const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.section.color_strips', gridClass: 'templates-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id', emptyKey: 'section.empty.color_strips', bulkActions: _colorStripDeleteAction });
|
||||
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources', bulkActions: _valueSourceDeleteAction });
|
||||
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks', bulkActions: _syncClockDeleteAction });
|
||||
const csWeatherSources = new CardSection('weather-sources', { titleKey: 'weather_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showWeatherSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.weather_sources', bulkActions: _weatherSourceDeleteAction });
|
||||
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt', bulkActions: _csptDeleteAction });
|
||||
const _gradientDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('gradients', gradientsCache, 'gradient.deleted') }];
|
||||
const csGradients = new CardSection('gradients', { titleKey: 'gradient.group.title', gridClass: 'templates-grid', addCardOnclick: "showGradientModal()", keyAttr: 'data-id', emptyKey: 'section.empty.gradients', bulkActions: _gradientDeleteAction });
|
||||
@@ -220,6 +224,7 @@ export async function loadPictureSources() {
|
||||
audioSourcesCache.fetch(),
|
||||
valueSourcesCache.fetch(),
|
||||
syncClocksCache.fetch(),
|
||||
weatherSourcesCache.fetch(),
|
||||
audioTemplatesCache.fetch(),
|
||||
colorStripSourcesCache.fetch(),
|
||||
csptCache.fetch(),
|
||||
@@ -274,6 +279,7 @@ const _streamSectionMap = {
|
||||
audio_templates: [csAudioTemplates],
|
||||
value: [csValueSources],
|
||||
sync: [csSyncClocks],
|
||||
weather: [csWeatherSources],
|
||||
};
|
||||
|
||||
type StreamCardRenderer = (stream: any) => string;
|
||||
@@ -483,6 +489,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
{ key: 'audio_templates', icon: ICON_AUDIO_TEMPLATE, titleKey: 'streams.group.audio_templates', count: _cachedAudioTemplates.length },
|
||||
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
|
||||
{ key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
|
||||
{ key: 'weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
|
||||
];
|
||||
|
||||
// Build tree navigation structure
|
||||
@@ -533,6 +540,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
children: [
|
||||
{ key: 'value', titleKey: 'streams.group.value', icon: ICON_VALUE_SOURCE, count: _cachedValueSources.length },
|
||||
{ key: 'sync', titleKey: 'streams.group.sync', icon: ICON_CLOCK, count: _cachedSyncClocks.length },
|
||||
{ key: 'weather', titleKey: 'streams.group.weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -663,6 +671,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
const gradientItems = csGradients.applySortOrder(gradients.map(g => ({ key: g.id, html: renderGradientCard(g) })));
|
||||
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
|
||||
const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) })));
|
||||
const weatherSourceItems = csWeatherSources.applySortOrder(_cachedWeatherSources.map(s => ({ key: s.id, html: createWeatherSourceCard(s) })));
|
||||
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
|
||||
|
||||
if (csRawStreams.isMounted()) {
|
||||
@@ -681,6 +690,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
audio_templates: _cachedAudioTemplates.length,
|
||||
value: _cachedValueSources.length,
|
||||
sync: _cachedSyncClocks.length,
|
||||
weather: _cachedWeatherSources.length,
|
||||
});
|
||||
csRawStreams.reconcile(rawStreamItems);
|
||||
csRawTemplates.reconcile(rawTemplateItems);
|
||||
@@ -696,6 +706,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
csVideoStreams.reconcile(videoItems);
|
||||
csValueSources.reconcile(valueItems);
|
||||
csSyncClocks.reconcile(syncClockItems);
|
||||
csWeatherSources.reconcile(weatherSourceItems);
|
||||
} else {
|
||||
// First render: build full HTML
|
||||
const panels = tabs.map(tab => {
|
||||
@@ -711,16 +722,18 @@ function renderPictureSourcesList(streams: any) {
|
||||
else if (tab.key === 'audio_templates') panelContent = csAudioTemplates.render(audioTemplateItems);
|
||||
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
|
||||
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
|
||||
else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
|
||||
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
|
||||
else panelContent = csStaticStreams.render(staticItems);
|
||||
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = panels;
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks]);
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources]);
|
||||
|
||||
// Event delegation for card actions (replaces inline onclick handlers)
|
||||
initSyncClockDelegation(container);
|
||||
initWeatherSourceDelegation(container);
|
||||
initAudioSourceDelegation(container);
|
||||
|
||||
// Render tree sidebar with expand/collapse buttons
|
||||
@@ -738,6 +751,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
'audio-templates': 'audio_templates',
|
||||
'value-sources': 'value',
|
||||
'sync-clocks': 'sync',
|
||||
'weather-sources': 'weather',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
329
server/src/wled_controller/static/js/features/weather-sources.ts
Normal file
329
server/src/wled_controller/static/js/features/weather-sources.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Weather Sources — CRUD, test, cards.
|
||||
*/
|
||||
|
||||
import { _cachedWeatherSources, weatherSourcesCache } from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { ICON_CLONE, ICON_EDIT, ICON_TEST } from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { loadPictureSources } from './streams.ts';
|
||||
import type { WeatherSource } from '../types.ts';
|
||||
|
||||
const ICON_WEATHER = `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`;
|
||||
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
function _getProviderItems() {
|
||||
return [
|
||||
{ value: 'open_meteo', icon: _icon(P.cloudSun), label: 'Open-Meteo', desc: t('weather_source.provider.open_meteo.desc') },
|
||||
];
|
||||
}
|
||||
|
||||
// ── Modal ──
|
||||
|
||||
let _weatherSourceTagsInput: TagInput | null = null;
|
||||
let _providerIconSelect: IconSelect | null = null;
|
||||
|
||||
class WeatherSourceModal extends Modal {
|
||||
constructor() { super('weather-source-modal'); }
|
||||
|
||||
onForceClose() {
|
||||
if (_weatherSourceTagsInput) { _weatherSourceTagsInput.destroy(); _weatherSourceTagsInput = null; }
|
||||
if (_providerIconSelect) { _providerIconSelect.destroy(); _providerIconSelect = null; }
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: (document.getElementById('weather-source-name') as HTMLInputElement).value,
|
||||
provider: (document.getElementById('weather-source-provider') as HTMLSelectElement).value,
|
||||
latitude: (document.getElementById('weather-source-latitude') as HTMLInputElement).value,
|
||||
longitude: (document.getElementById('weather-source-longitude') as HTMLInputElement).value,
|
||||
interval: (document.getElementById('weather-source-interval') as HTMLInputElement).value,
|
||||
description: (document.getElementById('weather-source-description') as HTMLInputElement).value,
|
||||
tags: JSON.stringify(_weatherSourceTagsInput ? _weatherSourceTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const weatherSourceModal = new WeatherSourceModal();
|
||||
|
||||
// ── Show / Close ──
|
||||
|
||||
export async function showWeatherSourceModal(editData: WeatherSource | null = null): Promise<void> {
|
||||
const isEdit = !!editData;
|
||||
const titleKey = isEdit ? 'weather_source.edit' : 'weather_source.add';
|
||||
document.getElementById('weather-source-modal-title')!.innerHTML = `${ICON_WEATHER} ${t(titleKey)}`;
|
||||
(document.getElementById('weather-source-id') as HTMLInputElement).value = editData?.id || '';
|
||||
(document.getElementById('weather-source-error') as HTMLElement).style.display = 'none';
|
||||
|
||||
if (isEdit) {
|
||||
(document.getElementById('weather-source-name') as HTMLInputElement).value = editData.name || '';
|
||||
(document.getElementById('weather-source-provider') as HTMLSelectElement).value = editData.provider || 'open_meteo';
|
||||
(document.getElementById('weather-source-latitude') as HTMLInputElement).value = String(editData.latitude ?? 50.0);
|
||||
(document.getElementById('weather-source-longitude') as HTMLInputElement).value = String(editData.longitude ?? 0.0);
|
||||
(document.getElementById('weather-source-interval') as HTMLInputElement).value = String(editData.update_interval ?? 600);
|
||||
document.getElementById('weather-source-interval-display')!.textContent = String(Math.round((editData.update_interval ?? 600) / 60));
|
||||
(document.getElementById('weather-source-description') as HTMLInputElement).value = editData.description || '';
|
||||
} else {
|
||||
(document.getElementById('weather-source-name') as HTMLInputElement).value = '';
|
||||
(document.getElementById('weather-source-provider') as HTMLSelectElement).value = 'open_meteo';
|
||||
(document.getElementById('weather-source-latitude') as HTMLInputElement).value = '50.0';
|
||||
(document.getElementById('weather-source-longitude') as HTMLInputElement).value = '0.0';
|
||||
(document.getElementById('weather-source-interval') as HTMLInputElement).value = '600';
|
||||
document.getElementById('weather-source-interval-display')!.textContent = '10';
|
||||
(document.getElementById('weather-source-description') as HTMLInputElement).value = '';
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (_weatherSourceTagsInput) { _weatherSourceTagsInput.destroy(); _weatherSourceTagsInput = null; }
|
||||
_weatherSourceTagsInput = new TagInput(document.getElementById('weather-source-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_weatherSourceTagsInput.setValue(isEdit ? (editData.tags || []) : []);
|
||||
|
||||
// Provider IconSelect
|
||||
if (_providerIconSelect) { _providerIconSelect.destroy(); _providerIconSelect = null; }
|
||||
_providerIconSelect = new IconSelect({
|
||||
target: document.getElementById('weather-source-provider') as HTMLSelectElement,
|
||||
items: _getProviderItems(),
|
||||
columns: 1,
|
||||
});
|
||||
|
||||
// Show/hide test button based on edit mode
|
||||
const testBtn = document.getElementById('weather-source-test-btn');
|
||||
if (testBtn) testBtn.style.display = isEdit ? '' : 'none';
|
||||
|
||||
weatherSourceModal.open();
|
||||
weatherSourceModal.snapshot();
|
||||
}
|
||||
|
||||
export async function closeWeatherSourceModal(): Promise<void> {
|
||||
await weatherSourceModal.close();
|
||||
}
|
||||
|
||||
// ── Save ──
|
||||
|
||||
export async function saveWeatherSource(): Promise<void> {
|
||||
const id = (document.getElementById('weather-source-id') as HTMLInputElement).value;
|
||||
const name = (document.getElementById('weather-source-name') as HTMLInputElement).value.trim();
|
||||
const provider = (document.getElementById('weather-source-provider') as HTMLSelectElement).value;
|
||||
const latitude = parseFloat((document.getElementById('weather-source-latitude') as HTMLInputElement).value) || 50.0;
|
||||
const longitude = parseFloat((document.getElementById('weather-source-longitude') as HTMLInputElement).value) || 0.0;
|
||||
const update_interval = parseInt((document.getElementById('weather-source-interval') as HTMLInputElement).value) || 600;
|
||||
const description = (document.getElementById('weather-source-description') as HTMLInputElement).value.trim() || null;
|
||||
|
||||
if (!name) {
|
||||
weatherSourceModal.showError(t('weather_source.error.name_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name, provider, latitude, longitude, update_interval, description,
|
||||
tags: _weatherSourceTagsInput ? _weatherSourceTagsInput.getValue() : [],
|
||||
};
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/weather-sources/${id}` : '/weather-sources';
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t(id ? 'weather_source.updated' : 'weather_source.created'), 'success');
|
||||
weatherSourceModal.forceClose();
|
||||
weatherSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
weatherSourceModal.showError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit / Clone / Delete ──
|
||||
|
||||
export async function editWeatherSource(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('weather_source.error.load'));
|
||||
const data = await resp.json();
|
||||
await showWeatherSourceModal(data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function cloneWeatherSource(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('weather_source.error.load'));
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
await showWeatherSourceModal(data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWeatherSource(sourceId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('weather_source.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('weather_source.deleted'), 'success');
|
||||
weatherSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test (fetch current weather) ──
|
||||
|
||||
export async function testWeatherSource(): Promise<void> {
|
||||
const id = (document.getElementById('weather-source-id') as HTMLInputElement).value;
|
||||
if (!id) return;
|
||||
|
||||
const testBtn = document.getElementById('weather-source-test-btn');
|
||||
if (testBtn) testBtn.classList.add('loading');
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/weather-sources/${id}/test`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success');
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
if (testBtn) testBtn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Geolocation ──
|
||||
|
||||
export function weatherSourceGeolocate(): void {
|
||||
const btn = document.getElementById('weather-source-geolocate-btn');
|
||||
if (!navigator.geolocation) {
|
||||
showToast(t('weather_source.geo.not_supported'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (btn) btn.classList.add('loading');
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
(document.getElementById('weather-source-latitude') as HTMLInputElement).value = pos.coords.latitude.toFixed(4);
|
||||
(document.getElementById('weather-source-longitude') as HTMLInputElement).value = pos.coords.longitude.toFixed(4);
|
||||
if (btn) btn.classList.remove('loading');
|
||||
showToast(t('weather_source.geo.success'), 'success');
|
||||
},
|
||||
(err) => {
|
||||
if (btn) btn.classList.remove('loading');
|
||||
showToast(`${t('weather_source.geo.error')}: ${err.message}`, 'error');
|
||||
},
|
||||
{ timeout: 10000, maximumAge: 60000 }
|
||||
);
|
||||
}
|
||||
|
||||
// ── Card rendering ──
|
||||
|
||||
export function createWeatherSourceCard(source: WeatherSource) {
|
||||
const intervalMin = Math.round(source.update_interval / 60);
|
||||
const providerLabel = source.provider === 'open_meteo' ? 'Open-Meteo' : source.provider;
|
||||
|
||||
return wrapCard({
|
||||
type: 'template-card',
|
||||
dataAttr: 'data-id',
|
||||
id: source.id,
|
||||
removeOnclick: `deleteWeatherSource('${source.id}')`,
|
||||
removeTitle: t('common.delete'),
|
||||
content: `
|
||||
<div class="template-card-header">
|
||||
<div class="template-name">${ICON_WEATHER} ${escapeHtml(source.name)}</div>
|
||||
</div>
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop">${ICON_WEATHER} ${providerLabel}</span>
|
||||
<span class="stream-card-prop" title="${source.latitude.toFixed(2)}, ${source.longitude.toFixed(2)}">
|
||||
<svg class="icon" viewBox="0 0 24 24">${P.mapPin}</svg> ${source.latitude.toFixed(1)}, ${source.longitude.toFixed(1)}
|
||||
</span>
|
||||
<span class="stream-card-prop">
|
||||
<svg class="icon" viewBox="0 0 24 24">${P.clock}</svg> ${intervalMin}min
|
||||
</span>
|
||||
</div>
|
||||
${renderTagChips(source.tags)}
|
||||
${source.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(source.description)}</div>` : ''}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" data-action="test" title="${t('weather_source.test')}">${ICON_TEST}</button>
|
||||
<button class="btn btn-icon btn-secondary" data-action="clone" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" data-action="edit" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Event delegation ──
|
||||
|
||||
const _weatherSourceActions: Record<string, (id: string) => void> = {
|
||||
test: (id) => _testWeatherSourceFromCard(id),
|
||||
clone: cloneWeatherSource,
|
||||
edit: editWeatherSource,
|
||||
};
|
||||
|
||||
async function _testWeatherSourceFromCard(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/weather-sources/${sourceId}/test`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success');
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function initWeatherSourceDelegation(container: HTMLElement): void {
|
||||
container.addEventListener('click', (e: MouseEvent) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
|
||||
if (!btn) return;
|
||||
|
||||
const section = btn.closest<HTMLElement>('[data-card-section="weather-sources"]');
|
||||
if (!section) return;
|
||||
const card = btn.closest<HTMLElement>('[data-id]');
|
||||
if (!card) return;
|
||||
|
||||
const action = btn.dataset.action;
|
||||
const id = card.getAttribute('data-id');
|
||||
if (!action || !id) return;
|
||||
|
||||
const handler = _weatherSourceActions[action];
|
||||
if (handler) {
|
||||
e.stopPropagation();
|
||||
handler(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Expose to global scope for HTML template onclick handlers ──
|
||||
|
||||
window.showWeatherSourceModal = showWeatherSourceModal;
|
||||
window.closeWeatherSourceModal = closeWeatherSourceModal;
|
||||
window.saveWeatherSource = saveWeatherSource;
|
||||
window.editWeatherSource = editWeatherSource;
|
||||
window.cloneWeatherSource = cloneWeatherSource;
|
||||
window.deleteWeatherSource = deleteWeatherSource;
|
||||
window.testWeatherSource = testWeatherSource;
|
||||
window.weatherSourceGeolocate = weatherSourceGeolocate;
|
||||
@@ -223,6 +223,10 @@ export interface ColorStripSource {
|
||||
// Processed
|
||||
input_source_id?: string;
|
||||
processing_template_id?: string;
|
||||
|
||||
// Weather
|
||||
weather_source_id?: string;
|
||||
temperature_influence?: number;
|
||||
}
|
||||
|
||||
// ── Pattern Template ──────────────────────────────────────────
|
||||
@@ -384,6 +388,25 @@ export interface SyncClock {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WeatherSource {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
provider_config: Record<string, any>;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
update_interval: number;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WeatherSourceListResponse {
|
||||
sources: WeatherSource[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ── Automation ────────────────────────────────────────────────
|
||||
|
||||
export type ConditionType =
|
||||
|
||||
Reference in New Issue
Block a user