Release 2.9.0
This commit is contained in:
@@ -179,6 +179,8 @@ function registerPartials(Handlebars, viewsRoot) {
|
||||
Handlebars.registerPartial('signage/templates/animation-advanced-modal', fs.readFileSync(path.join(viewsRoot, 'signage', 'templates', 'animation-advanced-modal.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('data-sources/api-sources/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'api-sources', 'form.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('data-sources/rss-feeds/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'rss-feeds', 'form.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('data-sources/weather/preview', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'weather', 'preview.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('data-sources/weather/forecast-preview', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'weather', 'forecast-preview.hbs'), 'utf8'));
|
||||
}
|
||||
|
||||
// One entry point keeps Handlebars bootstrap centralized.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh');
|
||||
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('../../data-source-refresh');
|
||||
|
||||
const TASK = {
|
||||
taskType: 'data-source-refresh'
|
||||
@@ -24,6 +24,7 @@ function registerDataSourceRefreshTask(options) {
|
||||
if (!apiSource) {
|
||||
throw new Error('API source not found.');
|
||||
}
|
||||
if (apiSource.enabled === 0 || apiSource.enabled === false) return { skipped: true, reason: 'disabled' };
|
||||
return refreshApiSource(pool, common, apiSource, Number(payload.actorId) || null, options.notifyPlayerScreens);
|
||||
}
|
||||
|
||||
@@ -32,9 +33,19 @@ function registerDataSourceRefreshTask(options) {
|
||||
if (!rssFeed) {
|
||||
throw new Error('RSS feed not found.');
|
||||
}
|
||||
if (rssFeed.enabled === 0 || rssFeed.enabled === false) return { skipped: true, reason: 'disabled' };
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null, options.notifyPlayerScreens);
|
||||
}
|
||||
|
||||
if (sourceType === 'weather-location') {
|
||||
const location = await common.fetchWeatherLocationById(pool, sourceId);
|
||||
if (!location) {
|
||||
throw new Error('Weather location not found.');
|
||||
}
|
||||
if (location.enabled === 0 || location.enabled === false) return { skipped: true, reason: 'disabled' };
|
||||
return refreshWeatherLocation(pool, common, location, Number(payload.actorId) || null, options.notifyPlayerScreens);
|
||||
}
|
||||
|
||||
throw new Error('Unsupported data source refresh task.');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ const TASK = {
|
||||
};
|
||||
|
||||
const { normalizeIntervalMs } = require('../queue');
|
||||
const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh');
|
||||
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('../../data-source-refresh');
|
||||
|
||||
function registerRecurringDataSourceRefreshes(options) {
|
||||
const pool = options && options.pool;
|
||||
@@ -23,6 +23,7 @@ function registerRecurringDataSourceRefreshes(options) {
|
||||
return (async function () {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
if (apiSource.enabled === 0 || apiSource.enabled === false) return;
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'api-source-refresh:' + Number(apiSource.id),
|
||||
title: 'API source refresh',
|
||||
@@ -41,6 +42,7 @@ function registerRecurringDataSourceRefreshes(options) {
|
||||
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
if (rssFeed.enabled === 0 || rssFeed.enabled === false) return;
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'rss-feed-refresh:' + Number(rssFeed.id),
|
||||
title: 'RSS feed refresh',
|
||||
@@ -56,6 +58,19 @@ function registerRecurringDataSourceRefreshes(options) {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const weatherLocationsData = await common.fetchWeatherLocationsData(pool);
|
||||
(weatherLocationsData.weatherLocations || []).forEach(function (location) {
|
||||
if (location.enabled === 0 || location.enabled === false) return;
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'weather-location-refresh:' + Number(location.id),
|
||||
title: 'Weather location refresh',
|
||||
category: TASK.category,
|
||||
intervalMs: normalizeIntervalMs(location.update_interval_value, location.update_interval_unit),
|
||||
metadata: { sourceType: 'weather-location', sourceId: Number(location.id), sourceName: location.name },
|
||||
run: function () { return refreshWeatherLocation(pool, common, location, null, options.notifyPlayerScreens); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -73,7 +88,7 @@ function createDataSourceTaskService(options) {
|
||||
}
|
||||
|
||||
function buildRecurringTitle(sourceType) {
|
||||
return sourceType === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh';
|
||||
return sourceType === 'rss-feed' ? 'RSS feed refresh' : sourceType === 'weather-location' ? 'Weather location refresh' : 'API source refresh';
|
||||
}
|
||||
|
||||
function registerRecurringRefresh(sourceType, id, name, intervalValue, intervalUnit, run) {
|
||||
@@ -122,6 +137,10 @@ function createDataSourceTaskService(options) {
|
||||
return refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId, options.notifyPlayerScreens);
|
||||
}
|
||||
|
||||
async function refreshWeatherLocationInBackground(locationId, actorId) {
|
||||
return refreshWeatherLocation(pool, common, locationId, actorId, options.notifyPlayerScreens);
|
||||
}
|
||||
|
||||
return {
|
||||
formatRecurringKey: formatRecurringKey,
|
||||
buildRecurringTitle: buildRecurringTitle,
|
||||
@@ -129,7 +148,8 @@ function createDataSourceTaskService(options) {
|
||||
removeRecurringRefresh: removeRecurringRefresh,
|
||||
getTaskStatusById: getTaskStatusById,
|
||||
refreshApiSourceInBackground: refreshApiSourceInBackground,
|
||||
refreshRssFeedInBackground: refreshRssFeedInBackground
|
||||
refreshRssFeedInBackground: refreshRssFeedInBackground,
|
||||
refreshWeatherLocationInBackground: refreshWeatherLocationInBackground
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh');
|
||||
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('../../data-source-refresh');
|
||||
const { normalizeIntervalMs } = require('../queue');
|
||||
|
||||
const TASK = {
|
||||
key: 'startup-data-source-refresh',
|
||||
@@ -26,23 +27,52 @@ function scheduleStartupDataSourceRefreshes(options) {
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRefreshAtStartup(source) {
|
||||
const lastPulledAt = source && source.last_pulled_at ? new Date(source.last_pulled_at).getTime() : NaN;
|
||||
if (!Number.isFinite(lastPulledAt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const intervalMs = normalizeIntervalMs(source.update_interval_value, source.update_interval_unit);
|
||||
return Date.now() - lastPulledAt >= intervalMs;
|
||||
}
|
||||
|
||||
return (async function () {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
const weatherLocationsData = await common.fetchWeatherLocationsData(pool);
|
||||
const startupSources = [];
|
||||
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
if (apiSource.enabled === 0 || apiSource.enabled === false) return;
|
||||
if (!shouldRefreshAtStartup(apiSource)) {
|
||||
return;
|
||||
}
|
||||
startupSources.push(buildStartupSource('api-source', apiSource.id, apiSource.name, function () {
|
||||
return refreshApiSource(pool, common, apiSource, null, options.notifyPlayerScreens);
|
||||
}));
|
||||
});
|
||||
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
if (rssFeed.enabled === 0 || rssFeed.enabled === false) return;
|
||||
if (!shouldRefreshAtStartup(rssFeed)) {
|
||||
return;
|
||||
}
|
||||
startupSources.push(buildStartupSource('rss-feed', rssFeed.id, rssFeed.name, function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null, options.notifyPlayerScreens);
|
||||
}));
|
||||
});
|
||||
|
||||
(weatherLocationsData.weatherLocations || []).forEach(function (location) {
|
||||
if (location.enabled === 0 || location.enabled === false) return;
|
||||
if (!shouldRefreshAtStartup(location)) {
|
||||
return;
|
||||
}
|
||||
startupSources.push(buildStartupSource('weather-location', location.id, location.name, function () {
|
||||
return refreshWeatherLocation(pool, common, location, null, options.notifyPlayerScreens);
|
||||
}));
|
||||
});
|
||||
|
||||
// Stagger startup refreshes to avoid a burst against the DB/player.
|
||||
startupSources.forEach(function (source, index) {
|
||||
const startupDelayMs = index * staggerMs;
|
||||
@@ -50,7 +80,7 @@ function scheduleStartupDataSourceRefreshes(options) {
|
||||
setTimeout(function () {
|
||||
backgroundTaskQueue.enqueueTask({
|
||||
key: TASK.key + ':' + source.type + ':' + source.id + ':' + Date.now(),
|
||||
title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh',
|
||||
title: source.type === 'rss-feed' ? 'RSS feed refresh' : source.type === 'weather-location' ? 'Weather location refresh' : 'API source refresh',
|
||||
category: TASK.category,
|
||||
taskType: 'data-source-refresh',
|
||||
metadata: {
|
||||
|
||||
@@ -153,6 +153,46 @@ async function refreshApiSource(pool, common, apiSourceOrId, actorId, notifyPlay
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWeatherLocation(pool, common, weatherLocationOrId, actorId, notifyPlayerScreens) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const location = weatherLocationOrId && typeof weatherLocationOrId === 'object'
|
||||
? weatherLocationOrId
|
||||
: await common.fetchWeatherLocationById(pool, Number(weatherLocationOrId));
|
||||
if (!location) throw new Error('Weather location not found.');
|
||||
|
||||
let result = null;
|
||||
let pullError = '';
|
||||
try {
|
||||
result = await common.fetchWeatherLocationForecast(pool, location);
|
||||
} catch (error) {
|
||||
pullError = String(error && error.message ? error.message : 'Unable to load weather forecast.');
|
||||
}
|
||||
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE i_weather_locations SET last_pulled_at = ?, last_pull_error = ?, last_response_json = COALESCE(?, last_response_json), modified_by = ? WHERE id = ?',
|
||||
[new Date(), pullError || null, result ? result.responseJson : null, actorId, location.id]
|
||||
);
|
||||
await connection.commit();
|
||||
|
||||
if (pullError) {
|
||||
console.error('[data-source-refresh] Weather location refresh completed with an error for location ' + location.id + ': ' + pullError);
|
||||
} else if (typeof notifyAffectedScreens === 'function') {
|
||||
try {
|
||||
await notifyAffectedScreens(connection, common, notifyPlayerScreens, 'weather_location_id', location.id);
|
||||
} catch (notifyError) {
|
||||
console.warn('[data-source-refresh] Unable to notify players after weather refresh ' + location.id + ':', notifyError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
try { await connection.rollback(); } catch (_rollbackError) { }
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId, notifyPlayerScreens) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
@@ -170,6 +210,10 @@ async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actor
|
||||
if (typeof common.replaceRssFeedItems === 'function') {
|
||||
await common.replaceRssFeedItems(connection, rssFeedId, updatedItems);
|
||||
}
|
||||
await connection.query(
|
||||
'UPDATE i_rss_feeds SET last_pulled_at = ? WHERE id = ?',
|
||||
[new Date(), rssFeedId]
|
||||
);
|
||||
await connection.commit();
|
||||
|
||||
if (!pullError && rssFeedChanged) {
|
||||
@@ -197,5 +241,6 @@ async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actor
|
||||
|
||||
module.exports = {
|
||||
refreshApiSource: refreshApiSource,
|
||||
refreshRssFeed: refreshRssFeed
|
||||
refreshRssFeed: refreshRssFeed,
|
||||
refreshWeatherLocation: refreshWeatherLocation
|
||||
};
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
sanitizeTextColor
|
||||
} = require('#src/player/render-helpers');
|
||||
const { createRequestAuthHeaders, createPageAuthToken } = require('#src/request-auth');
|
||||
const { convertWeatherSnapshot } = require('#src/data/weather-units');
|
||||
|
||||
const placeholderUtils = (() => {
|
||||
const sandbox = { window: {} };
|
||||
@@ -147,15 +148,50 @@ function getImagePlaceholderConfig(expression) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWeatherExpression(expression) {
|
||||
const value = String(expression || '').trim();
|
||||
const currentAliases = { temp: 'current.temperature_2m', feels_like: 'current.apparent_temperature', humidity: 'current.relative_humidity_2m', wind: 'current.wind_speed_10m', precip: 'current.precipitation', uv_index: 'current.uv_index', cloud_cover: 'current.cloud_cover', icon: 'current.weather_code.icon' };
|
||||
const globalAliases = { temp_unit: 'temperature_unit', wind_unit: 'wind_speed_unit', precip_unit: 'precipitation_unit' };
|
||||
if (globalAliases[value]) return globalAliases[value];
|
||||
const currentField = value.replace(/^current\./, '').match(/^([^.()]+)((?:\(.*\))|(?:\..*))?$/);
|
||||
if (currentField && currentAliases[currentField[1]]) return currentAliases[currentField[1]] + (currentField[2] || '');
|
||||
const indexed = value.match(/^(daily|hourly)\.(\d+)\.(.+)$/);
|
||||
if (!indexed) return value;
|
||||
const fieldMatch = indexed[3].match(/^([^.()]+)((?:\(.*\))|(?:\..*))?$/);
|
||||
const field = fieldMatch ? fieldMatch[1] : indexed[3];
|
||||
const aliases = indexed[1] === 'daily' ? { time: 'time', temp_max: 'temperature_2m_max', temp_min: 'temperature_2m_min', precip: 'precipitation_sum', wind: 'wind_speed_10m_max', uv_index: 'uv_index_max', cloud_cover: 'cloud_cover_mean', sunrise: 'sunrise', sunset: 'sunset', icon: 'weather_code' } : { time: 'time', temp: 'temperature_2m', precip: 'precipitation', wind: 'wind_speed_10m', uv_index: 'uv_index', cloud_cover: 'cloud_cover', icon: 'weather_code' };
|
||||
if (!Object.prototype.hasOwnProperty.call(aliases, field)) return value;
|
||||
return field === 'icon' ? indexed[1] + '.' + aliases[field] + '.' + indexed[2] + '.icon' + (fieldMatch[2] || '') : indexed[1] + '.' + aliases[field] + '.' + indexed[2] + (fieldMatch[2] || '');
|
||||
}
|
||||
|
||||
function weatherIconForCode(code) {
|
||||
const value = Number(code);
|
||||
if (value === 0) return 'bi-sun';
|
||||
if (value <= 3) return 'bi-cloud-sun';
|
||||
if (value <= 48) return 'bi-cloud-fog';
|
||||
if (value <= 67 || value > 77 && value <= 82) return 'bi-cloud-rain';
|
||||
if (value <= 77) return 'bi-cloud-snow';
|
||||
return 'bi-cloud-lightning-rain';
|
||||
}
|
||||
|
||||
function substitutePlaceholders(html, regionContent, options) {
|
||||
const source = String(html || '');
|
||||
const type = String(regionContent && regionContent.type || '').trim().toLowerCase();
|
||||
const item = options && typeof options.getItem === 'function' ? options.getItem(type, regionContent) : null;
|
||||
if (!item) return source;
|
||||
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/gi, function (_match, expression) {
|
||||
const weatherExpression = type === 'weather' ? normalizeWeatherExpression(expression) : expression;
|
||||
const resolved = typeof placeholderUtils.resolvePlaceholderExpression === 'function'
|
||||
? placeholderUtils.resolvePlaceholderExpression(item, expression)
|
||||
? placeholderUtils.resolvePlaceholderExpression(item, weatherExpression, { timeZone: item.timezone })
|
||||
: resolvePlaceholderPath(item, expression);
|
||||
if (type === 'weather' && /(?:^|\.)weather_code\.\d+\.icon(?:\(|$)|^current\.weather_code\.icon/.test(weatherExpression)) {
|
||||
const codeExpression = weatherExpression.replace(/\.icon(?:\(.*\))?$/, '');
|
||||
const iconSize = String(expression).match(/\.icon\(\s*(\d+)(?:\s*,\s*(\d+))?\s*\)$/);
|
||||
const width = iconSize ? Number(iconSize[1]) : 0;
|
||||
const height = iconSize && iconSize[2] ? Number(iconSize[2]) : width;
|
||||
const sizeStyle = width ? ' style="display:inline-block;width:' + width + 'px;height:' + height + 'px;font-size:' + width + 'px;line-height:' + height + 'px;"' : '';
|
||||
return '<i class="bi ' + weatherIconForCode(placeholderUtils.resolvePlaceholderExpression(item, codeExpression)) + '"' + sizeStyle + ' aria-hidden="true"></i>';
|
||||
}
|
||||
const value = typeof placeholderUtils.formatPlaceholderValue === 'function' ? placeholderUtils.formatPlaceholderValue(resolved) : String(resolved || '');
|
||||
if (typeof placeholderUtils.isImagePlaceholderExpression !== 'function' || !placeholderUtils.isImagePlaceholderExpression(expression)) {
|
||||
return escapeHtml(value);
|
||||
@@ -348,6 +384,23 @@ async function captureSlideThumbnail(options) {
|
||||
const apiSources = (apiSourcesData.apiSources || []).map(function (source) {
|
||||
return Object.assign({}, source, { responseJson: common.parseJsonSafe ? common.parseJsonSafe(source.last_response_json) : null });
|
||||
});
|
||||
const weatherLocationsData = typeof common.fetchWeatherLocationsData === 'function' ? await common.fetchWeatherLocationsData(pool) : { weatherLocations: [] };
|
||||
const weatherLocations = (weatherLocationsData.weatherLocations || []).map(function (location) {
|
||||
let snapshot = null;
|
||||
try {
|
||||
snapshot = JSON.parse(location.last_response_json || '');
|
||||
} catch (_error) {
|
||||
snapshot = null;
|
||||
}
|
||||
if (!snapshot || typeof snapshot !== 'object') return null;
|
||||
const temperatureUnit = location.temperature_unit === 'fahrenheit' ? '°F' : '°C';
|
||||
const windUnit = location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h';
|
||||
const precipitationUnit = location.precipitation_unit === 'inch' ? 'in' : 'mm';
|
||||
const data = Object.assign({}, convertWeatherSnapshot(snapshot, { temperature: location.temperature_unit === 'fahrenheit' ? 'fahrenheit' : 'celsius', wind: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'ms' : 'kmh', precipitation: location.precipitation_unit === 'inch' ? 'inch' : 'mm' }), { location_label: location.location_label || '', name: location.name || '', timezone: location.timezone || '', temp_unit: temperatureUnit, wind_unit: windUnit, precip_unit: precipitationUnit });
|
||||
data.current = Object.assign({}, data.current || {}, { temperature_unit: temperatureUnit, wind_speed_unit: windUnit, precipitation_unit: precipitationUnit });
|
||||
data.daily = Object.assign({}, data.daily || {}, { temperature_unit: temperatureUnit });
|
||||
return { id: location.id, data: data };
|
||||
}).filter(Boolean);
|
||||
|
||||
function getApiItems(source) {
|
||||
const response = source && source.responseJson;
|
||||
@@ -375,6 +428,10 @@ async function captureSlideThumbnail(options) {
|
||||
const feed = rssFeeds.find(function (entry) { return Number(entry.id) === Number(regionContent.feed_id); });
|
||||
return feed && Array.isArray(feed.items) ? feed.items[index] || null : null;
|
||||
}
|
||||
if (type === 'weather') {
|
||||
const location = weatherLocations.find(function (entry) { return Number(entry.id) === Number(regionContent.weather_location_id); });
|
||||
return location ? location.data : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ module.exports = {
|
||||
renderRssFeedsPage: require(routePath('data-sources', 'rss-feeds', 'list')),
|
||||
renderRssFeedAddPage: require(routePath('data-sources', 'rss-feeds', 'add')),
|
||||
renderRssFeedEditPage: require(routePath('data-sources', 'rss-feeds', 'edit')),
|
||||
renderWeatherLocationsPage: require(routePath('data-sources', 'weather', 'list')),
|
||||
renderWeatherLocationAddPage: require(routePath('data-sources', 'weather', 'add')),
|
||||
renderWeatherLocationEditPage: require(routePath('data-sources', 'weather', 'edit')),
|
||||
renderScreensPage: require(routePath('signage', 'screens', 'list')),
|
||||
renderScreenFormPage: require(routePath('signage', 'screens', 'add')),
|
||||
renderScreenEditPage: require(routePath('signage', 'screens', 'edit')),
|
||||
|
||||
@@ -3279,4 +3279,55 @@ table.table thead th.sort-desc .table-sort-indicator {
|
||||
.card-body.table-responsive > table.table > thead th {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
.weather-preview-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.weather-preview-card-body {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.weather-preview-card-footer {
|
||||
min-height: 3.5rem;
|
||||
}
|
||||
|
||||
.weather-preview-icon {
|
||||
font-size: 4rem;
|
||||
color: #e0a11a;
|
||||
}
|
||||
|
||||
.weather-preview-forecast-icon {
|
||||
display: block;
|
||||
font-size: 2rem;
|
||||
color: #e0a11a;
|
||||
margin: 1rem 0 0.65rem;
|
||||
}
|
||||
|
||||
.weather-daily-forecast {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.weather-daily-forecast > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.weather-daily-forecast {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.weather-daily-forecast {
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.weather-preview-hourly-item {
|
||||
width: 7.5rem;
|
||||
}
|
||||
@@ -610,7 +610,7 @@
|
||||
} catch (_error) {
|
||||
actionPath = String(form.action || '');
|
||||
}
|
||||
var shouldReloadAfterSuccess = /^\/announcements\/\d+\/(?:play|stop)$/.test(actionPath);
|
||||
var shouldReloadAfterSuccess = /^\/announcements\/\d+\/(?:play|stop)$/.test(actionPath) || /^\/data-sources\/(?:api-sources|rss-feeds|weather)\/\d+$/.test(actionPath);
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
@@ -799,6 +799,28 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var refreshButton = target.closest('[data-manual-refresh-url]');
|
||||
if (refreshButton) {
|
||||
event.preventDefault();
|
||||
if (refreshButton.disabled) {
|
||||
return;
|
||||
}
|
||||
refreshButton.disabled = true;
|
||||
fetch(refreshButton.getAttribute('data-manual-refresh-url'), {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Refresh failed');
|
||||
}
|
||||
window.location.assign(response.url);
|
||||
}).catch(function () {
|
||||
refreshButton.disabled = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var button = target.closest('button[name="save_action"], button[name="action"]');
|
||||
if (!button) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var label = document.getElementById('weather-label');
|
||||
var results = document.getElementById('weather-location-results');
|
||||
var latitude = document.getElementById('weather-latitude');
|
||||
var longitude = document.getElementById('weather-longitude');
|
||||
var latitudeValue = document.getElementById('weather-latitude-value');
|
||||
var longitudeValue = document.getElementById('weather-longitude-value');
|
||||
var timezone = document.getElementById('weather-timezone');
|
||||
var manualCoordinates = document.getElementById('weather-manual-coordinates');
|
||||
var timer;
|
||||
var requestId = 0;
|
||||
|
||||
if (!label || !results || !latitude || !longitude || !timezone) {
|
||||
return;
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
results.replaceChildren();
|
||||
results.classList.add('d-none');
|
||||
}
|
||||
|
||||
function selectResult(result) {
|
||||
label.value = result.label;
|
||||
latitude.value = result.latitude;
|
||||
longitude.value = result.longitude;
|
||||
timezone.value = result.timezone;
|
||||
latitudeValue.value = result.latitude;
|
||||
longitudeValue.value = result.longitude;
|
||||
latitude.disabled = true;
|
||||
longitude.disabled = true;
|
||||
latitude.removeAttribute('name');
|
||||
longitude.removeAttribute('name');
|
||||
latitudeValue.name = 'latitude';
|
||||
longitudeValue.name = 'longitude';
|
||||
manualCoordinates.classList.remove('d-none');
|
||||
clearResults();
|
||||
}
|
||||
|
||||
function enableManualCoordinates() {
|
||||
latitude.disabled = false;
|
||||
longitude.disabled = false;
|
||||
latitude.name = 'latitude';
|
||||
longitude.name = 'longitude';
|
||||
latitudeValue.removeAttribute('name');
|
||||
longitudeValue.removeAttribute('name');
|
||||
manualCoordinates.classList.add('d-none');
|
||||
}
|
||||
|
||||
function renderResults(items) {
|
||||
results.replaceChildren();
|
||||
if (!items.length) {
|
||||
clearResults();
|
||||
return;
|
||||
}
|
||||
items.forEach(function (item) {
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'list-group-item list-group-item-action';
|
||||
button.textContent = item.label;
|
||||
button.addEventListener('click', function () { selectResult(item); });
|
||||
results.appendChild(button);
|
||||
});
|
||||
results.classList.remove('d-none');
|
||||
}
|
||||
|
||||
label.addEventListener('input', function () {
|
||||
enableManualCoordinates();
|
||||
window.clearTimeout(timer);
|
||||
var query = label.value.trim();
|
||||
if (query.length < 2) {
|
||||
clearResults();
|
||||
return;
|
||||
}
|
||||
timer = window.setTimeout(function () {
|
||||
var currentRequestId = ++requestId;
|
||||
fetch('/data-sources/weather/geocode?q=' + encodeURIComponent(query), { credentials: 'same-origin' })
|
||||
.then(function (response) { if (!response.ok) throw new Error('Lookup failed'); return response.json(); })
|
||||
.then(function (data) { if (currentRequestId === requestId) renderResults(data.results || []); })
|
||||
.catch(function () { if (currentRequestId === requestId) clearResults(); });
|
||||
}, 300);
|
||||
});
|
||||
|
||||
manualCoordinates.addEventListener('click', enableManualCoordinates);
|
||||
|
||||
if (latitude.value && longitude.value) {
|
||||
selectResult({ label: label.value, latitude: latitude.value, longitude: longitude.value, timezone: timezone.value });
|
||||
}
|
||||
|
||||
var refreshButton = document.querySelector('[data-weather-refresh-url]');
|
||||
if (refreshButton) {
|
||||
refreshButton.addEventListener('click', function () {
|
||||
refreshButton.disabled = true;
|
||||
fetch(refreshButton.getAttribute('data-weather-refresh-url'), { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||
.then(function (response) {
|
||||
if (!response.ok) throw new Error('Refresh failed');
|
||||
window.location.assign(response.url);
|
||||
})
|
||||
.catch(function () { refreshButton.disabled = false; });
|
||||
});
|
||||
}
|
||||
|
||||
var forecastButtons = document.querySelectorAll('[data-weather-forecast-mode]');
|
||||
var dailyForecast = document.getElementById('weather-daily-forecast');
|
||||
var hourlyForecast = document.getElementById('weather-hourly-forecast');
|
||||
forecastButtons.forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
var hourly = button.getAttribute('data-weather-forecast-mode') === 'hourly';
|
||||
dailyForecast.classList.toggle('d-none', hourly);
|
||||
hourlyForecast.classList.toggle('d-none', !hourly);
|
||||
forecastButtons.forEach(function (item) {
|
||||
var selected = item === button;
|
||||
item.classList.toggle('btn-primary', selected);
|
||||
item.classList.toggle('btn-outline-secondary', !selected);
|
||||
item.setAttribute('aria-pressed', selected ? 'true' : 'false');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
if (!results.contains(event.target) && event.target !== label) {
|
||||
clearResults();
|
||||
}
|
||||
});
|
||||
}());
|
||||
@@ -88,6 +88,7 @@
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
i: ['class', 'style', 'aria-hidden'],
|
||||
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
|
||||
col: ['class', 'style', 'span', 'width'],
|
||||
colgroup: ['class', 'style', 'span'],
|
||||
@@ -159,6 +160,7 @@
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
i: ['class', 'style', 'aria-hidden'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// Weather region helpers for editor previews and defaults.
|
||||
|
||||
(function () {
|
||||
var registry = window.pulseRegionTypes;
|
||||
var utils = window.pulseRegionUtils || {};
|
||||
var placeholderUtils = window.placeholderUtils || {};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value == null ? '' : value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function getLocationById(locationId, locations) {
|
||||
return (Array.isArray(locations) ? locations : []).find(function (location) {
|
||||
return Number(location.id) === Number(locationId);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function iconForCode(code) {
|
||||
var value = Number(code);
|
||||
if (!Number.isFinite(value)) return 'bi-cloud';
|
||||
if (value === 0) return 'bi-sun';
|
||||
if (value <= 3) return 'bi-cloud-sun';
|
||||
if (value <= 48) return 'bi-cloud-fog';
|
||||
if (value <= 67 || value > 77 && value <= 82) return 'bi-cloud-rain';
|
||||
if (value <= 77) return 'bi-cloud-snow';
|
||||
return 'bi-cloud-lightning-rain';
|
||||
}
|
||||
|
||||
function conditionForCode(code) {
|
||||
var value = Number(code);
|
||||
if (value === 0) return 'Clear sky';
|
||||
if (value <= 3) return 'Partly cloudy';
|
||||
if (value <= 48) return 'Foggy';
|
||||
if (value <= 67) return 'Rain';
|
||||
if (value <= 77) return 'Snow';
|
||||
if (value <= 82) return 'Showers';
|
||||
return 'Thunderstorm';
|
||||
}
|
||||
|
||||
function getSnapshot(location) {
|
||||
return location && location.responseJson && typeof location.responseJson === 'object' ? location.responseJson : null;
|
||||
}
|
||||
|
||||
function getDefaultStyle() {
|
||||
return { font_family: 'Arial', font_size: 32, font_color: '#000000' };
|
||||
}
|
||||
|
||||
function getPlaceholderFields(forecastMode) {
|
||||
var fields = ['location_label', 'name', 'temp_unit', 'wind_unit', 'precip_unit'];
|
||||
if (forecastMode === 'current') {
|
||||
return fields.concat(['current.temp', 'current.feels_like', 'current.humidity', 'current.wind', 'current.precip', 'current.uv_index', 'current.cloud_cover', 'current.icon']);
|
||||
}
|
||||
var prefix = forecastMode === 'hourly' ? 'hourly' : 'daily';
|
||||
var fieldsByMode = forecastMode === 'hourly' ? ['time', 'code', 'temp', 'precip', 'wind', 'uv_index', 'cloud_cover'] : ['time', 'code', 'temp_max', 'temp_min', 'precip', 'wind', 'uv_index', 'cloud_cover', 'sunrise', 'sunset'];
|
||||
var count = forecastMode === 'hourly' ? 24 : 7;
|
||||
for (var index = 0; index < count; index += 1) {
|
||||
fieldsByMode.forEach(function (field) { fields.push(prefix + '.' + index + '.' + field); });
|
||||
fields.push(prefix + '.' + index + '.icon');
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function renderLimitedPlaceholderChips(fields, visibleCount) {
|
||||
if (visibleCount >= fields.length) return fields.map(function (field) {
|
||||
return window.placeholderChips && typeof window.placeholderChips.renderChip === 'function'
|
||||
? window.placeholderChips.renderChip(field)
|
||||
: '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).join('');
|
||||
var visibleFields = fields.slice(0, visibleCount);
|
||||
var overflowFields = fields.slice(visibleFields.length);
|
||||
var renderChip = function (field) {
|
||||
return window.placeholderChips && typeof window.placeholderChips.renderChip === 'function'
|
||||
? window.placeholderChips.renderChip(field)
|
||||
: '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
};
|
||||
var visibleMarkup = visibleFields.map(renderChip).join('');
|
||||
if (!overflowFields.length) return visibleMarkup;
|
||||
return visibleMarkup + '<details class="w-100 mt-1" data-placeholder-chips-more><summary class="small text-body-secondary">Show ' + escapeHtml(overflowFields.length) + ' more fields</summary><div class="d-flex flex-wrap gap-2 mt-1">' + overflowFields.map(renderChip).join('') + '</div></details>';
|
||||
}
|
||||
|
||||
function normalizeWeatherExpression(expression) {
|
||||
var value = String(expression || '').trim();
|
||||
var currentAliases = {
|
||||
temp: 'current.temperature_2m', temp_unit: 'current.temperature_unit', feels_like: 'current.apparent_temperature', humidity: 'current.relative_humidity_2m', code: 'current.weather_code', wind: 'current.wind_speed_10m', wind_unit: 'current.wind_speed_unit', precip: 'current.precipitation', precip_unit: 'current.precipitation_unit', uv_index: 'current.uv_index', cloud_cover: 'current.cloud_cover', icon: 'current.weather_code.icon'
|
||||
};
|
||||
var globalAliases = { temp_unit: 'temperature_unit', wind_unit: 'wind_speed_unit', precip_unit: 'precipitation_unit' };
|
||||
if (globalAliases[value]) return globalAliases[value];
|
||||
var currentField = value.replace(/^current\./, '').match(/^([^.()]+)((?:\(.*\))|(?:\..*))?$/);
|
||||
if (currentField && currentAliases[currentField[1]]) return currentAliases[currentField[1]] + (currentField[2] || '');
|
||||
var indexed = value.match(/^(daily|hourly)\.(\d+)\.(.+)$/);
|
||||
if (!indexed) return value;
|
||||
var prefix = indexed[1];
|
||||
var index = indexed[2];
|
||||
var fieldMatch = indexed[3].match(/^([^.()]+)((?:\(.*\))|(?:\..*))?$/);
|
||||
var field = fieldMatch ? fieldMatch[1] : indexed[3];
|
||||
var aliases = prefix === 'daily' ? { time: 'time', code: 'weather_code', temp_max: 'temperature_2m_max', temp_min: 'temperature_2m_min', precip: 'precipitation_sum', wind: 'wind_speed_10m_max', uv_index: 'uv_index_max', cloud_cover: 'cloud_cover_mean', sunrise: 'sunrise', sunset: 'sunset', icon: 'weather_code' } : { time: 'time', code: 'weather_code', temp: 'temperature_2m', precip: 'precipitation', wind: 'wind_speed_10m', uv_index: 'uv_index', cloud_cover: 'cloud_cover', icon: 'weather_code' };
|
||||
if (!Object.prototype.hasOwnProperty.call(aliases, field)) return value;
|
||||
return field === 'icon' ? prefix + '.' + aliases[field] + '.' + index + '.icon' + (fieldMatch[2] || '') : prefix + '.' + aliases[field] + '.' + index + (fieldMatch[2] || '');
|
||||
}
|
||||
|
||||
function withWeatherUnits(snapshot, location) {
|
||||
var data = Object.assign({}, snapshot, { location_label: location.location_label || '', name: location.name || '', timezone: location.timezone || '', temp_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C', wind_unit: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h', precip_unit: location.precipitation_unit === 'inch' ? 'in' : 'mm' });
|
||||
var temperatureUnit = location.temperature_unit === 'fahrenheit' ? '°F' : '°C';
|
||||
var windUnit = location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h';
|
||||
var precipitationUnit = location.precipitation_unit === 'inch' ? 'in' : 'mm';
|
||||
data.current = Object.assign({}, snapshot.current || {}, { temperature_unit: temperatureUnit, wind_speed_unit: windUnit, precipitation_unit: precipitationUnit });
|
||||
data.daily = Object.assign({}, snapshot.daily || {}, { temperature_unit: temperatureUnit });
|
||||
data.hourly = Object.assign({}, snapshot.hourly || {}, { temperature_unit: temperatureUnit });
|
||||
return data;
|
||||
}
|
||||
|
||||
function substituteVariables(html, value) {
|
||||
return String(html || '').replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
||||
if (!value || typeof value !== 'object' || typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') return '';
|
||||
var rawExpression = String(expression).trim();
|
||||
var normalizedExpression = normalizeWeatherExpression(rawExpression);
|
||||
var iconMatch = normalizedExpression.match(/^(?:current\.weather_code|daily\.weather_code\.\d+|hourly\.weather_code\.\d+)\.icon(?:\((\d+)(?:\s*,\s*(\d+))?\))?$/);
|
||||
if (iconMatch) {
|
||||
var codeExpression = normalizedExpression.replace(/\.icon(?:\(.*\))?$/, '');
|
||||
var sizeExpression = rawExpression.match(/\.icon\((\d+)(?:\s*,\s*(\d+))?\)$/);
|
||||
var weatherCode = placeholderUtils.resolvePlaceholderExpression(value, codeExpression, { timeZone: value.timezone });
|
||||
var width = sizeExpression ? Math.max(1, Math.min(1000, Number(sizeExpression[1]))) : 0;
|
||||
var height = sizeExpression && sizeExpression[2] ? Math.max(1, Math.min(1000, Number(sizeExpression[2]))) : width;
|
||||
var style = width ? ' style="display:inline-block;vertical-align:middle;font-size:' + width + 'px;line-height:' + height + 'px;width:' + width + 'px;height:' + height + 'px;"' : '';
|
||||
var icon = '<i class="bi ' + iconForCode(weatherCode) + '"' + style + ' aria-hidden="true"></i>';
|
||||
return width ? '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + width + 'px;height:' + height + 'px;border:1px dashed rgba(120,120,120,.75);">' + icon + '</span>' : icon;
|
||||
}
|
||||
return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(value, normalizeWeatherExpression(expression), { timeZone: value.timezone })));
|
||||
});
|
||||
}
|
||||
|
||||
function renderTransformInfo(regionId) {
|
||||
var offcanvasId = 'weather-placeholder-info-' + regionId;
|
||||
return '<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" data-bs-toggle="offcanvas" data-bs-target="#' + offcanvasId + '" aria-controls="' + offcanvasId + '">More info</button>' +
|
||||
'<div class="offcanvas offcanvas-end fw-normal" tabindex="-1" id="' + offcanvasId + '" aria-labelledby="' + offcanvasId + '-label">' +
|
||||
'<div class="offcanvas-header"><h2 class="offcanvas-title fs-5" id="' + offcanvasId + '-label">Weather placeholder transforms</h2><button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button></div>' +
|
||||
'<div class="offcanvas-body"><p class="small text-body-secondary">Placeholders read values from the selected weather snapshot. Nested fields use dots.</p>' +
|
||||
'<h3 class="h6 mt-3">Weather icons</h3><p class="small text-body-secondary">Use the icon property to insert a Bootstrap weather icon. Add width and height in pixels when sizing is needed.</p><ul class="small"><li><code>{{current.icon}}</code></li><li><code>{{current.icon(48,48)}}</code></li><li><code>{{daily.0.icon(32,24)}}</code></li></ul>' +
|
||||
(window.placeholderInfo ? window.placeholderInfo.renderTextTransforms() : '') +
|
||||
(window.placeholderInfo ? window.placeholderInfo.renderDateFormatTokens() : '') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderPreview(region, content, context) {
|
||||
var locations = context && context.weatherLocations ? context.weatherLocations : [];
|
||||
var location = getLocationById(content && content.weather_location_id, locations);
|
||||
var snapshot = getSnapshot(location);
|
||||
var value = String(content && content.value || '');
|
||||
if (!location || !snapshot) return '<div class="template-region weather"><div class="slide-preview-placeholder">Select a weather location with cached data</div></div>';
|
||||
var weatherData = withWeatherUnits(snapshot, location);
|
||||
if (value) {
|
||||
var fontSize = Math.max(8, Number(content && content.font_size || region && (region.font_size || region.fontSize) || 32) || 32);
|
||||
return '<div class="template-region weather" style="width:100%;height:100%;overflow:hidden;font-family:Arial;font-size:' + fontSize + 'px;line-height:1.5;color:#000000;"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;">' + (utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(substituteVariables(value, weatherData)) : substituteVariables(value, weatherData)) + '</div></div>';
|
||||
}
|
||||
var current = snapshot.current || {};
|
||||
var daily = snapshot.daily || {};
|
||||
var code = current.weather_code;
|
||||
var days = (daily.time || []).slice(0, 7).map(function (day, index) {
|
||||
return '<div class="weather-region-day"><strong>' + escapeHtml(index === 0 ? 'Today' : String(day).slice(5)) + '</strong><i class="bi ' + iconForCode(daily.weather_code && daily.weather_code[index]) + '"></i><span>' + escapeHtml(daily.temperature_2m_max && daily.temperature_2m_max[index] !== undefined ? daily.temperature_2m_max[index] + '°' : '-') + '</span></div>';
|
||||
}).join('');
|
||||
return '<div class="template-region weather" style="width:100%;height:100%;overflow:hidden;padding:3%;font-family:Arial,sans-serif;"><div style="display:flex;align-items:center;justify-content:space-between;"><div><div style="font-size:.8em;opacity:.72;">' + escapeHtml(location.location_label || location.name) + '</div><strong style="font-size:2em;">' + escapeHtml(current.temperature_2m === undefined ? '-' : current.temperature_2m) + '°</strong><div>' + escapeHtml(conditionForCode(code)) + '</div></div><i class="bi ' + iconForCode(code) + '" style="font-size:3em;"></i></div><div style="display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:.35em;margin-top:1em;">' + days + '</div></div>';
|
||||
}
|
||||
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = context.current || {};
|
||||
var locations = Array.isArray(context.weatherLocations) ? context.weatherLocations : [];
|
||||
var options = locations.map(function (location) {
|
||||
return '<option value="' + escapeHtml(location.id) + '"' + (Number(location.id) === Number(current.weather_location_id) ? ' selected' : '') + '>' + escapeHtml(location.name || location.location_label || ('Location ' + location.id)) + '</option>';
|
||||
}).join('');
|
||||
var currentValue = current.value !== undefined ? current.value : current.text !== undefined ? current.text : '';
|
||||
var forecastMode = ['current', 'hourly'].includes(current.forecast_mode) ? current.forecast_mode : 'daily';
|
||||
var placeholderFields = getPlaceholderFields(forecastMode);
|
||||
var visiblePlaceholderCount = forecastMode === 'current' ? placeholderFields.length : 5 + (forecastMode === 'hourly' ? 8 : 11);
|
||||
var chips = renderLimitedPlaceholderChips(placeholderFields, visiblePlaceholderCount);
|
||||
return '<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + escapeHtml(region.id) + '"><div class="card-header template-field-head"><strong>' + escapeHtml(region.label) + '</strong><div class="template-field-actions"><span class="chip">Weather</span></div></div><div class="card-body p-3 d-grid gap-3"><div class="d-flex justify-content-end"><div class="btn-group btn-group-sm template-editor-size-controls" role="group" aria-label="Editor size controls"><button type="button" class="btn btn-outline-secondary" data-editor-size-action="decrease" aria-label="Decrease editor size">-</button><button type="button" class="btn btn-outline-secondary" data-editor-size-action="increase" aria-label="Increase editor size">+</button></div></div><div class="editor-holder" data-region-id="' + escapeHtml(region.id) + '"><textarea class="editor-source" rows="10">' + escapeHtml(currentValue) + '</textarea></div><div class="row g-3 align-items-end"><div class="col-12 col-md-8"><label class="form-label" for="region_weather_location_id_' + escapeHtml(region.id) + '">Weather location</label><select id="region_weather_location_id_' + escapeHtml(region.id) + '" name="region_weather_location_id_' + escapeHtml(region.id) + '" class="form-select"><option value="">Select a location</option>' + options + '</select></div><div class="col-12 col-md-4"><label class="form-label" for="region_weather_forecast_mode_' + escapeHtml(region.id) + '">Forecast</label><select id="region_weather_forecast_mode_' + escapeHtml(region.id) + '" name="region_weather_forecast_mode_' + escapeHtml(region.id) + '" class="form-select"><option value="current"' + (forecastMode === 'current' ? ' selected' : '') + '>Current</option><option value="daily"' + (forecastMode === 'daily' ? ' selected' : '') + '>7 days</option><option value="hourly"' + (forecastMode === 'hourly' ? ' selected' : '') + '>24 hour</option></select></div></div><input type="hidden" name="region_text_' + escapeHtml(region.id) + '" value="' + escapeHtml(currentValue) + '" /><div class="api-region-placeholder-section"><div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2">Available placeholders ' + renderTransformInfo(region.id) + '</div><div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + chips + '</div></div></div></div>';
|
||||
}
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.matches || !target.matches('select[name^="region_weather_forecast_mode_"]')) return;
|
||||
var card = target.closest('[data-region-id]');
|
||||
var chipContainer = card && card.querySelector('[data-placeholder-chips]');
|
||||
if (!chipContainer || !window.placeholderChips || typeof window.placeholderChips.renderChips !== 'function') return;
|
||||
var forecastMode = target.value === 'hourly' ? 'hourly' : target.value === 'current' ? 'current' : 'daily';
|
||||
var placeholderFields = getPlaceholderFields(forecastMode);
|
||||
var visiblePlaceholderCount = forecastMode === 'current' ? placeholderFields.length : 5 + (forecastMode === 'hourly' ? 8 : 11);
|
||||
chipContainer.innerHTML = renderLimitedPlaceholderChips(placeholderFields, visiblePlaceholderCount);
|
||||
});
|
||||
|
||||
function buildEditorCardContext(context) {
|
||||
return { region: context.region, current: context.current || {}, weatherLocations: context.weatherLocations || [] };
|
||||
}
|
||||
|
||||
function buildPreviewRenderContext(region, card, existingContent, locations) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
var input = card && card.querySelector('select[name="region_weather_location_id_' + region.id + '"]');
|
||||
var modeInput = card && card.querySelector('select[name="region_weather_forecast_mode_' + region.id + '"]');
|
||||
var textInput = card && card.querySelector('input[name="region_text_' + region.id + '"]');
|
||||
var textArea = card && card.querySelector('textarea.editor-source');
|
||||
return { value: textInput ? textInput.value : textArea ? textArea.value : current.value || '', weather_location_id: input ? input.value : current.weather_location_id, forecast_mode: modeInput ? modeInput.value : current.forecast_mode || 'daily', weatherLocations: locations || [] };
|
||||
}
|
||||
|
||||
registry.register('weather', { label: 'Weather', getDefaultStyle: getDefaultStyle, getDefaultRegionSize: function () { return { width: 720, height: 260 }; }, renderPreview: renderPreview, renderEditorCard: renderEditorCard, buildEditorCardContext: buildEditorCardContext, buildPreviewRenderContext: buildPreviewRenderContext });
|
||||
}());
|
||||
@@ -617,6 +617,8 @@ export function createSlideFormEditorController(options) {
|
||||
remove_script_host: false,
|
||||
convert_urls: true,
|
||||
paste_data_images: false,
|
||||
paste_block_drop: true,
|
||||
resize: false,
|
||||
automatic_uploads: true,
|
||||
images_file_types: imageUploadFileTypes,
|
||||
images_upload_handler: uploadEditorImage,
|
||||
|
||||
@@ -9,6 +9,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
var rssFeeds = Array.isArray(settings.rssFeeds) ? settings.rssFeeds : [];
|
||||
var apiSources = Array.isArray(settings.apiSources) ? settings.apiSources : [];
|
||||
var timetableGroups = Array.isArray(settings.timetableGroups) ? settings.timetableGroups : [];
|
||||
var weatherLocations = Array.isArray(settings.weatherLocations) ? settings.weatherLocations : [];
|
||||
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () { return null; };
|
||||
var requestPreviewRender = typeof settings.requestPreviewRender === 'function' ? settings.requestPreviewRender : function () {};
|
||||
var placeholderUtils = window.placeholderUtils || {};
|
||||
@@ -537,7 +538,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
if (module && typeof module.buildEditorCardContext === 'function') {
|
||||
return module.buildEditorCardContext({
|
||||
region: region,
|
||||
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' || region.region_type === 'timetable' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region),
|
||||
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' || region.region_type === 'timetable' || region.region_type === 'weather' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region),
|
||||
currentDuration: getCurrentRegionVideoDuration(region),
|
||||
regionRatio: reduceAspectRatio(region.width, region.height),
|
||||
uploadMaxLabel: uploadMaxLabel,
|
||||
@@ -546,7 +547,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
uploadVideoMaxBytes: uploadVideoMaxBytes,
|
||||
uploadMimeTypes: uploadMimeTypes,
|
||||
disableAudio: (existingContent[region.region_key] || {}).disable_audio,
|
||||
config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : getCurrentRssConfig(region),
|
||||
config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : region.region_type === 'weather' ? { weather_location_id: (existingContent[region.region_key] || {}).weather_location_id || '' } : getCurrentRssConfig(region),
|
||||
style: getCurrentTextStyle(region),
|
||||
fontSize: getCurrentTextStyle(region).font_size,
|
||||
feedOptions: rssFeeds.map(function (feed) {
|
||||
@@ -568,8 +569,9 @@ export function createSlideFormRegionHelpers(options) {
|
||||
? getRssFieldList(getCurrentRssConfig(region).feed_id)
|
||||
: [],
|
||||
sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path),
|
||||
timetableGroups: timetableGroups
|
||||
}, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources);
|
||||
timetableGroups: timetableGroups,
|
||||
weatherLocations: weatherLocations
|
||||
}, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : region.region_type === 'weather' ? weatherLocations : apiSources);
|
||||
}
|
||||
|
||||
var textStyle = getCurrentTextStyle(region);
|
||||
@@ -580,7 +582,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
|
||||
return {
|
||||
region: region,
|
||||
current: region.region_type === 'rss' || region.region_type === 'api' ? currentContent : getCurrentRegionValue(region),
|
||||
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'weather' ? currentContent : getCurrentRegionValue(region),
|
||||
currentDuration: getCurrentRegionVideoDuration(region),
|
||||
regionRatio: reduceAspectRatio(region.width, region.height),
|
||||
uploadMaxLabel: uploadMaxLabel,
|
||||
@@ -589,7 +591,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
uploadVideoMaxBytes: uploadVideoMaxBytes,
|
||||
uploadMimeTypes: uploadMimeTypes,
|
||||
disableAudio: currentContent.disable_audio,
|
||||
config: region.region_type === 'api' ? apiConfig : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : rssConfig,
|
||||
config: region.region_type === 'api' ? apiConfig : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : region.region_type === 'weather' ? { weather_location_id: currentContent.weather_location_id || '' } : rssConfig,
|
||||
style: textStyle,
|
||||
fontSize: textStyle.font_size,
|
||||
feedOptions: rssFeeds.map(function (feed) {
|
||||
@@ -609,7 +611,8 @@ export function createSlideFormRegionHelpers(options) {
|
||||
? getRssFieldList(rssConfig.feed_id)
|
||||
: [],
|
||||
sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath),
|
||||
timetableGroups: timetableGroups
|
||||
timetableGroups: timetableGroups,
|
||||
weatherLocations: weatherLocations
|
||||
};
|
||||
}
|
||||
|
||||
@@ -745,7 +748,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
function getPreviewRegionContent(card, region) {
|
||||
var module = getRegionTypeModule(region.region_type);
|
||||
if (module && typeof module.buildPreviewRenderContext === 'function') {
|
||||
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources);
|
||||
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : region.region_type === 'weather' ? weatherLocations : apiSources);
|
||||
}
|
||||
|
||||
var current = existingContent[region.region_key];
|
||||
@@ -813,7 +816,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
function buildPreviewRenderContext(region, card) {
|
||||
var module = getRegionTypeModule(region.region_type);
|
||||
if (module && typeof module.buildPreviewRenderContext === 'function') {
|
||||
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources);
|
||||
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : region.region_type === 'weather' ? weatherLocations : apiSources);
|
||||
}
|
||||
|
||||
var textStyle = getCurrentTextStyle(region);
|
||||
|
||||
@@ -30,6 +30,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
var rssFeeds = Array.isArray(slideEditorData.rssFeeds) ? slideEditorData.rssFeeds : [];
|
||||
var apiSources = Array.isArray(slideEditorData.apiSources) ? slideEditorData.apiSources : [];
|
||||
var timetableGroups = Array.isArray(slideEditorData.timetableGroups) ? slideEditorData.timetableGroups : [];
|
||||
var weatherLocations = Array.isArray(slideEditorData.weatherLocations) ? slideEditorData.weatherLocations : [];
|
||||
var fontStylesheetHref = String(slideEditorData.fontStylesheetHref || '').trim();
|
||||
var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null;
|
||||
var existingContent = slideEditorData.existingContent || {};
|
||||
@@ -139,6 +140,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
rssFeeds: rssFeeds,
|
||||
apiSources: apiSources,
|
||||
timetableGroups: timetableGroups,
|
||||
weatherLocations: weatherLocations,
|
||||
defaultFontSize: DEFAULT_FONT_SIZE,
|
||||
uploadMaxLabel: uploadMaxLabel,
|
||||
uploadVideoMaxLabel: uploadVideoMaxLabel,
|
||||
|
||||
@@ -4,6 +4,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { loadFontLibrary } = require('#src/web/lib/media/font-library');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { convertWeatherSnapshot } = require('#src/data/weather-units');
|
||||
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
|
||||
@@ -65,6 +66,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
const rssData = await common.fetchRssFeedsData(pool);
|
||||
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
|
||||
const timetableData = typeof common.fetchTimetablesData === 'function' ? await common.fetchTimetablesData(pool) : { timetableGroups: [] };
|
||||
const weatherData = typeof common.fetchWeatherLocationsData === 'function' ? await common.fetchWeatherLocationsData(pool) : { weatherLocations: [] };
|
||||
const appSettings = await fetchAppSettings(pool);
|
||||
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
|
||||
return Object.assign({}, source, {
|
||||
@@ -80,10 +82,16 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
}) });
|
||||
}));
|
||||
|
||||
return Object.assign(data, rssData, apiData, timetableData, {
|
||||
const weatherLocations = Array.isArray(weatherData.weatherLocations) ? weatherData.weatherLocations.map(function (location) {
|
||||
const responseJson = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(location.last_response_json) : null;
|
||||
return Object.assign({}, location, { responseJson: convertWeatherSnapshot(responseJson, { temperature: location.temperature_unit === 'fahrenheit' ? 'fahrenheit' : 'celsius', wind: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'ms' : 'kmh', precipitation: location.precipitation_unit === 'inch' ? 'inch' : 'mm' }) });
|
||||
}) : [];
|
||||
|
||||
return Object.assign(data, rssData, apiData, timetableData, weatherData, {
|
||||
rssFeeds: rssFeeds,
|
||||
apiSources: apiSources,
|
||||
timetableGroups: timetableData.timetableGroups || [],
|
||||
weatherLocations: weatherLocations,
|
||||
fontLibrary: loadFontLibrary(deps.uploadDir),
|
||||
uploadLimits: {
|
||||
imageMaxBytes: Number(appSettings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES,
|
||||
|
||||
@@ -15,13 +15,16 @@ module.exports = function registerDataSourceRoutes(app, deps) {
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
if (hasAnyPermission(req.currentUser, ['timetables.read', 'rss-feeds.read', 'api-sources.read'])) {
|
||||
if (hasAnyPermission(req.currentUser, ['timetables.read', 'rss-feeds.read', 'api-sources.read', 'weather.read'])) {
|
||||
if (hasAnyPermission(req.currentUser, ['timetables.read'])) {
|
||||
return res.redirect('/data-sources/timetables');
|
||||
}
|
||||
if (hasAnyPermission(req.currentUser, ['rss-feeds.read'])) {
|
||||
return res.redirect('/data-sources/rss-feeds');
|
||||
}
|
||||
if (hasAnyPermission(req.currentUser, ['weather.read'])) {
|
||||
return res.redirect('/data-sources/weather');
|
||||
}
|
||||
return res.redirect('/data-sources/api-sources');
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
function buildDefaultApiSource() {
|
||||
return {
|
||||
id: null,
|
||||
enabled: 1,
|
||||
name: '',
|
||||
apiUrl: '',
|
||||
requestMethod: 'GET',
|
||||
|
||||
@@ -288,6 +288,19 @@ module.exports = function registerApiSourceRoutes(app, deps) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
if (String(req.body && req.body.data_source_action || '').trim() === 'toggle') {
|
||||
const enabled = apiSource.enabled === 0 || apiSource.enabled === false ? 1 : 0;
|
||||
await connection.query('UPDATE i_api_sources SET enabled = ?, modified_by = ? WHERE id = ?', [enabled, getAuditUserId(req), apiSource.id]);
|
||||
if (enabled) {
|
||||
dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, apiSource.name, apiSource.update_interval_value, apiSource.update_interval_unit, function () {
|
||||
return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, getAuditUserId(req));
|
||||
});
|
||||
} else {
|
||||
dataSourceTasks.removeRecurringRefresh('api-source', apiSource.id);
|
||||
}
|
||||
return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent(enabled ? 'API source enabled.' : 'API source disabled.'));
|
||||
}
|
||||
|
||||
const payload = common.buildApiSourcePayload(req, apiSource);
|
||||
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name, apiSource.id)) {
|
||||
return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('An API source with that name already exists.'));
|
||||
@@ -295,35 +308,21 @@ module.exports = function registerApiSourceRoutes(app, deps) {
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE i_api_sources SET name = ?, api_url = ?, request_method = ?, request_body_json = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, token_url = ?, token_request_body_json = ?, token_response_path = ?, token_header_name = ?, token_header_prefix = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.apiUrl, payload.requestMethod, payload.requestBodyJson || null, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.tokenUrl || null, payload.tokenRequestBodyJson || null, payload.tokenResponsePath || null, payload.tokenHeaderName || null, payload.tokenHeaderPrefix || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
|
||||
'UPDATE i_api_sources SET name = ?, api_url = ?, request_method = ?, request_body_json = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, token_url = ?, token_request_body_json = ?, token_response_path = ?, token_header_name = ?, token_header_prefix = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.apiUrl, payload.requestMethod, payload.requestBodyJson || null, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.tokenUrl || null, payload.tokenRequestBodyJson || null, payload.tokenResponsePath || null, payload.tokenHeaderName || null, payload.tokenHeaderPrefix || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, actorId, apiSource.id]
|
||||
);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, actorId);
|
||||
});
|
||||
const message = 'API source updated. Refresh is running in the background.';
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'api-source-refresh:' + apiSource.id,
|
||||
title: 'API source refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: apiSource.id,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: apiSource.id,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
redirectAfterSave(req, res, '/data-sources/api-sources/' + apiSource.id + '/edit?refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''), {
|
||||
if (apiSource.enabled === 0 || apiSource.enabled === false) {
|
||||
dataSourceTasks.removeRecurringRefresh('api-source', apiSource.id);
|
||||
} else {
|
||||
dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, actorId);
|
||||
});
|
||||
}
|
||||
redirectAfterSave(req, res, '/data-sources/api-sources/' + apiSource.id + '/edit', {
|
||||
closeUrl: '/data-sources/api-sources',
|
||||
newUrl: '/data-sources/api-sources/new',
|
||||
message: message
|
||||
message: 'API source updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
@@ -337,6 +336,18 @@ module.exports = function registerApiSourceRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources/:id/refresh', requirePermission('api-sources.allow'), async function (req, res, next) {
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) return res.status(404).send('API source not found');
|
||||
if (apiSource.enabled === 0 || apiSource.enabled === false) {
|
||||
return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('API source is disabled.'));
|
||||
}
|
||||
await backgroundTaskQueue.enqueueTask({ key: 'api-source-refresh:' + apiSource.id, title: 'API source refresh', category: 'data-source', taskType: 'data-source-refresh', payload: { sourceType: 'api-source', sourceId: apiSource.id, sourceName: apiSource.name, actorId: getAuditUserId(req) }, metadata: { sourceType: 'api-source', sourceId: apiSource.id, sourceName: apiSource.name } });
|
||||
res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('API source refresh queued.'));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources/:id/delete', requirePermission('api-sources.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
function buildDefaultRssFeed() {
|
||||
return {
|
||||
id: null,
|
||||
enabled: 1,
|
||||
name: '',
|
||||
feedUrl: '',
|
||||
updateIntervalValue: 60,
|
||||
|
||||
@@ -249,6 +249,19 @@ module.exports = function registerRssFeedRoutes(app, deps) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
if (String(req.body && req.body.data_source_action || '').trim() === 'toggle') {
|
||||
const enabled = rssFeed.enabled === 0 || rssFeed.enabled === false ? 1 : 0;
|
||||
await connection.query('UPDATE i_rss_feeds SET enabled = ?, modified_by = ? WHERE id = ?', [enabled, getAuditUserId(req), rssFeed.id]);
|
||||
if (enabled) {
|
||||
dataSourceTasks.registerRecurringRefresh('rss-feed', rssFeed.id, rssFeed.name, rssFeed.update_interval_value, rssFeed.update_interval_unit, function () {
|
||||
return dataSourceTasks.refreshRssFeedInBackground(rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, getAuditUserId(req));
|
||||
});
|
||||
} else {
|
||||
dataSourceTasks.removeRecurringRefresh('rss-feed', rssFeed.id);
|
||||
}
|
||||
return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent(enabled ? 'RSS feed enabled.' : 'RSS feed disabled.'));
|
||||
}
|
||||
|
||||
const payload = common.buildRssFeedPayload(req, rssFeed);
|
||||
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name, rssFeed.id)) {
|
||||
return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
|
||||
@@ -260,31 +273,17 @@ module.exports = function registerRssFeedRoutes(app, deps) {
|
||||
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, rssFeed.id]
|
||||
);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId);
|
||||
});
|
||||
const message = 'RSS feed updated. Refresh is running in the background.';
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'rss-feed-refresh:' + rssFeed.id,
|
||||
title: 'RSS feed refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: rssFeed.id,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: rssFeed.id,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
redirectAfterSave(req, res, '/data-sources/rss-feeds/' + rssFeed.id + '/edit?refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''), {
|
||||
if (rssFeed.enabled === 0 || rssFeed.enabled === false) {
|
||||
dataSourceTasks.removeRecurringRefresh('rss-feed', rssFeed.id);
|
||||
} else {
|
||||
dataSourceTasks.registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId);
|
||||
});
|
||||
}
|
||||
redirectAfterSave(req, res, '/data-sources/rss-feeds/' + rssFeed.id + '/edit', {
|
||||
closeUrl: '/data-sources/rss-feeds',
|
||||
newUrl: '/data-sources/rss-feeds/new',
|
||||
message: message
|
||||
message: 'RSS feed updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
@@ -298,6 +297,18 @@ module.exports = function registerRssFeedRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/data-sources/rss-feeds/:id/refresh', requirePermission('rss-feeds.allow'), async function (req, res, next) {
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) return res.status(404).send('RSS feed not found');
|
||||
if (rssFeed.enabled === 0 || rssFeed.enabled === false) {
|
||||
return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('RSS feed is disabled.'));
|
||||
}
|
||||
await backgroundTaskQueue.enqueueTask({ key: 'rss-feed-refresh:' + rssFeed.id, title: 'RSS feed refresh', category: 'data-source', taskType: 'data-source-refresh', payload: { sourceType: 'rss-feed', sourceId: rssFeed.id, sourceName: rssFeed.name, actorId: getAuditUserId(req) }, metadata: { sourceType: 'rss-feed', sourceId: rssFeed.id, sourceName: rssFeed.name } });
|
||||
res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('RSS feed refresh queued.'));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.post('/data-sources/rss-feeds/:id/delete', requirePermission('rss-feeds.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Weather location source CRUD routes.
|
||||
|
||||
const { buildPagination } = require('../../lib/pagination');
|
||||
const renderWeatherLocationsPage = require('./weather/list');
|
||||
const renderWeatherLocationAddPage = require('./weather/add');
|
||||
const renderWeatherLocationEditPage = require('./weather/edit');
|
||||
const { fetchAppSettings } = require('../../../data/app-settings');
|
||||
|
||||
async function getWeatherLocationUsageIds(pool, common) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
const weatherLocationIds = new Set();
|
||||
slides.forEach(function (slide) {
|
||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slide.content_json) : null;
|
||||
if (!content || typeof content !== 'object') return;
|
||||
(function walk(value) {
|
||||
if (!value || typeof value !== 'object') return;
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(walk);
|
||||
return;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'weather_location_id')) {
|
||||
const locationId = Number(value.weather_location_id);
|
||||
if (Number.isFinite(locationId)) weatherLocationIds.add(locationId);
|
||||
}
|
||||
Object.keys(value).forEach(function (key) { walk(value[key]); });
|
||||
}(content));
|
||||
});
|
||||
return weatherLocationIds;
|
||||
}
|
||||
|
||||
async function isWeatherLocationInUse(pool, common, locationId) {
|
||||
const usageIds = await getWeatherLocationUsageIds(pool, common);
|
||||
return usageIds.has(Number(locationId));
|
||||
}
|
||||
|
||||
module.exports = function registerWeatherRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const redirectAfterSave = deps.redirectAfterSave;
|
||||
const dataSourceTasks = deps.dataSourceTasks;
|
||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||
const requirePermission = deps.requirePermission;
|
||||
const listPageSize = 25;
|
||||
async function getProviderAvailability() {
|
||||
const settings = await fetchAppSettings(pool);
|
||||
return { openMeteo: true, pirateWeather: Boolean(String(settings['weather.pirate_weather_api_key'] || '').trim()) };
|
||||
}
|
||||
|
||||
app.get('/data-sources/weather', requirePermission('weather.read'), async function (req, res, next) {
|
||||
try {
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const search = common.getSearchQuery(req);
|
||||
const sort = common.getSortQuery(req);
|
||||
const direction = common.getSortDirectionQuery(req);
|
||||
const data = await common.fetchWeatherLocationsPage(pool, page, listPageSize, search, sort, direction);
|
||||
const usageIds = await getWeatherLocationUsageIds(pool, common);
|
||||
res.send(renderWeatherLocationsPage({
|
||||
weatherLocations: data.rows.map(function (location) { return Object.assign({}, location, { inUse: usageIds.has(Number(location.id)) }); }),
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search, sort, direction }, listPageSize, 'weather locations', 'weather location pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.get('/data-sources/weather/new', requirePermission('weather.create'), function (req, res) {
|
||||
getProviderAvailability().then(function (providerAvailability) {
|
||||
res.send(renderWeatherLocationAddPage({}, req.query.message ? String(req.query.message) : '', req.currentUser, { providerAvailability: providerAvailability }));
|
||||
}).catch(next);
|
||||
});
|
||||
|
||||
app.get('/data-sources/weather/geocode', requirePermission('weather.read'), async function (req, res, next) {
|
||||
try {
|
||||
const results = await common.fetchWeatherLocationSuggestions(req.query.q);
|
||||
res.json({ results: results });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.get('/data-sources/weather/:id/edit', requirePermission('weather.update'), async function (req, res, next) {
|
||||
try {
|
||||
const location = await common.fetchWeatherLocationById(pool, Number(req.params.id));
|
||||
if (!location) return res.status(404).send('Weather location not found');
|
||||
const providerAvailability = await getProviderAvailability();
|
||||
const inUse = await isWeatherLocationInUse(pool, common, location.id);
|
||||
res.send(renderWeatherLocationEditPage(Object.assign({}, location, { inUse: inUse }), req.query.message ? String(req.query.message) : '', req.currentUser, { providerAvailability: providerAvailability }));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.post('/data-sources/weather', requirePermission('weather.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const payload = common.buildWeatherLocationPayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'i_weather_locations', payload.name)) {
|
||||
return res.redirect('/data-sources/weather/new?message=' + encodeURIComponent('A weather location with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query('INSERT INTO i_weather_locations (name, location_label, latitude, longitude, timezone, provider, temperature_unit, wind_unit, precipitation_unit, update_interval_value, update_interval_unit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [payload.name, payload.locationLabel, payload.latitude, payload.longitude, payload.timezone, payload.provider, payload.temperatureUnit, payload.windUnit, payload.precipitationUnit, payload.updateIntervalValue, payload.updateIntervalUnit, actorId, actorId]);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('weather-location', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshWeatherLocationInBackground(result.insertId, null);
|
||||
});
|
||||
await backgroundTaskQueue.enqueueTask({ key: 'weather-location-refresh:' + result.insertId, title: 'Weather location refresh', category: 'data-source', taskType: 'data-source-refresh', payload: { sourceType: 'weather-location', sourceId: result.insertId, sourceName: payload.name, actorId: actorId }, metadata: { sourceType: 'weather-location', sourceId: result.insertId, sourceName: payload.name } });
|
||||
redirectAfterSave(req, res, '/data-sources/weather/' + result.insertId + '/edit', { closeUrl: '/data-sources/weather', newUrl: '/data-sources/weather/new', message: 'Weather location created.' });
|
||||
} catch (error) {
|
||||
try { await connection.rollback(); } catch (_rollbackError) { }
|
||||
next(error);
|
||||
} finally { connection.release(); }
|
||||
});
|
||||
|
||||
app.post('/data-sources/weather/:id', requirePermission('weather.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const location = await common.fetchWeatherLocationById(pool, Number(req.params.id));
|
||||
if (!location) return res.status(404).send('Weather location not found');
|
||||
if (String(req.body && req.body.data_source_action || '').trim() === 'toggle') {
|
||||
const enabled = location.enabled === 0 || location.enabled === false ? 1 : 0;
|
||||
await connection.query('UPDATE i_weather_locations SET enabled = ?, modified_by = ? WHERE id = ?', [enabled, getAuditUserId(req), location.id]);
|
||||
if (enabled) {
|
||||
dataSourceTasks.registerRecurringRefresh('weather-location', location.id, location.name, location.update_interval_value, location.update_interval_unit, function () {
|
||||
return dataSourceTasks.refreshWeatherLocationInBackground(location.id, getAuditUserId(req));
|
||||
});
|
||||
} else {
|
||||
dataSourceTasks.removeRecurringRefresh('weather-location', location.id);
|
||||
}
|
||||
return res.redirect('/data-sources/weather/' + location.id + '/edit?message=' + encodeURIComponent(enabled ? 'Weather location enabled.' : 'Weather location disabled.'));
|
||||
}
|
||||
const payload = common.buildWeatherLocationPayload(req, location);
|
||||
if (await common.fetchDuplicateName(pool, 'i_weather_locations', payload.name, location.id)) {
|
||||
return res.redirect('/data-sources/weather/' + location.id + '/edit?message=' + encodeURIComponent('A weather location with that name already exists.'));
|
||||
}
|
||||
await connection.beginTransaction();
|
||||
await connection.query('UPDATE i_weather_locations SET name = ?, location_label = ?, latitude = ?, longitude = ?, timezone = ?, provider = ?, temperature_unit = ?, wind_unit = ?, precipitation_unit = ?, update_interval_value = ?, update_interval_unit = ?, modified_by = ? WHERE id = ?', [payload.name, payload.locationLabel, payload.latitude, payload.longitude, payload.timezone, payload.provider, payload.temperatureUnit, payload.windUnit, payload.precipitationUnit, payload.updateIntervalValue, payload.updateIntervalUnit, getAuditUserId(req), location.id]);
|
||||
await connection.commit();
|
||||
if (location.enabled === 0 || location.enabled === false) {
|
||||
dataSourceTasks.removeRecurringRefresh('weather-location', location.id);
|
||||
} else {
|
||||
dataSourceTasks.registerRecurringRefresh('weather-location', location.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshWeatherLocationInBackground(location.id, null);
|
||||
});
|
||||
}
|
||||
redirectAfterSave(req, res, '/data-sources/weather/' + location.id + '/edit', { closeUrl: '/data-sources/weather', newUrl: '/data-sources/weather/new', message: 'Weather location updated.' });
|
||||
} catch (error) {
|
||||
try { await connection.rollback(); } catch (_rollbackError) { }
|
||||
next(error);
|
||||
} finally { connection.release(); }
|
||||
});
|
||||
|
||||
app.post('/data-sources/weather/:id/refresh', requirePermission('weather.allow'), async function (req, res, next) {
|
||||
try {
|
||||
const location = await common.fetchWeatherLocationById(pool, Number(req.params.id));
|
||||
if (!location) return res.status(404).send('Weather location not found');
|
||||
if (location.enabled === 0 || location.enabled === false) {
|
||||
return res.redirect('/data-sources/weather/' + location.id + '/edit?message=' + encodeURIComponent('Weather location is disabled.'));
|
||||
}
|
||||
await backgroundTaskQueue.enqueueTask({ key: 'weather-location-refresh:' + location.id, title: 'Weather location refresh', category: 'data-source', taskType: 'data-source-refresh', payload: { sourceType: 'weather-location', sourceId: location.id, sourceName: location.name, actorId: getAuditUserId(req) }, metadata: { sourceType: 'weather-location', sourceId: location.id, sourceName: location.name } });
|
||||
res.redirect('/data-sources/weather/' + location.id + '/edit?message=' + encodeURIComponent('Weather refresh queued.'));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.post('/data-sources/weather/:id/delete', requirePermission('weather.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const location = await common.fetchWeatherLocationById(pool, Number(req.params.id));
|
||||
if (!location) return res.status(404).send('Weather location not found');
|
||||
if (await isWeatherLocationInUse(pool, common, location.id)) {
|
||||
return res.redirect('/data-sources/weather?message=' + encodeURIComponent('This weather location is still used by one or more slides.'));
|
||||
}
|
||||
await pool.query('DELETE FROM i_weather_locations WHERE id = ?', [location.id]);
|
||||
dataSourceTasks.removeRecurringRefresh('weather-location', location.id);
|
||||
res.redirect('/data-sources/weather?message=' + encodeURIComponent('Weather location deleted.'));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
const { renderView } = require('../../../view');
|
||||
const buildWeatherLocationFormViewModel = require('./form-view-model');
|
||||
module.exports = function renderWeatherLocationAddPage(location, message, currentUser, options) { return renderView('data-sources/weather/form', buildWeatherLocationFormViewModel(location, message, currentUser, false, options)); };
|
||||
@@ -0,0 +1,3 @@
|
||||
const { renderView } = require('../../../view');
|
||||
const buildWeatherLocationFormViewModel = require('./form-view-model');
|
||||
module.exports = function renderWeatherLocationEditPage(location, message, currentUser, options) { return renderView('data-sources/weather/form', buildWeatherLocationFormViewModel(location, message, currentUser, true, options)); };
|
||||
@@ -0,0 +1,69 @@
|
||||
const { convertWeatherSnapshot } = require('../../../../data/weather-units');
|
||||
|
||||
function buildDefaultWeatherLocation() {
|
||||
return { id: null, enabled: 1, name: '', location_label: '', latitude: '', longitude: '', timezone: 'Europe/London', provider: 'open-meteo', temperature_unit: 'celsius', wind_unit: 'kmh', precipitation_unit: 'mm', update_interval_value: 30, update_interval_unit: 'minutes' };
|
||||
}
|
||||
|
||||
function weatherIconForCode(code) {
|
||||
const value = Number(code);
|
||||
if (!Number.isFinite(value)) return 'bi-cloud';
|
||||
if (value === 0) return 'bi-sun';
|
||||
if (value <= 3) return 'bi-cloud-sun';
|
||||
if (value <= 48) return 'bi-cloud-fog';
|
||||
if (value <= 67) return 'bi-cloud-rain';
|
||||
if (value <= 77) return 'bi-cloud-snow';
|
||||
if (value <= 82) return 'bi-cloud-rain';
|
||||
return 'bi-cloud-lightning-rain';
|
||||
}
|
||||
|
||||
function buildWeatherPreview(location) {
|
||||
const preview = {
|
||||
hasSnapshot: false,
|
||||
forecast: [{ day: 'Today', condition: '-', icon: 'bi-cloud', high: '-', low: '-', rain: '-' }, { day: 'Tomorrow', condition: '-', icon: 'bi-cloud', high: '-', low: '-', rain: '-' }, { day: 'Day 3', condition: '-', icon: 'bi-cloud', high: '-', low: '-', rain: '-' }, { day: 'Day 4', condition: '-', icon: 'bi-cloud', high: '-', low: '-', rain: '-' }, { day: 'Day 5', condition: '-', icon: 'bi-cloud', high: '-', low: '-', rain: '-' }, { day: 'Day 6', condition: '-', icon: 'bi-cloud', high: '-', low: '-', rain: '-' }, { day: 'Day 7', condition: '-', icon: 'bi-cloud', high: '-', low: '-', rain: '-' }],
|
||||
hourly: Array.from({ length: 24 }, function () { return { day: '--:--', icon: 'bi-cloud', temperature: '-', rain: '-' }; }),
|
||||
};
|
||||
let snapshot;
|
||||
try {
|
||||
snapshot = JSON.parse(location && location.last_response_json || '');
|
||||
} catch (_error) {
|
||||
return preview;
|
||||
}
|
||||
if (!snapshot || typeof snapshot !== 'object') return preview;
|
||||
const displaySnapshot = convertWeatherSnapshot(snapshot, { temperature: location.temperature_unit === 'fahrenheit' ? 'fahrenheit' : 'celsius', wind: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'ms' : 'kmh', precipitation: location.precipitation_unit === 'inch' ? 'inch' : 'mm' });
|
||||
const current = displaySnapshot.current || {};
|
||||
const daily = displaySnapshot.daily || {};
|
||||
const hourly = displaySnapshot.hourly || {};
|
||||
const weatherCode = Number(current.weather_code);
|
||||
preview.hasSnapshot = true;
|
||||
preview.location = location.location_label;
|
||||
preview.temperatureUnit = location.temperature_unit === 'fahrenheit' ? '°F' : '°C';
|
||||
preview.windUnit = location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h';
|
||||
preview.precipitationUnit = location.precipitation_unit === 'inch' ? 'in' : 'mm';
|
||||
preview.temperature = current.temperature_2m ?? current.temperature ?? '-';
|
||||
preview.feelsLike = current.apparent_temperature ?? current.feels_like ?? '-';
|
||||
preview.icon = weatherIconForCode(weatherCode);
|
||||
preview.condition = weatherCode === 0 ? 'Clear sky' : weatherCode <= 3 ? 'Partly cloudy' : weatherCode <= 48 ? 'Foggy' : weatherCode <= 67 ? 'Rain' : weatherCode <= 77 ? 'Snow' : weatherCode <= 82 ? 'Showers' : 'Thunderstorm';
|
||||
preview.humidity = current.relative_humidity_2m === undefined ? '-' : current.relative_humidity_2m + '%';
|
||||
preview.wind = current.wind_speed_10m ?? current.windSpeed ?? '-';
|
||||
preview.precipitation = current.precipitation ?? '-';
|
||||
preview.uvIndex = current.uv_index ?? '-';
|
||||
preview.cloudCover = current.cloud_cover ?? '-';
|
||||
preview.sunrise = daily.sunrise && daily.sunrise[0] ? String(daily.sunrise[0]).slice(11, 16) : '-';
|
||||
preview.sunset = daily.sunset && daily.sunset[0] ? String(daily.sunset[0]).slice(11, 16) : '-';
|
||||
preview.fetchedAt = location.last_pulled_at || '';
|
||||
preview.forecast = (daily.time || []).slice(0, 7).map(function (day, index) {
|
||||
const code = Number(daily.weather_code && daily.weather_code[index]);
|
||||
return { day: index === 0 ? 'Today' : day, condition: code <= 3 ? 'Partly cloudy' : code <= 67 ? 'Rain' : code <= 77 ? 'Snow' : 'Showers', icon: weatherIconForCode(code), high: daily.temperature_2m_max && daily.temperature_2m_max[index] !== undefined ? daily.temperature_2m_max[index] : '-', low: daily.temperature_2m_min && daily.temperature_2m_min[index] !== undefined ? daily.temperature_2m_min[index] : '-', rain: daily.precipitation_sum && daily.precipitation_sum[index] !== undefined ? daily.precipitation_sum[index] : '-', wind: daily.wind_speed_10m_max && daily.wind_speed_10m_max[index] !== undefined ? daily.wind_speed_10m_max[index] : '-', uvIndex: daily.uv_index_max && daily.uv_index_max[index] !== undefined ? daily.uv_index_max[index] : '-' };
|
||||
});
|
||||
preview.hourly = (hourly.time || []).slice(0, 24).map(function (time, index) {
|
||||
return { day: String(time).slice(11, 16), icon: weatherIconForCode(hourly.weather_code && hourly.weather_code[index]), temperature: hourly.temperature_2m && hourly.temperature_2m[index] !== undefined ? hourly.temperature_2m[index] : '-', rain: hourly.precipitation_probability && hourly.precipitation_probability[index] !== undefined ? hourly.precipitation_probability[index] + '%' : '-' };
|
||||
});
|
||||
return preview;
|
||||
}
|
||||
|
||||
module.exports = function buildWeatherLocationFormViewModel(location, message, currentUser, isEdit, options) {
|
||||
const weatherLocation = Object.assign(buildDefaultWeatherLocation(), location || {});
|
||||
const providerAvailability = options && options.providerAvailability ? options.providerAvailability : { openMeteo: true, pirateWeather: false };
|
||||
const formAction = isEdit ? '/data-sources/weather/' + weatherLocation.id : '/data-sources/weather';
|
||||
return { title: isEdit ? 'Edit weather location' : 'Add weather location', active: 'weather', message, currentUser: currentUser || null, weatherLocation, weatherPreview: buildWeatherPreview(weatherLocation), providerAvailability, isEdit: Boolean(isEdit), formAction: formAction, formAttrs: isEdit ? 'data-async-save data-async-save-close-url="/data-sources/weather" data-async-save-new-url="/data-sources/weather/new"' : 'data-async-save data-async-save-new-redirect="response-url" data-async-save-close-url="/data-sources/weather" data-async-save-new-url="/data-sources/weather/new"', cancelUrl: '/data-sources/weather', deleteUrl: isEdit ? '/data-sources/weather/' + weatherLocation.id + '/delete' : '', deleteDisabled: !isEdit || Boolean(weatherLocation.inUse), showSaveSecondaryActions: true, scripts: ['js/data-sources/weather-location-form.js'] };
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function formatInterval(value, unit) {
|
||||
const amount = Math.max(1, Number(value) || 0);
|
||||
const normalized = String(unit || 'minutes');
|
||||
return 'Every ' + amount + ' ' + normalized.replace(/s$/, '') + (amount === 1 ? '' : 's');
|
||||
}
|
||||
|
||||
module.exports = function renderWeatherLocationsPage(data, message, currentUser) {
|
||||
return renderView('data-sources/weather/list', {
|
||||
title: 'Weather locations', active: 'weather', message, currentUser: currentUser || null,
|
||||
weatherLocations: (data.weatherLocations || []).map(function (location) {
|
||||
return Object.assign({}, location, { intervalLabel: formatInterval(location.update_interval_value, location.update_interval_unit) });
|
||||
}), pagination: data.pagination || null
|
||||
});
|
||||
};
|
||||
@@ -13,6 +13,7 @@ const registerRootDataSourceRoutes = require('./admin/data-sources');
|
||||
const registerApiSourceRoutes = require('./data-sources/api-sources/routes');
|
||||
const registerRssFeedRoutes = require('./data-sources/rss-feeds/routes');
|
||||
const registerTimetableRoutes = require('./data-sources/timetables/routes');
|
||||
const registerWeatherRoutes = require('./data-sources/weather');
|
||||
const registerSettingsRoutes = require('./settings/background-tasks');
|
||||
const registerFontRoutes = require('./settings/fonts');
|
||||
const registerSettingsPageRoutes = require('./settings/routes');
|
||||
@@ -282,6 +283,16 @@ function registerSettingsAndContentRoutes(app, deps) {
|
||||
requirePermission: deps.requirePermission
|
||||
});
|
||||
|
||||
registerWeatherRoutes(app, {
|
||||
pool: deps.pool,
|
||||
common: deps.common,
|
||||
getAuditUserId: deps.getAuditUserId,
|
||||
redirectAfterSave: deps.redirectAfterSave,
|
||||
dataSourceTasks: deps.dataSourceTasks,
|
||||
backgroundTaskQueue: deps.backgroundTaskQueue,
|
||||
requirePermission: deps.requirePermission
|
||||
});
|
||||
|
||||
registerSettingsRoutes(app, {
|
||||
pages: deps.pages,
|
||||
backgroundTaskQueue: deps.backgroundTaskQueue,
|
||||
|
||||
@@ -58,9 +58,18 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
],
|
||||
audit: ['audit.enabled', 'audit.categories', 'audit.include_request_metadata', 'audit.retention_days'],
|
||||
media: ['uploads.image_max_bytes', 'uploads.video_max_bytes', 'uploads.wysiwyg_image_max_bytes', 'uploads.allowed_mime_types'],
|
||||
weather: ['weather.open_meteo_api_key', 'weather.pirate_weather_api_key'],
|
||||
icons: ['announcements.suggested_icons']
|
||||
};
|
||||
|
||||
function maskApiKey(value) {
|
||||
const key = String(value || '');
|
||||
if (!key) {
|
||||
return '';
|
||||
}
|
||||
return key.length <= 8 ? 'Configured' : key.slice(0, 4) + '...' + key.slice(-4);
|
||||
}
|
||||
|
||||
function buildSettingChanges(previousSettings, nextSettings, keys) {
|
||||
const changes = {};
|
||||
(Array.isArray(keys) ? keys : []).forEach(function (key) {
|
||||
@@ -157,6 +166,10 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
,rssDefaultIntervalUnit: String(settings['data-sources.rss_default_interval_unit'] || 'minutes')
|
||||
,apiDefaultIntervalValue: Number(settings['data-sources.api_default_interval_value']) || 60
|
||||
,apiDefaultIntervalUnit: String(settings['data-sources.api_default_interval_unit'] || 'minutes')
|
||||
,weatherPirateWeatherConfigured: Boolean(settings['weather.pirate_weather_api_key'])
|
||||
,weatherPirateWeatherKeyLabel: maskApiKey(settings['weather.pirate_weather_api_key'])
|
||||
,weatherOpenMeteoConfigured: Boolean(settings['weather.open_meteo_api_key'])
|
||||
,weatherOpenMeteoKeyLabel: maskApiKey(settings['weather.open_meteo_api_key'])
|
||||
,auditRetentionDays: Number(settings['audit.retention_days']) || 0
|
||||
,auditEnabled: Boolean(settings['audit.enabled'])
|
||||
,auditIncludeRequestMetadata: Boolean(settings['audit.include_request_metadata'])
|
||||
@@ -231,7 +244,7 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
app.post('/settings/system', deps.requirePermission('system-settings.update'), async function (req, res, next) {
|
||||
try {
|
||||
const section = String(req.body && req.body.settings_section || '').trim().toLowerCase();
|
||||
if (section !== 'icons' && section !== 'media' && section !== 'security' && section !== 'defaults' && section !== 'audit') {
|
||||
if (section !== 'icons' && section !== 'media' && section !== 'security' && section !== 'defaults' && section !== 'audit' && section !== 'weather') {
|
||||
const error = new Error('Unknown settings section.');
|
||||
error.statusCode = 400;
|
||||
error.expose = true;
|
||||
@@ -358,7 +371,7 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
error.expose = true;
|
||||
throw error;
|
||||
}
|
||||
await pool.query('DELETE FROM o_app_settings WHERE setting_key NOT IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', ['announcements.suggested_icons', 'announcements.default_icon', 'announcements.default_duration_value', 'announcements.default_duration_unit', 'player.default_slide_duration_seconds', 'player.default_fade_between_slides', 'player.skip_unavailable_rtmp', 'data-sources.rss_default_interval_value', 'data-sources.rss_default_interval_unit', 'data-sources.api_default_interval_value', 'data-sources.api_default_interval_unit', 'uploads.image_max_bytes', 'uploads.video_max_bytes', 'uploads.wysiwyg_image_max_bytes', 'uploads.allowed_mime_types', 'security.session_lifetime_days', 'security.allow_user_session_revocation', 'security.max_active_sessions', 'security.login_max_attempts', 'security.login_lockout_minutes', 'security.login_rate_limit_scope', 'security.password_min_length', 'security.password_min_categories', 'security.password_require_lowercase', 'security.password_require_uppercase', 'security.password_require_number', 'security.password_require_symbol', 'security.require_password_change_for_new_users', 'security.require_password_change_after_admin_reset', 'audit.enabled', 'audit.categories', 'audit.include_request_metadata', 'audit.retention_days']);
|
||||
await pool.query('DELETE FROM o_app_settings WHERE setting_key NOT IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', ['announcements.suggested_icons', 'announcements.default_icon', 'announcements.default_duration_value', 'announcements.default_duration_unit', 'player.default_slide_duration_seconds', 'player.default_fade_between_slides', 'player.skip_unavailable_rtmp', 'data-sources.rss_default_interval_value', 'data-sources.rss_default_interval_unit', 'data-sources.api_default_interval_value', 'data-sources.api_default_interval_unit', 'weather.open_meteo_api_key', 'weather.pirate_weather_api_key', 'uploads.image_max_bytes', 'uploads.video_max_bytes', 'uploads.wysiwyg_image_max_bytes', 'uploads.allowed_mime_types', 'security.session_lifetime_days', 'security.allow_user_session_revocation', 'security.max_active_sessions', 'security.login_max_attempts', 'security.login_lockout_minutes', 'security.login_rate_limit_scope', 'security.password_min_length', 'security.password_min_categories', 'security.password_require_lowercase', 'security.password_require_uppercase', 'security.password_require_number', 'security.password_require_symbol', 'security.require_password_change_for_new_users', 'security.require_password_change_after_admin_reset', 'audit.enabled', 'audit.categories', 'audit.include_request_metadata', 'audit.retention_days']);
|
||||
const savedSettings = await saveAppSettings(pool, {
|
||||
'uploads.image_max_bytes': imageMaxMb * 1024 * 1024,
|
||||
'uploads.video_max_bytes': videoMaxMb * 1024 * 1024,
|
||||
@@ -369,6 +382,17 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
return res.redirect('/settings/system?message=' + encodeURIComponent('Media settings saved.') + '#media-uploads');
|
||||
}
|
||||
|
||||
if (section === 'weather') {
|
||||
const submittedOpenMeteoKey = String(req.body && req.body.open_meteo_api_key || '').trim();
|
||||
const submittedPirateWeatherKey = String(req.body && req.body.pirate_weather_api_key || '').trim();
|
||||
const savedSettings = await saveAppSettings(pool, {
|
||||
'weather.open_meteo_api_key': submittedOpenMeteoKey || previousSettings['weather.open_meteo_api_key'],
|
||||
'weather.pirate_weather_api_key': submittedPirateWeatherKey || previousSettings['weather.pirate_weather_api_key']
|
||||
}, req.currentUser && req.currentUser.id);
|
||||
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
|
||||
return res.redirect('/settings/system?message=' + encodeURIComponent('Weather provider settings saved.') + '#weather-providers');
|
||||
}
|
||||
|
||||
const selectedIcons = req.body && (req.body['suggested_icons[]'] !== undefined ? req.body['suggested_icons[]'] : req.body.suggested_icons);
|
||||
const catalogKeys = new Set(ANNOUNCEMENT_ICON_CATALOG.map(function (option) {
|
||||
return option.value;
|
||||
@@ -384,7 +408,7 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
error.expose = true;
|
||||
throw error;
|
||||
}
|
||||
await pool.query('DELETE FROM o_app_settings WHERE setting_key NOT IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', ['announcements.suggested_icons', 'announcements.default_icon', 'announcements.default_duration_value', 'announcements.default_duration_unit', 'player.default_slide_duration_seconds', 'player.default_fade_between_slides', 'player.skip_unavailable_rtmp', 'data-sources.rss_default_interval_value', 'data-sources.rss_default_interval_unit', 'data-sources.api_default_interval_value', 'data-sources.api_default_interval_unit', 'uploads.image_max_bytes', 'uploads.video_max_bytes', 'uploads.wysiwyg_image_max_bytes', 'uploads.allowed_mime_types', 'security.session_lifetime_days', 'security.allow_user_session_revocation', 'security.max_active_sessions', 'security.login_max_attempts', 'security.login_lockout_minutes', 'security.login_rate_limit_scope', 'security.password_min_length', 'security.password_min_categories', 'security.password_require_lowercase', 'security.password_require_uppercase', 'security.password_require_number', 'security.password_require_symbol', 'security.require_password_change_for_new_users', 'security.require_password_change_after_admin_reset', 'audit.enabled', 'audit.categories', 'audit.include_request_metadata', 'audit.retention_days']);
|
||||
await pool.query('DELETE FROM o_app_settings WHERE setting_key NOT IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', ['announcements.suggested_icons', 'announcements.default_icon', 'announcements.default_duration_value', 'announcements.default_duration_unit', 'player.default_slide_duration_seconds', 'player.default_fade_between_slides', 'player.skip_unavailable_rtmp', 'data-sources.rss_default_interval_value', 'data-sources.rss_default_interval_unit', 'data-sources.api_default_interval_value', 'data-sources.api_default_interval_unit', 'weather.open_meteo_api_key', 'weather.pirate_weather_api_key', 'uploads.image_max_bytes', 'uploads.video_max_bytes', 'uploads.wysiwyg_image_max_bytes', 'uploads.allowed_mime_types', 'security.session_lifetime_days', 'security.allow_user_session_revocation', 'security.max_active_sessions', 'security.login_max_attempts', 'security.login_lockout_minutes', 'security.login_rate_limit_scope', 'security.password_min_length', 'security.password_min_categories', 'security.password_require_lowercase', 'security.password_require_uppercase', 'security.password_require_number', 'security.password_require_symbol', 'security.require_password_change_for_new_users', 'security.require_password_change_after_admin_reset', 'audit.enabled', 'audit.categories', 'audit.include_request_metadata', 'audit.retention_days']);
|
||||
const savedSettings = await saveAppSettings(pool, {
|
||||
'announcements.suggested_icons': suggestedIcons
|
||||
}, req.currentUser && req.currentUser.id);
|
||||
|
||||
@@ -25,6 +25,7 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) {
|
||||
const rssFeeds = data && data.rssFeeds ? data.rssFeeds : [];
|
||||
const apiSources = data && data.apiSources ? data.apiSources : [];
|
||||
const timetableGroups = data && data.timetableGroups ? data.timetableGroups : [];
|
||||
const weatherLocations = data && data.weatherLocations ? data.weatherLocations : [];
|
||||
const fontLibrary = data && data.fontLibrary ? data.fontLibrary : null;
|
||||
const uploadLimits = data && data.uploadLimits ? data.uploadLimits : {};
|
||||
const templates = buildTemplates(data && data.templates ? data.templates : [], templateRegions);
|
||||
@@ -48,6 +49,7 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) {
|
||||
rssFeeds: rssFeeds,
|
||||
apiSources: apiSources,
|
||||
timetableGroups: timetableGroups,
|
||||
weatherLocations: weatherLocations,
|
||||
fontStylesheetHref: fontLibrary && fontLibrary.stylesheetHref ? fontLibrary.stylesheetHref : '',
|
||||
fontFamilyFormats: fontLibrary && fontLibrary.fontFamilyFormats ? fontLibrary.fontFamilyFormats : '',
|
||||
existingTemplateId: viewSlide && viewSlide.template_id ? viewSlide.template_id : null,
|
||||
|
||||
@@ -6,6 +6,7 @@ const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const { verifyRequestAuth, verifyPageAuthToken } = require('#src/request-auth');
|
||||
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
|
||||
const { convertWeatherSnapshot } = require('#src/data/weather-units');
|
||||
const { buildThumbnailPreviewPayload } = require('#src/web/lib/media/slide-thumbnails');
|
||||
|
||||
function safeJsonForScript(value) {
|
||||
@@ -80,6 +81,20 @@ module.exports = function registerSlidesRoutes(app, deps) {
|
||||
const apiSources = (apiData.apiSources || []).map(function (source) {
|
||||
return Object.assign({}, source, { responseJson: common.parseJsonSafe ? common.parseJsonSafe(source.last_response_json) : null });
|
||||
});
|
||||
const weatherData = typeof common.fetchWeatherLocationsData === 'function' ? await common.fetchWeatherLocationsData(pool) : { weatherLocations: [] };
|
||||
const weatherLocations = (weatherData.weatherLocations || []).map(function (location) {
|
||||
const snapshot = common.parseJsonSafe ? common.parseJsonSafe(location.last_response_json) : null;
|
||||
if (!snapshot || typeof snapshot !== 'object') return null;
|
||||
const item = Object.assign({}, convertWeatherSnapshot(snapshot, {
|
||||
temperature: location.temperature_unit === 'fahrenheit' ? 'fahrenheit' : 'celsius',
|
||||
wind: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'ms' : 'kmh',
|
||||
precipitation: location.precipitation_unit === 'inch' ? 'inch' : 'mm'
|
||||
}), { location_label: location.location_label || '', name: location.name || '', timezone: location.timezone || '', temp_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C', wind_unit: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h', precip_unit: location.precipitation_unit === 'inch' ? 'in' : 'mm' });
|
||||
item.current = Object.assign({}, item.current || {}, { temperature_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C', wind_speed_unit: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h', precipitation_unit: location.precipitation_unit === 'inch' ? 'in' : 'mm' });
|
||||
item.daily = Object.assign({}, item.daily || {}, { temperature_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C' });
|
||||
item.hourly = Object.assign({}, item.hourly || {}, { temperature_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C' });
|
||||
return { id: location.id, data: item };
|
||||
}).filter(Boolean);
|
||||
const getApiItems = function (source) {
|
||||
const response = source && source.responseJson;
|
||||
const itemsPath = String(source && source.items_path || '').trim();
|
||||
@@ -103,6 +118,10 @@ module.exports = function registerSlidesRoutes(app, deps) {
|
||||
const feed = rssFeeds.find(function (entry) { return Number(entry.id) === Number(regionContent.feed_id); });
|
||||
return feed && Array.isArray(feed.items) ? feed.items[index] || null : null;
|
||||
}
|
||||
if (type === 'weather') {
|
||||
const location = weatherLocations.find(function (entry) { return Number(entry.id) === Number(regionContent.weather_location_id); });
|
||||
return location ? location.data : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const getCachedImagePath = function (remoteUrl) {
|
||||
|
||||
@@ -137,11 +137,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="card-footer d-flex justify-content-end align-items-center">
|
||||
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "api-sources.update")}}<button type="submit" form="api-source-toggle-form" class="btn {{#if apiSource.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if apiSource.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if apiSource.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "api-sources.allow")}}<button type="button" data-manual-refresh-url="/data-sources/api-sources/{{apiSource.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
|
||||
{{{saveActionButtons formId="api-source-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{#if isEdit}}<form id="api-source-toggle-form" method="post" action="/data-sources/api-sources/{{apiSource.id}}" data-async-command><input type="hidden" name="data_source_action" value="toggle" /></form>{{/if}}
|
||||
|
||||
<div class="card card-outline card-secondary mt-3" id="api-source-response-panel">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<th data-table-sort-key="interval">Refresh interval</th>
|
||||
<th data-table-sort-key="last_pulled">Last pulled</th>
|
||||
<th data-table-sort-key="last_response">Last response</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -54,6 +55,7 @@
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Status"><span class="badge text-bg-{{#if enabled}}success{{else}}danger{{/if}}">{{#if enabled}}Enabled{{else}}Disabled{{/if}}</span></td>
|
||||
<td data-label="Actions">
|
||||
{{#if (anyPermission ../currentUser 'api-sources.create' 'api-sources.update' 'api-sources.delete')}}
|
||||
<div class="actions">
|
||||
@@ -80,7 +82,7 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default><td colspan="6" class="empty">No API sources yet.</td></tr>
|
||||
<tr data-table-search-empty-default><td colspan="7" class="empty">No API sources yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -38,11 +38,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="card-footer d-flex justify-content-end align-items-center">
|
||||
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "rss-feeds.update")}}<button type="submit" form="rss-feed-toggle-form" class="btn {{#if rssFeed.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if rssFeed.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if rssFeed.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "rss-feeds.allow")}}<button type="button" data-manual-refresh-url="/data-sources/rss-feeds/{{rssFeed.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
|
||||
{{{saveActionButtons formId="rss-feed-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{#if isEdit}}<form id="rss-feed-toggle-form" method="post" action="/data-sources/rss-feeds/{{rssFeed.id}}" data-async-command><input type="hidden" name="data_source_action" value="toggle" /></form>{{/if}}
|
||||
|
||||
<div class="card card-outline card-secondary admin-form-card mt-4">
|
||||
<div class="card-header">
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<th data-table-sort-key="url">Feed URL</th>
|
||||
<th data-table-sort-key="interval">Refresh interval</th>
|
||||
<th data-table-sort-key="items">Items pulled</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -37,6 +38,7 @@
|
||||
<td data-label="Feed URL" class="text-break">{{feed_url}}</td>
|
||||
<td data-label="Refresh interval">{{intervalLabel}}</td>
|
||||
<td data-label="Items pulled">{{itemLabel}}</td>
|
||||
<td data-label="Status"><span class="badge text-bg-{{#if enabled}}success{{else}}danger{{/if}}">{{#if enabled}}Enabled{{else}}Disabled{{/if}}</span></td>
|
||||
<td data-label="Actions">
|
||||
{{#if (anyPermission ../currentUser 'rss-feeds.create' 'rss-feeds.update' 'rss-feeds.delete')}}
|
||||
<div class="actions">
|
||||
@@ -63,7 +65,7 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default><td colspan="5" class="empty">No RSS feeds yet.</td></tr>
|
||||
<tr data-table-search-empty-default><td colspan="6" class="empty">No RSS feeds yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="card card-outline card-secondary mt-3">
|
||||
<div class="card-header d-flex align-items-center flex-wrap"><h3 class="card-title mb-0 flex-grow-1">Forecast preview</h3><div class="btn-group btn-group-sm flex-shrink-0 ms-auto" role="group" aria-label="Forecast preview mode"><button type="button" class="btn btn-primary" data-weather-forecast-mode="daily" aria-pressed="true">Daily</button><button type="button" class="btn btn-outline-secondary" data-weather-forecast-mode="hourly" aria-pressed="false">24 hours</button></div></div>
|
||||
<div class="card-body">
|
||||
<div class="small text-body-secondary mb-3">Daily and hourly forecasts will appear here after the first successful fetch.</div>
|
||||
<div id="weather-daily-forecast" class="weather-daily-forecast">{{#each weatherPreview.forecast}}<div class="border rounded p-2 h-100 text-center"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-preview-forecast-icon" aria-hidden="true"></i><div class="small mb-2">{{condition}}</div><strong>{{high}}°</strong><span class="text-body-secondary ms-2">{{low}}°</span><div class="small text-body-secondary mt-2"><i class="bi bi-droplet me-1"></i>{{rain}} rain</div></div>{{/each}}</div>
|
||||
<div id="weather-hourly-forecast" class="border-top mt-4 pt-3 d-none"><div class="small text-body-secondary mb-2">Hourly forecast · next 24 hours</div><div class="d-flex gap-2 overflow-auto pb-2">{{#each weatherPreview.hourly}}<div class="border rounded p-2 text-center flex-shrink-0 weather-preview-hourly-item"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-preview-forecast-icon" aria-hidden="true"></i><strong>{{temperature}}°</strong><div class="small text-body-secondary mt-1"><i class="bi bi-droplet me-1"></i>{{rain}}</div></div>{{/each}}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<div class="page-header">
|
||||
<div><h2>{{#if isEdit}}Edit weather location{{else}}Add weather location{{/if}}</h2><p>Configure a saved location for weather data regions.</p></div>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xl-7">
|
||||
<form id="weather-location-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header"><h3 class="card-title">Weather location details</h3></div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3"><label for="weather-name" class="form-label">Name</label><input id="weather-name" name="name" class="form-control" value="{{weatherLocation.name}}" maxlength="255" required /></div>
|
||||
<div class="mb-3 position-relative"><label for="weather-label" class="form-label">Location</label><input id="weather-label" name="location_label" class="form-control" value="{{weatherLocation.location_label}}" maxlength="255" placeholder="Start typing a town, city, or postcode" autocomplete="off" required /><div id="weather-location-results" class="list-group position-absolute w-100 d-none" style="z-index: 10;"></div><div class="form-text">Choose a result to fill the coordinates and timezone automatically.</div></div>
|
||||
<div class="row g-3"><div class="col-md-4"><label for="weather-latitude" class="form-label">Latitude</label><input id="weather-latitude" type="number" step="0.000001" min="-90" max="90" class="form-control" value="{{weatherLocation.latitude}}" required /><input id="weather-latitude-value" name="latitude" type="hidden" value="{{weatherLocation.latitude}}" /></div><div class="col-md-4"><label for="weather-longitude" class="form-label">Longitude</label><input id="weather-longitude" type="number" step="0.000001" min="-180" max="180" class="form-control" value="{{weatherLocation.longitude}}" required /><input id="weather-longitude-value" name="longitude" type="hidden" value="{{weatherLocation.longitude}}" /></div><div class="col-md-4 d-flex align-items-end"><button id="weather-manual-coordinates" type="button" class="btn btn-outline-secondary">Edit coordinates manually</button></div></div>
|
||||
<input id="weather-timezone" name="timezone" type="hidden" value="{{weatherLocation.timezone}}" required />
|
||||
<div class="form-text mb-3">Coordinates are locked after selecting a lookup result.</div>
|
||||
<div class="row g-3"><div class="col-md-3"><label for="weather-provider" class="form-label">Provider</label><select id="weather-provider" name="provider" class="form-select"><option value="open-meteo" {{#if (eq weatherLocation.provider 'open-meteo')}}selected{{/if}}>Open-Meteo</option><option value="pirate-weather" {{#if (eq weatherLocation.provider 'pirate-weather')}}selected{{/if}} {{#unless providerAvailability.pirateWeather}}disabled{{/unless}}>Pirate Weather{{#unless providerAvailability.pirateWeather}} (API key not configured){{/unless}}</option></select></div><div class="col-md-3"><label for="weather-temperature-unit" class="form-label">Temperature</label><select id="weather-temperature-unit" name="temperature_unit" class="form-select"><option value="celsius" {{#if (eq weatherLocation.temperature_unit 'celsius')}}selected{{/if}}>Celsius</option><option value="fahrenheit" {{#if (eq weatherLocation.temperature_unit 'fahrenheit')}}selected{{/if}}>Fahrenheit</option></select></div><div class="col-md-3"><label for="weather-wind-unit" class="form-label">Wind</label><select id="weather-wind-unit" name="wind_unit" class="form-select"><option value="kmh" {{#if (eq weatherLocation.wind_unit 'kmh')}}selected{{/if}}>km/h</option><option value="mph" {{#if (eq weatherLocation.wind_unit 'mph')}}selected{{/if}}>mph</option><option value="ms" {{#if (eq weatherLocation.wind_unit 'ms')}}selected{{/if}}>m/s</option></select></div><div class="col-md-3"><label for="weather-precipitation-unit" class="form-label">Precipitation</label><select id="weather-precipitation-unit" name="precipitation_unit" class="form-select"><option value="mm" {{#if (eq weatherLocation.precipitation_unit 'mm')}}selected{{/if}}>Millimetres</option><option value="inch" {{#if (eq weatherLocation.precipitation_unit 'inch')}}selected{{/if}}>Inches</option></select></div></div>
|
||||
<div class="row g-3 mt-1"><div class="col-md-6"><label for="weather-interval" class="form-label">Update interval</label><input id="weather-interval" name="update_interval_value" type="number" min="1" max="1440" class="form-control" value="{{weatherLocation.update_interval_value}}" required /></div><div class="col-md-6"><label for="weather-interval-unit" class="form-label">Unit</label><select id="weather-interval-unit" name="update_interval_unit" class="form-select"><option value="minutes" {{#if (eq weatherLocation.update_interval_unit 'minutes')}}selected{{/if}}>Minutes</option><option value="hours" {{#if (eq weatherLocation.update_interval_unit 'hours')}}selected{{/if}}>Hours</option></select></div></div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end align-items-center"><div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "weather.update")}}<button type="submit" form="weather-location-toggle-form" class="btn {{#if weatherLocation.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if weatherLocation.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if weatherLocation.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "weather.allow")}}<button type="button" data-weather-refresh-url="/data-sources/weather/{{weatherLocation.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>{{{saveActionButtons formId="weather-location-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}</div>
|
||||
</div>
|
||||
</form>
|
||||
{{#if isEdit}}<form id="weather-location-toggle-form" method="post" action="/data-sources/weather/{{weatherLocation.id}}" data-async-command><input type="hidden" name="data_source_action" value="toggle" /></form>{{/if}}
|
||||
</div>
|
||||
<div class="col-12 col-xl-5">{{> data-sources/weather/preview}}</div>
|
||||
</div>
|
||||
{{> data-sources/weather/forecast-preview}}
|
||||
@@ -0,0 +1,38 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Weather locations</h2>
|
||||
<p>Save the locations used by weather regions and control how often their snapshots refresh.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Saved locations</h3>
|
||||
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||
<div class="input-group input-group-sm" style="width: min(14rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search weather locations" aria-label="Search weather locations" data-table-search />
|
||||
</div>
|
||||
{{#if (hasPermission currentUser 'weather.create')}}<a class="btn btn-primary btn-sm" href="/data-sources/weather/new">Add location</a>{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||
<thead><tr><th data-table-sort-key="name">Name</th><th data-table-sort-key="location">Location</th><th data-table-sort-key="provider">Provider</th><th data-table-sort-key="interval">Refresh interval</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{#if weatherLocations.length}}
|
||||
{{#each weatherLocations}}
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Name">{{name}}</td><td data-label="Location">{{location_label}}<br><small class="text-muted">{{latitude}}, {{longitude}} · {{timezone}}</small></td><td data-label="Provider">{{provider}}</td><td data-label="Refresh interval">{{intervalLabel}}</td><td data-label="Status"><span class="badge text-bg-{{#if enabled}}success{{else}}danger{{/if}}">{{#if enabled}}Enabled{{else}}Disabled{{/if}}</span></td>
|
||||
<td data-label="Actions"><div class="actions">
|
||||
{{#if (hasPermission ../currentUser 'weather.update')}}<a class="btn btn-sm btn-primary" href="/data-sources/weather/{{id}}/edit">Edit</a>{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'weather.delete')}}<form class="inline-form" method="post" action="/data-sources/weather/{{id}}/delete" data-confirm-message="Delete this weather location?"><button class="btn btn-sm btn-danger" type="submit">Delete</button></form>{{/if}}
|
||||
</div></td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}<tr data-table-search-empty-default><td colspan="6" class="empty">No weather locations yet.</td></tr>{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{> table-pagination pagination=pagination basePath="/data-sources/weather" alwaysShow=true}}
|
||||
</div>
|
||||
@@ -0,0 +1,117 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Weather source</h2>
|
||||
<p>Configure a remote weather feed once, then serve its cached snapshot to every player.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card card-outline card-primary h-100 weather-mock-card">
|
||||
<div class="card-header"><h3 class="card-title">Source configuration</h3></div>
|
||||
<div class="card-body weather-mock-card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="weather-name">Name</label>
|
||||
<input id="weather-name" class="form-control" value="{{weather.name}}" />
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<label class="form-label" for="weather-location">Location</label>
|
||||
<input id="weather-location" class="form-control" value="{{weather.location}}" />
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="weather-provider">Provider</label>
|
||||
<select id="weather-provider" class="form-select">
|
||||
<option selected>{{weather.provider}} (no key)</option>
|
||||
<option>Custom JSON endpoint</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12"><div class="form-text"><i class="bi bi-info-circle me-1"></i>Location is the display name. Coordinates identify the weather point and can be set from a map picker, browser location, or manual entry.</div></div>
|
||||
<div class="col-6">
|
||||
<label class="form-label" for="weather-latitude">Latitude</label>
|
||||
<input id="weather-latitude" class="form-control" value="{{weather.latitude}}" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label" for="weather-longitude">Longitude</label>
|
||||
<input id="weather-longitude" class="form-control" value="{{weather.longitude}}" />
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="weather-temperature-unit">Temperature</label>
|
||||
<select id="weather-temperature-unit" class="form-select"><option selected>{{weather.temperatureUnit}}</option><option>Fahrenheit (°F)</option></select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="weather-wind-unit">Wind speed</label>
|
||||
<select id="weather-wind-unit" class="form-select"><option selected>{{weather.windUnit}}</option><option>Miles per hour (mph)</option><option>Metres per second (m/s)</option></select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="weather-precipitation-unit">Precipitation</label>
|
||||
<select id="weather-precipitation-unit" class="form-select"><option selected>{{weather.precipitationUnit}}</option><option>Inches (in)</option></select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="weather-refresh">Refresh interval</label>
|
||||
<select id="weather-refresh" class="form-select"><option>15 minutes</option><option selected>{{weather.refreshInterval}}</option><option>1 hour</option></select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="weather-forecast-mode">Forecast display</label>
|
||||
<select id="weather-forecast-mode" class="form-select"><option selected value="daily">Daily forecast</option><option value="hourly">Hourly forecast (next 24 hours)</option></select>
|
||||
<div class="form-text">The source can cache both datasets; this controls what the region displays.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer weather-mock-card-footer d-flex justify-content-between align-items-center">
|
||||
<button type="button" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1"></i>Refresh now</button>
|
||||
<button type="button" class="btn btn-primary"><i class="bi bi-check2 me-1"></i>Save source</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="card card-outline card-secondary h-100 weather-mock-card">
|
||||
<div class="card-header d-flex align-items-center gap-2"><h3 class="card-title mb-0 flex-grow-1">Cached snapshot</h3><span class="badge text-bg-success flex-shrink-0"><i class="bi bi-check-circle me-1"></i>Last fetch succeeded</span></div>
|
||||
<div class="card-body weather-mock-card-body">
|
||||
<div class="d-flex align-items-start justify-content-between border-bottom pb-3 mb-3">
|
||||
<div><div class="text-body-secondary small">{{weather.location}}</div><div class="display-4 fw-semibold">{{weather.temperature}}°</div><div class="fw-medium">{{weather.condition}}</div></div>
|
||||
<i class="bi bi-cloud-sun weather-mock-icon" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="row g-3 small">
|
||||
<div class="col-6"><span class="text-body-secondary d-block">Feels like</span><strong>{{weather.feelsLike}}°</strong></div>
|
||||
<div class="col-6"><span class="text-body-secondary d-block">Humidity</span><strong>{{weather.humidity}}</strong></div>
|
||||
<div class="col-6"><span class="text-body-secondary d-block">Wind</span><strong>{{weather.wind}}</strong></div>
|
||||
<div class="col-6"><span class="text-body-secondary d-block">UV index</span><strong>{{weather.uvIndex}}</strong></div>
|
||||
<div class="col-6"><span class="text-body-secondary d-block">Sunrise</span><strong>{{weather.sunrise}}</strong></div>
|
||||
<div class="col-6"><span class="text-body-secondary d-block">Sunset</span><strong>{{weather.sunset}}</strong></div>
|
||||
</div>
|
||||
<div class="alert alert-light border mt-4 mb-0 small"><i class="bi bi-database-check me-1"></i> Cached {{weather.cacheAge}}. Players keep using this snapshot if the next request fails.</div>
|
||||
</div>
|
||||
<div class="card-footer weather-mock-card-footer small text-body-secondary">Last successful fetch: {{weather.fetchedAt}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-secondary mt-3">
|
||||
<div class="card-header d-flex align-items-center justify-content-between gap-2 flex-wrap"><h3 class="card-title mb-0">Forecast preview</h3><div class="btn-group btn-group-sm flex-shrink-0" role="group" aria-label="Forecast preview mode"><button type="button" class="btn btn-primary">Daily</button><button type="button" class="btn btn-outline-secondary">24 hours</button></div></div>
|
||||
<div class="card-body">
|
||||
<div class="small text-body-secondary mb-3">{{weather.forecastModeLabel}} · timezone {{weather.timezone}}</div>
|
||||
<div class="row row-cols-2 row-cols-md-4 g-2">
|
||||
{{#each weather.forecast}}
|
||||
<div class="col"><div class="border rounded p-3 h-100 text-center"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-mock-forecast-icon" aria-hidden="true"></i><div class="small mb-2">{{condition}}</div><strong>{{high}}°</strong><span class="text-body-secondary ms-2">{{low}}°</span><div class="small text-body-secondary mt-2"><i class="bi bi-droplet me-1"></i>{{rain}} rain</div></div></div>
|
||||
{{/each}}
|
||||
</div>
|
||||
<div class="border-top mt-4 pt-3">
|
||||
<div class="small text-body-secondary mb-2">Hourly option preview · next 24 hours</div>
|
||||
<div class="d-flex gap-2 overflow-auto pb-2">
|
||||
{{#each weather.hourly}}
|
||||
<div class="border rounded p-2 text-center flex-shrink-0" style="width:7.5rem"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-mock-forecast-icon" aria-hidden="true"></i><strong>{{temperature}}°</strong><div class="small text-body-secondary mt-1"><i class="bi bi-droplet me-1"></i>{{rain}}</div></div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.weather-mock-card { display: flex; flex-direction: column; }
|
||||
.weather-mock-card-body { flex: 1 1 auto; }
|
||||
.weather-mock-card-footer { min-height: 3.5rem; }
|
||||
.weather-mock-icon { font-size: 4rem; color: #e0a11a; }
|
||||
.weather-mock-forecast-icon { display: block; font-size: 2rem; color: #e0a11a; margin: 1rem 0 .65rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="card card-outline card-secondary h-100 weather-preview-card">
|
||||
<div class="card-header d-flex align-items-center"><h3 class="card-title mb-0 flex-grow-1">Cached snapshot</h3><span class="badge text-bg-secondary flex-shrink-0">{{#if weatherPreview.hasSnapshot}}Available{{else}}Not available{{/if}}</span></div>
|
||||
<div class="card-body weather-preview-card-body">
|
||||
{{#if weatherPreview.hasSnapshot}}
|
||||
<div class="d-flex align-items-start justify-content-between border-bottom pb-3 mb-3"><div><div class="text-body-secondary small">{{weatherPreview.location}}</div><div class="display-4 fw-semibold">{{weatherPreview.temperature}}{{weatherPreview.temperatureUnit}}</div><div class="fw-medium">{{weatherPreview.condition}}</div></div><i class="bi {{weatherPreview.icon}} weather-preview-icon" aria-hidden="true"></i></div>
|
||||
<div class="row g-3 small"><div class="col-6"><span class="text-body-secondary d-block">Feels like</span><strong>{{weatherPreview.feelsLike}}{{weatherPreview.temperatureUnit}}</strong></div><div class="col-6"><span class="text-body-secondary d-block">Humidity</span><strong>{{weatherPreview.humidity}}</strong></div><div class="col-6"><span class="text-body-secondary d-block">Wind</span><strong>{{weatherPreview.wind}} {{weatherPreview.windUnit}}</strong></div><div class="col-6"><span class="text-body-secondary d-block">Precipitation</span><strong>{{weatherPreview.precipitation}} {{weatherPreview.precipitationUnit}}</strong></div><div class="col-6"><span class="text-body-secondary d-block">Cloud cover</span><strong>{{weatherPreview.cloudCover}}%</strong></div><div class="col-6"><span class="text-body-secondary d-block">UV index</span><strong>{{weatherPreview.uvIndex}}</strong></div><div class="col-6"><span class="text-body-secondary d-block">Sunrise</span><strong>{{weatherPreview.sunrise}}</strong></div><div class="col-6"><span class="text-body-secondary d-block">Sunset</span><strong>{{weatherPreview.sunset}}</strong></div></div>
|
||||
<div class="alert alert-light border mt-4 mb-0 small"><i class="bi bi-database-check me-1"></i>Cached snapshot will be served to players between refreshes.</div>
|
||||
{{else}}
|
||||
<div class="d-flex align-items-center justify-content-center text-body-secondary text-center h-100 py-5"><div><i class="bi bi-cloud-sun fs-1 d-block mb-3" aria-hidden="true"></i><p class="mb-1">No cached weather snapshot yet.</p><small>Save this location to enable weather refreshes.</small></div></div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="card-footer weather-preview-card-footer d-flex align-items-center small text-body-secondary">{{#if weatherPreview.hasSnapshot}}Last successful fetch: {{weatherPreview.fetchedAt}}{{else}}Waiting for the first successful fetch{{/if}}</div>
|
||||
</div>
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12">
|
||||
<div class="card card-outline card-primary admin-form-card" data-table-search-container data-table-pagination-card>
|
||||
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Existing roles</h3>
|
||||
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
<a class="nav-link" href="#icon-suggestions" data-settings-section-link>
|
||||
<i class="bi bi-grid-3x3-gap me-2" aria-hidden="true"></i>Icon Suggestions
|
||||
</a>
|
||||
<a class="nav-link" href="#weather-providers" data-settings-section-link>
|
||||
<i class="bi bi-cloud-sun me-2" aria-hidden="true"></i>Weather Providers
|
||||
</a>
|
||||
<a class="nav-link" href="#security-sessions" data-settings-section-link>
|
||||
<i class="bi bi-shield-lock me-2" aria-hidden="true"></i>Security and Sessions
|
||||
</a>
|
||||
@@ -146,6 +149,44 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/settings/system" data-async-save data-settings-form="weather">
|
||||
<input type="hidden" name="settings_section" value="weather">
|
||||
<div id="weather-providers" class="card settings-section-card" data-settings-section hidden>
|
||||
<div class="card-header d-flex align-items-start justify-content-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="h5 mb-1 fw-semibold"><i class="bi bi-cloud-sun me-2 text-primary" aria-hidden="true"></i>Weather Providers</h3>
|
||||
<p class="text-muted small mb-0">Manage shared credentials used by weather locations.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12"><h4 class="h6 text-uppercase text-muted mb-0">Provider credentials</h4></div>
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<div class="d-flex align-items-start justify-content-between gap-2 mb-3"><div><strong>Open-Meteo</strong><div class="small text-body-secondary">Global forecast provider · API key optional</div></div>{{#if mediaSettings.weatherOpenMeteoConfigured}}<span class="badge text-bg-success flex-shrink-0">Configured</span>{{else}}<span class="badge text-bg-secondary flex-shrink-0">Keyless</span>{{/if}}</div>
|
||||
<label for="settings-open-meteo-key" class="form-label">API key</label>
|
||||
<input id="settings-open-meteo-key" name="open_meteo_api_key" type="password" class="form-control" autocomplete="off" placeholder="{{#if mediaSettings.weatherOpenMeteoConfigured}}{{mediaSettings.weatherOpenMeteoKeyLabel}}{{else}}Optional API key{{/if}}">
|
||||
<div class="form-text">Open-Meteo can be used without a key. Leave blank to keep the existing credential.</div>
|
||||
<a class="small" href="https://open-meteo.com/" target="_blank" rel="noreferrer">open-meteo.com</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<div class="d-flex align-items-start justify-content-between gap-2 mb-3"><div><strong>Pirate Weather</strong><div class="small text-body-secondary">Global forecast provider · API key required</div></div>{{#if mediaSettings.weatherPirateWeatherConfigured}}<span class="badge text-bg-success flex-shrink-0">Configured</span>{{else}}<span class="badge text-bg-secondary flex-shrink-0">Not configured</span>{{/if}}</div>
|
||||
<label for="settings-pirate-weather-key" class="form-label">API key</label>
|
||||
<input id="settings-pirate-weather-key" name="pirate_weather_api_key" type="password" class="form-control" autocomplete="off" placeholder="{{#if mediaSettings.weatherPirateWeatherConfigured}}{{mediaSettings.weatherPirateWeatherKeyLabel}}{{else}}Enter API key{{/if}}">
|
||||
<div class="form-text">Leave blank to keep the existing credential. One key is shared by all weather locations.</div>
|
||||
<a class="small" href="https://pirateweather.net/" target="_blank" rel="noreferrer">pirateweather.net</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
{{#if (hasPermission currentUser 'system-settings.update')}}<button type="submit" class="btn btn-success"><i class="bi bi-check2 me-1"></i>Save</button>{{else}}<button type="button" class="btn btn-outline-success" disabled>Save</button>{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/settings/system" data-async-save data-settings-form="media">
|
||||
<input type="hidden" name="settings_section" value="media">
|
||||
<div id="media-uploads" class="card settings-section-card" data-settings-section hidden>
|
||||
|
||||
@@ -241,7 +241,7 @@
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (anyPermission currentUser 'rss-feeds.read' 'schedules.read' 'api-sources.read')}}
|
||||
{{#if (anyPermission currentUser 'rss-feeds.read' 'schedules.read' 'api-sources.read' 'weather.read')}}
|
||||
<li class="nav-header">DATA SOURCES</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'api-sources.read')}}
|
||||
@@ -268,6 +268,14 @@
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'weather.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'weather')}}active{{/if}}" href="/data-sources/weather">
|
||||
<i class="nav-icon bi bi-cloud-sun"></i>
|
||||
<p>Weather</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (anyPermission currentUser 'system-settings.read' 'audit-log.read' 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read' 'fonts.read')}}
|
||||
<li class="nav-header">SETTINGS</li>
|
||||
{{/if}}
|
||||
@@ -304,7 +312,7 @@
|
||||
<i class="nav-arrow bi bi-chevron-right"></i>
|
||||
</p>
|
||||
</a>
|
||||
{{#if (anyPermission currentUser 'background-tasks.read')}}
|
||||
{{#if (hasPermission currentUser 'background-tasks.read')}}
|
||||
<ul class="nav nav-treeview">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/tasks-background">
|
||||
|
||||
Reference in New Issue
Block a user