Release v2.14.2
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m11s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 30s

This commit is contained in:
lzstealth committed 2026-10-12 00:56:09 +01:00
1 parent c3299abaac
commit ddcc665f81
47 files changed
+1209 -66

No files matched your search

+1
View File
@@ -469,6 +469,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
type: 'weather',
value: body[`region_text_${region.id}`] !== undefined ? String(body[`region_text_${region.id}`] || '') : String(current.value || ''),
weather_location_id: locationId === undefined || locationId === null ? (current.weather_location_id || null) : (locationId === '' ? null : Number(locationId)),
weather_icon_set_id: body[`region_weather_icon_set_id_${region.id}`] === undefined ? String(current.weather_icon_set_id || '') : String(body[`region_weather_icon_set_id_${region.id}`] || '').trim(),
forecast_mode: body[`region_weather_forecast_mode_${region.id}`] === undefined ? (['current', 'hourly'].includes(current.forecast_mode) ? current.forecast_mode : 'daily') : forecastMode,
font_family: style.font_family,
font_size: style.font_size,
+5 -3
View File
@@ -404,8 +404,9 @@ function createPlayerPlaylistService(options) {
await cachePlaceholderImages(slides, rssFeeds, apiSources);
const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups, weatherLocations);
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, weatherLocations: weatherLocations, revision: revision };
const weatherIconSets = mediaDir ? require('#src/web/lib/media/weather-icon-library').readWeatherIconSets(mediaDir) : [];
const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups, weatherLocations, weatherIconSets);
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, weatherLocations: weatherLocations, weatherIconSets: weatherIconSets, revision: revision };
await writeSnapshot(slug, payload);
return payload;
} catch (error) {
@@ -422,7 +423,7 @@ function createPlayerPlaylistService(options) {
hash.update('\0');
}
function getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups, weatherLocations) {
function getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups, weatherLocations, weatherIconSets) {
const hash = crypto.createHash('sha1');
updatePlaylistRevisionHash(hash, screen && screen.id);
@@ -489,6 +490,7 @@ function createPlayerPlaylistService(options) {
updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || []));
updatePlaylistRevisionHash(hash, JSON.stringify(timetableGroups || []));
updatePlaylistRevisionHash(hash, JSON.stringify(weatherLocations || []));
updatePlaylistRevisionHash(hash, JSON.stringify(weatherIconSets || []));
return hash.digest('hex');
}
@@ -231,6 +231,7 @@ function refresh(applyImmediately, bypassDebounce) {
apiSources: Array.isArray(data.apiSources) ? data.apiSources : [],
timetableGroups: Array.isArray(data.timetableGroups) ? data.timetableGroups : [],
weatherLocations: Array.isArray(data.weatherLocations) ? data.weatherLocations : [],
weatherIconSets: Array.isArray(data.weatherIconSets) ? data.weatherIconSets : [],
revision: nextSignature
});
if (typeof initialData !== 'undefined') {
+11 -3
View File
@@ -84,9 +84,17 @@ function substituteApiVariables(html, item, sourceId) {
? 'display:block;width:100%;height:100%;object-fit:contain;'
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
return hasImageBounds
? '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;">' + image + '</span>'
: image;
if (hasImageBounds || imageConfig.verticalAlign) {
var wrapperStyle = 'display:inline-flex;align-items:center;justify-content:center;';
if (hasImageBounds) {
wrapperStyle += 'box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;';
}
if (imageConfig.verticalAlign) {
wrapperStyle += 'vertical-align:' + imageConfig.verticalAlign + ';';
}
return '<span style="' + wrapperStyle + '">' + image + '</span>';
}
return image;
}
return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved));
});
+11 -3
View File
@@ -70,9 +70,17 @@ function substituteRssVariables(html, item, feedId) {
? 'display:block;width:100%;height:100%;object-fit:contain;'
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
return hasImageBounds
? '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;">' + image + '</span>'
: image;
if (hasImageBounds || imageConfig.verticalAlign) {
var wrapperStyle = 'display:inline-flex;align-items:center;justify-content:center;';
if (hasImageBounds) {
wrapperStyle += 'box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;';
}
if (imageConfig.verticalAlign) {
wrapperStyle += 'vertical-align:' + imageConfig.verticalAlign + ';';
}
return '<span style="' + wrapperStyle + '">' + image + '</span>';
}
return image;
}
return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved));
});
+9 -5
View File
@@ -46,7 +46,7 @@ function withWeatherUnits(snapshot, location) {
return data;
}
function substituteWeatherVariables(html, value) {
function substituteWeatherVariables(html, value, iconSetId) {
return String(html || '').replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!value || typeof value !== 'object' || typeof weatherPlaceholderUtils.resolvePlaceholderExpression !== 'function' || typeof weatherPlaceholderUtils.formatPlaceholderValue !== 'function') return '';
var rawExpression = String(expression).trim();
@@ -54,15 +54,19 @@ function substituteWeatherVariables(html, value) {
if (typeof weatherPlaceholderUtils.isProgressPlaceholderExpression === 'function' && weatherPlaceholderUtils.isProgressPlaceholderExpression(rawExpression)) {
return weatherPlaceholderUtils.renderProgressPlaceholder(value, rawExpression);
}
var iconMatch = normalizedExpression.match(/^(?:current\.weather_code|daily\.weather_code\.\d+|hourly\.weather_code\.\d+)\.icon(?:\((\d+)(?:\s*,\s*(\d+))?\))?$/);
var iconMatch = normalizedExpression.match(/^(?:current\.weather_code|daily\.weather_code\.\d+|hourly\.weather_code\.\d+)\.icon(?:\((\d+)(?:\s*,\s*(\d+))?(?:\s*,\s*(top|middle|bottom))?\))?$/);
if (iconMatch) {
var codeExpression = normalizedExpression.replace(/\.icon(?:\(.*\))?$/, '');
var width = iconMatch[1] ? Math.max(1, Math.min(1000, Number(iconMatch[1]))) : 0;
var height = iconMatch[2] ? Math.max(1, Math.min(1000, Number(iconMatch[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 alignment = iconMatch[3] || 'middle';
var style = width ? ' style="display:block;font-size:' + width + 'px;line-height:1;width:' + width + 'px;height:' + height + 'px;"' : '';
var weatherCode = weatherPlaceholderUtils.resolvePlaceholderExpression(value, codeExpression, { timeZone: value.timezone });
var source = window.pulseWeatherIcons && window.pulseWeatherIcons.imageForCode(initialData && initialData.weatherIconSets, iconSetId, weatherCode, window.pulseWeatherIcons.isNightForWeather(value));
if (source) return window.pulseWeatherIcons.renderImage(source, width, height, alignment);
var icon = '<i class="bi ' + weatherIconForCode(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;">' + icon + '</span>' : icon;
var verticalAlign = alignment === 'top' || alignment === 'bottom' ? alignment : 'middle';
return width ? '<span style="display:inline-flex;vertical-align:' + verticalAlign + ';align-items:center;justify-content:center;box-sizing:border-box;line-height:0;width:' + width + 'px;height:' + height + 'px;">' + icon + '</span>' : icon;
}
return escapeHtml(weatherPlaceholderUtils.formatPlaceholderValue(weatherPlaceholderUtils.resolvePlaceholderExpression(value, normalizeWeatherExpression(expression), { timeZone: value.timezone })));
});
@@ -82,7 +86,7 @@ function renderWeatherRegion(region, regionContent) {
if (value) {
var weatherData = withWeatherUnits(snapshot, location);
var fontSize = Math.max(8, Number(regionContent && regionContent.font_size || region && (region.font_size || region.fontSize) || 32) || 32);
return '<div class="template-region weather" style="' + region.baseStyle + '"><div style="' + style + 'font-family:Arial,sans-serif;font-size:' + fontSize + 'px;line-height:1.5;">' + renderEditorJsContent(substituteWeatherVariables(value, weatherData)) + '</div></div>';
return '<div class="template-region weather" style="' + region.baseStyle + '"><div style="' + style + 'font-family:Arial,sans-serif;font-size:' + fontSize + 'px;line-height:1.5;">' + renderEditorJsContent(substituteWeatherVariables(value, weatherData, regionContent.weather_icon_set_id)) + '</div></div>';
}
var daily = snapshot.daily || {};
var days = (daily.time || []).slice(0, 7).map(function (day, index) {
+2 -1
View File
@@ -1,6 +1,7 @@
// Player page rendering and bootstrap script assembly.
const Handlebars = require('handlebars');
const fs = require('fs');
const crypto = require('crypto');
const path = require('path');
const packageMetadata = require('#root/package.json');
@@ -102,7 +103,7 @@ function renderOnboardingFormBody(deviceId) {
function renderPlayerPage(slug, initialData) {
const serviceWorkerScript = getPlayerServiceWorkerRegistrationScript();
const serviceWorkerScript = '<script>' + fs.readFileSync(path.join(__dirname, '../web/public/js/shared/weather-icons.js'), 'utf8') + '</script>' + getPlayerServiceWorkerRegistrationScript();
const template = getPlayerPageTemplate();
const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
const chartScriptTag = '<script src="/assets/vendor/chart.js/chart.umd.js"></script>';
+11
View File
@@ -242,6 +242,17 @@ const PERMISSION_SECTIONS = [
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
]
},
{
key: 'weather-icons',
order: 35,
name: 'Weather icons',
permissions: [
{ key: 'read', name: 'Read', description: 'View weather icon sets.' },
{ key: 'create', name: 'Create', description: 'Upload weather icon sets.' },
{ key: 'update', name: 'Update', description: 'Update weather icon sets.' },
{ key: 'delete', name: 'Delete', description: 'Delete unused weather icon sets.' }
]
},
{
key: 'scheduled-tasks',
order: 50,
@@ -31,7 +31,8 @@ function registerFontSyncTask(options) {
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir, undefined, playerIdentifier);
} else {
await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir, undefined, playerIdentifier);
const copied = await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir, undefined, playerIdentifier);
if (payload.requireSuccess && copied === false) throw new Error('Unable to synchronize ' + uploadPath + ' to player ' + playerIdentifier + '.');
}
}
});
@@ -12,6 +12,7 @@ const {
collectFontLibraryDirectoryUploadPaths,
collectFontLibrarySyncOperations
} = require('#src/web/lib/media/font-library');
const { collectWeatherIconDirectoryUploadPaths, collectWeatherIconSyncOperations } = require('#src/web/lib/media/weather-icon-library');
function registerFontSweepTask(options) {
const backgroundTaskQueue = options && options.backgroundTaskQueue;
@@ -33,11 +34,11 @@ function registerFontSweepTask(options) {
mediaDir: mediaDir
},
run: async function () {
const desiredOperations = collectFontLibrarySyncOperations(mediaDir);
const desiredOperations = collectFontLibrarySyncOperations(mediaDir).concat(collectWeatherIconSyncOperations(mediaDir));
const desiredUploadPaths = new Set(desiredOperations.map(function (operation) {
return operation && operation.uploadPath ? operation.uploadPath : '';
}).filter(Boolean));
const currentUploadPaths = await collectFontLibraryDirectoryUploadPaths(mediaDir);
const currentUploadPaths = (await collectFontLibraryDirectoryUploadPaths(mediaDir)).concat(await collectWeatherIconDirectoryUploadPaths(mediaDir));
for (let i = 0; i < desiredOperations.length; i += 1) {
const operation = desiredOperations[i] || {};
@@ -56,7 +56,7 @@ function registerInitialFontSyncTask(options) {
return null;
}
const operations = collectFontLibrarySyncOperations(mediaDir);
const operations = collectFontLibrarySyncOperations(mediaDir).concat(require('#src/web/lib/media/weather-icon-library').collectWeatherIconSyncOperations(mediaDir));
return Promise.all(livePlayers.map(function (player) {
const metadata = normalizePlayerMetadata(player);
return backgroundTaskQueue.enqueueTask({
+1 -1
View File
@@ -181,7 +181,7 @@ function createDashboardStateService(options) {
const kioskPlayers = buildKioskLauncherPlayers(playerRegistrations, connectedPlayerCountStaleSeconds);
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
return socket && socket.readyState === WebSocket.OPEN;
});
}) || Number(connectedPlayersCount) > 0;
return {
playlists: data.playlists || [],
+81
View File
@@ -0,0 +1,81 @@
const fs = require('fs');
const path = require('path');
function resolveMediaRoot(mediaRootOrUploadDir) {
const root = path.resolve(mediaRootOrUploadDir);
return path.basename(root) === 'uploads' ? path.dirname(root) : root;
}
function moveDirectory(source, destination) {
if (!fs.existsSync(source)) return;
fs.mkdirSync(destination, { recursive: true });
let entries;
try {
entries = fs.readdirSync(source, { withFileTypes: true });
} catch (error) {
if (error.code === 'ENOENT') return;
throw error;
}
for (const entry of entries) {
const sourcePath = path.join(source, entry.name);
const targetPath = path.join(destination, entry.name);
try {
if (entry.isDirectory()) {
moveDirectory(sourcePath, targetPath);
} else if (!fs.existsSync(targetPath)) {
fs.renameSync(sourcePath, targetPath);
} else if (fs.readFileSync(sourcePath).equals(fs.readFileSync(targetPath))) {
fs.unlinkSync(sourcePath);
} else {
throw new Error('Conflicting managed asset: ' + targetPath);
}
} catch (error) {
if (error.code !== 'ENOENT' || !fs.existsSync(targetPath)) throw error;
}
}
try {
fs.rmdirSync(source);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
function migrateLegacyAssets(mediaRootOrUploadDir) {
const root = resolveMediaRoot(mediaRootOrUploadDir);
const assets = path.join(root, 'assets');
const legacyFonts = path.join(root, 'fonts');
const legacyWeather = path.join(root, 'weather-icons');
if (!fs.existsSync(legacyFonts) && !fs.existsSync(legacyWeather)) return;
fs.mkdirSync(assets, { recursive: true });
for (const fileName of ['fonts.json', 'fonts.css']) {
const source = path.join(legacyFonts, fileName);
if (fs.existsSync(source)) {
const target = path.join(assets, fileName);
try {
if (!fs.existsSync(target)) fs.renameSync(source, target);
else if (fs.readFileSync(source).equals(fs.readFileSync(target))) fs.unlinkSync(source);
else throw new Error('Conflicting managed asset: ' + target);
} catch (error) {
if (error.code !== 'ENOENT' || !fs.existsSync(target)) throw error;
}
}
}
moveDirectory(legacyFonts, path.join(assets, 'fonts'));
moveDirectory(legacyWeather, path.join(assets, 'weather-icons'));
const stylesheet = path.join(assets, 'fonts.css');
if (fs.existsSync(stylesheet)) {
fs.writeFileSync(stylesheet, fs.readFileSync(stylesheet, 'utf8').replaceAll('/media/fonts/', '/media/assets/fonts/'));
}
const manifest = path.join(assets, 'weather-icons', 'sets.json');
if (fs.existsSync(manifest)) {
const sets = JSON.parse(fs.readFileSync(manifest, 'utf8'));
for (const set of sets) {
for (const condition of Object.keys(set.icons || {})) {
set.icons[condition] = String(set.icons[condition]).replace(/^\/media\/weather-icons\//, '/media/assets/weather-icons/');
}
}
fs.writeFileSync(manifest, JSON.stringify(sets, null, 2));
}
}
module.exports = { resolveMediaRoot, migrateLegacyAssets };
+10 -6
View File
@@ -1,8 +1,9 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { migrateLegacyAssets } = require('#src/web/lib/media/assets-storage');
const FONT_LIBRARY_DIR_NAME = 'fonts';
const FONT_LIBRARY_DIR_NAME = 'assets/fonts';
const FONT_MANIFEST_FILE = 'fonts.json';
const FONT_STYLESHEET_FILE = 'fonts.css';
const DEFAULT_FONT_FAMILY_FORMATS = [
@@ -77,21 +78,22 @@ function getFontLibraryDir(mediaRootOrUploadDir) {
}
function getFontManifestPath(mediaRootOrUploadDir) {
return path.join(getFontLibraryDir(mediaRootOrUploadDir), FONT_MANIFEST_FILE);
return path.join(resolveMediaRoot(mediaRootOrUploadDir), 'assets', FONT_MANIFEST_FILE);
}
function getFontStylesheetPath(mediaRootOrUploadDir) {
return path.join(getFontLibraryDir(mediaRootOrUploadDir), FONT_STYLESHEET_FILE);
return path.join(resolveMediaRoot(mediaRootOrUploadDir), 'assets', FONT_STYLESHEET_FILE);
}
function getFontStylesheetHref(mediaRootOrUploadDir) {
migrateLegacyAssets(mediaRootOrUploadDir);
const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir);
if (!fs.existsSync(stylesheetPath)) {
return '';
}
const stat = fs.statSync(stylesheetPath);
return `/media/${FONT_LIBRARY_DIR_NAME}/${FONT_STYLESHEET_FILE}?v=${Number(stat.mtimeMs) || Date.now()}`;
return `/media/assets/${FONT_STYLESHEET_FILE}?v=${Number(stat.mtimeMs) || Date.now()}`;
}
function normalizeFontEntry(entry) {
@@ -117,6 +119,7 @@ function sortFonts(left, right) {
}
function readManifest(mediaRootOrUploadDir) {
migrateLegacyAssets(mediaRootOrUploadDir);
const manifestPath = getFontManifestPath(mediaRootOrUploadDir);
if (!fs.existsSync(manifestPath)) {
return [];
@@ -180,6 +183,7 @@ function buildFontFamilyFormats(fonts) {
}
async function ensureFontLibrary(mediaRootOrUploadDir) {
migrateLegacyAssets(mediaRootOrUploadDir);
const fontDir = getFontLibraryDir(mediaRootOrUploadDir);
const manifestPath = getFontManifestPath(mediaRootOrUploadDir);
const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir);
@@ -368,8 +372,8 @@ async function collectFontLibraryDirectoryUploadPaths(mediaRootOrUploadDir) {
function collectFontLibrarySyncOperations(mediaRootOrUploadDir) {
const fonts = readManifest(mediaRootOrUploadDir);
const operations = [
{ type: 'put', uploadPath: '/media/' + FONT_LIBRARY_DIR_NAME + '/' + FONT_MANIFEST_FILE },
{ type: 'put', uploadPath: '/media/' + FONT_LIBRARY_DIR_NAME + '/' + FONT_STYLESHEET_FILE }
{ type: 'put', uploadPath: '/media/assets/' + FONT_MANIFEST_FILE },
{ type: 'put', uploadPath: '/media/assets/' + FONT_STYLESHEET_FILE }
];
fonts.forEach(function (font) {
+9 -3
View File
@@ -189,11 +189,17 @@ function substitutePlaceholders(html, regionContent, options) {
}
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 iconSize = String(expression).match(/\.icon\(\s*(\d+)(?:\s*,\s*(\d+))?(?:\s*,\s*(top|middle|bottom))?\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 alignment = iconSize && iconSize[3] || 'middle';
const weatherIcons = require('#src/web/public/js/shared/weather-icons');
const source = weatherIcons.imageForCode(options && options.weatherIconSets, regionContent.weather_icon_set_id, placeholderUtils.resolvePlaceholderExpression(item, codeExpression), weatherIcons.isNightForWeather(item));
if (source) return weatherIcons.renderImage(resolveAssetUrl(options && options.baseUrl, source), width, height, alignment);
const sizeStyle = width ? ' style="display:block;width:' + width + 'px;height:' + height + 'px;font-size:' + width + 'px;line-height:1;"' : '';
const icon = '<i class="bi ' + weatherIconForCode(placeholderUtils.resolvePlaceholderExpression(item, codeExpression)) + '"' + sizeStyle + ' aria-hidden="true"></i>';
const verticalAlign = alignment === 'top' || alignment === 'bottom' ? alignment : 'middle';
return width ? '<span style="display:inline-flex;vertical-align:' + verticalAlign + ';align-items:center;justify-content:center;box-sizing:border-box;line-height:0;width:' + width + 'px;height:' + height + 'px;">' + icon + '</span>' : icon;
}
const value = typeof placeholderUtils.formatPlaceholderValue === 'function' ? placeholderUtils.formatPlaceholderValue(resolved) : String(resolved || '');
if (typeof placeholderUtils.isImagePlaceholderExpression !== 'function' || !placeholderUtils.isImagePlaceholderExpression(expression)) {
+1 -1
View File
@@ -946,7 +946,7 @@ function createUploadSyncService(options) {
uploadRefs.add(reference);
});
});
const fontLibraryOperations = collectFontLibrarySyncOperations(uploadDir);
const fontLibraryOperations = collectFontLibrarySyncOperations(uploadDir).concat(require('#src/web/lib/media/weather-icon-library').collectWeatherIconSyncOperations(uploadDir));
const generatedCachePaths = await collectUploadPathsFromDirectory(uploadDir);
generatedCachePaths.filter(function (uploadPath) {
return String(uploadPath || '').startsWith('/media/player-cache/remote-images/');
+162
View File
@@ -0,0 +1,162 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { resolveMediaRoot, migrateLegacyAssets } = require('#src/web/lib/media/assets-storage');
const CONDITIONS = [
['clear', 'Clear'], ['partly-cloudy', 'Partly cloudy'], ['overcast', 'Overcast'],
['fog', 'Fog'], ['drizzle', 'Drizzle'], ['rain', 'Rain'], ['snow', 'Snow'],
['showers', 'Showers'], ['thunderstorm', 'Thunderstorm']
].map(function (entry) { return { key: entry[0], label: entry[1] }; });
function libraryDir(mediaRoot) {
return path.join(resolveMediaRoot(mediaRoot), 'assets', 'weather-icons');
}
function getWeatherManifestPath(mediaRoot) {
const root = resolveMediaRoot(mediaRoot);
const manifest = path.join(root, 'assets', 'weather.json');
const legacyManifest = path.join(libraryDir(mediaRoot), 'sets.json');
if (!fs.existsSync(legacyManifest)) return manifest;
fs.mkdirSync(path.dirname(manifest), { recursive: true });
if (!fs.existsSync(manifest)) {
fs.renameSync(legacyManifest, manifest);
} else if (fs.readFileSync(manifest).equals(fs.readFileSync(legacyManifest))) {
fs.unlinkSync(legacyManifest);
} else {
throw new Error('Conflicting weather icon manifest: ' + manifest);
}
return manifest;
}
function readWeatherIconSets(mediaRoot) {
migrateLegacyAssets(mediaRoot);
const manifest = getWeatherManifestPath(mediaRoot);
if (!fs.existsSync(manifest)) return [];
const sets = JSON.parse(fs.readFileSync(manifest, 'utf8'));
if (!Array.isArray(sets)) throw new Error('Invalid weather icon manifest.');
return sets.map(function (set) {
return Object.assign({}, set, { enabled: set && set.enabled !== false, nightIcons: set && set.nightIcons || {} });
});
}
function validateSvgSafety(buffer) {
const svg = Buffer.from(buffer).toString('utf8');
if (/<(?:script|foreignObject|iframe|object|embed)\b|\son[a-z]+\s*=|(?:href|xlink:href)\s*=\s*["']\s*(?:https?:|\/\/|data:|javascript:)/i.test(svg)) {
throw new Error('SVG icons cannot contain scripts or external resources.');
}
}
async function saveWeatherIconSet(mediaRoot, name, files, setId) {
const label = String(name || '').trim();
if (!label || label.length > 100) throw new Error('Enter a set name of up to 100 characters.');
const sets = readWeatherIconSets(mediaRoot);
const existing = setId ? sets.find(function (set) { return set.id === setId; }) : null;
if (setId && !existing) throw new Error('Weather icon set not found.');
if (sets.some(function (set) { return set.id !== setId && set.name.toLowerCase() === label.toLowerCase(); })) {
throw new Error('A weather icon set with this name already exists.');
}
const id = existing ? existing.id : crypto.randomUUID();
const icons = Object.assign({}, existing && existing.icons);
const nightIcons = Object.assign({}, existing && existing.nightIcons);
const prepared = [];
for (const file of files || []) {
const fieldName = String(file.fieldname || '');
const isNight = fieldName.startsWith('night_icon_');
const condition = fieldName.replace(/^(?:night_)?icon_/, '');
if (!CONDITIONS.some(function (entry) { return entry.key === condition; })) throw new Error('Unknown weather condition.');
if (!file.buffer || file.buffer.length > 5 * 1024 * 1024) throw new Error('Each icon must be smaller than 5MB.');
const image = require('sharp')(file.buffer, { limitInputPixels: 16777216 });
const metadata = await image.metadata();
if (!['png', 'webp', 'svg'].includes(metadata.format) || metadata.pages > 1) throw new Error('Upload static PNG, WebP, or SVG icons.');
const buffer = metadata.format === 'svg'
? (validateSvgSafety(file.buffer), Buffer.from(file.buffer))
: await image.resize({ width: 1024, height: 1024, fit: 'inside', withoutEnlargement: true }).png().toBuffer();
const extension = metadata.format === 'svg' ? '.svg' : '.png';
const fileName = (isNight ? 'night-' : '') + condition + '-' + crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 16) + extension;
prepared.push({ fileName: fileName, buffer: buffer });
(isNight ? nightIcons : icons)[condition] = '/media/assets/weather-icons/' + id + '/' + fileName;
}
const missingConditions = CONDITIONS.filter(function (condition) {
return !icons[condition.key];
}).map(function (condition) {
return condition.label;
});
if (missingConditions.length) throw new Error('Upload an icon for every weather condition: ' + missingConditions.join(', ') + '.');
const directory = path.join(libraryDir(mediaRoot), id);
fs.mkdirSync(directory, { recursive: true });
prepared.forEach(function (file) { fs.writeFileSync(path.join(directory, file.fileName), file.buffer); });
const set = { id: id, name: label, icons: icons, nightIcons: nightIcons, enabled: existing ? existing.enabled !== false : true };
const next = existing ? sets.map(function (entry) { return entry.id === id ? set : entry; }) : sets.concat(set);
const manifest = getWeatherManifestPath(mediaRoot);
fs.writeFileSync(manifest + '.tmp', JSON.stringify(next, null, 2));
fs.renameSync(manifest + '.tmp', manifest);
return set;
}
function collectWeatherIconSyncOperations(mediaRoot) {
const sets = readWeatherIconSets(mediaRoot);
if (!fs.existsSync(getWeatherManifestPath(mediaRoot))) return [];
return sets.flatMap(function (set) {
return Object.values(set.icons).concat(Object.values(set.nightIcons || {})).map(function (uploadPath) { return { type: 'put', uploadPath: uploadPath }; });
}).concat([{ type: 'put', uploadPath: '/media/assets/weather.json' }, { type: 'delete', uploadPath: '/media/assets/weather-icons/sets.json' }]);
}
function setWeatherIconSetEnabled(mediaRoot, id, enabled) {
const sets = readWeatherIconSets(mediaRoot);
const index = sets.findIndex(function (set) { return set.id === id; });
if (index === -1) return null;
const next = sets.slice();
next[index] = Object.assign({}, next[index], { enabled: Boolean(enabled) });
const manifest = getWeatherManifestPath(mediaRoot);
fs.writeFileSync(manifest + '.tmp', JSON.stringify(next, null, 2));
fs.renameSync(manifest + '.tmp', manifest);
return next[index];
}
function collectWeatherIconPaths(sets) {
return Array.from(new Set((Array.isArray(sets) ? sets : []).flatMap(function (set) {
return Object.values(set && set.icons || {}).concat(Object.values(set && set.nightIcons || {})).filter(function (uploadPath) {
return typeof uploadPath === 'string' && uploadPath.startsWith('/media/assets/weather-icons/');
});
})));
}
function collectWeatherIconRemovalOperations(previousSets, nextSets) {
const desired = new Set(collectWeatherIconPaths(nextSets));
return collectWeatherIconPaths(previousSets).filter(function (uploadPath) {
return !desired.has(uploadPath);
}).map(function (uploadPath) {
return { type: 'delete', uploadPath: uploadPath };
});
}
async function collectWeatherIconDirectoryUploadPaths(mediaRoot) {
const root = libraryDir(mediaRoot);
const paths = [];
async function walk(directory, relative) {
let entries;
try { entries = await fs.promises.readdir(directory, { withFileTypes: true }); } catch (_error) { return; }
await Promise.all(entries.map(async function (entry) {
const childRelative = path.join(relative, entry.name);
const child = path.join(directory, entry.name);
if (entry.isDirectory()) return walk(child, childRelative);
if (entry.isFile()) paths.push('/media/assets/weather-icons/' + childRelative.replace(/\\/g, '/'));
}));
}
await walk(root, '');
return paths;
}
function deleteWeatherIconSet(mediaRoot, id) {
const sets = readWeatherIconSets(mediaRoot);
const existing = sets.find(function (set) { return set.id === id; });
if (!existing) throw new Error('Weather icon set not found.');
const manifest = getWeatherManifestPath(mediaRoot);
fs.writeFileSync(manifest + '.tmp', JSON.stringify(sets.filter(function (set) { return set.id !== id; }), null, 2));
fs.renameSync(manifest + '.tmp', manifest);
fs.rmSync(path.join(libraryDir(mediaRoot), id), { recursive: true, force: true });
return existing;
}
module.exports = { CONDITIONS, readWeatherIconSets, saveWeatherIconSet, setWeatherIconSetEnabled, collectWeatherIconSyncOperations, collectWeatherIconRemovalOperations, collectWeatherIconDirectoryUploadPaths, deleteWeatherIconSet, getWeatherManifestPath };
+1 -1
View File
@@ -28,7 +28,7 @@ function getRegionModuleScripts(assetVersion) {
}
function getRegionEditorScripts(assetVersion) {
return REGION_CORE_SCRIPTS.map((script) => withAssetVersion(script, assetVersion)).concat(getRegionModuleScripts(assetVersion));
return ['js/shared/weather-icons.js'].concat(REGION_CORE_SCRIPTS).map((script) => withAssetVersion(script, assetVersion)).concat(getRegionModuleScripts(assetVersion));
}
module.exports = {
+83
View File
@@ -0,0 +1,83 @@
.weather-icon-upload-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: .5rem;
}
.weather-icon-upload-section + .weather-icon-upload-section {
margin-top: 1rem;
}
.weather-icon-upload-row-label {
color: var(--bs-body-color);
font-size: .875rem;
font-weight: 600;
margin-bottom: .5rem;
}
.weather-icon-upload-row-label span {
color: var(--bs-secondary-color);
font-weight: 400;
}
.weather-icon-upload-tile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: .25rem;
width: 100%;
height: 112px;
padding: .5rem;
color: var(--bs-body-color);
background: var(--bs-tertiary-bg);
border: 1px dashed var(--bs-border-color);
border-radius: 4px;
}
.weather-icon-upload-tile:hover,
.weather-icon-upload-tile:focus-visible {
border-color: var(--bs-primary);
background: var(--bs-secondary-bg);
}
.weather-icon-upload-tile:focus-visible {
outline: 2px solid var(--bs-primary);
outline-offset: 2px;
}
.weather-icon-upload-preview {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
font-size: 1.5rem;
color: var(--bs-secondary-color);
}
.weather-icon-upload-label {
font-size: .75rem;
line-height: 1.25;
text-align: center;
overflow-wrap: anywhere;
}
.weather-icon-upload.is-missing .weather-icon-upload-tile {
border-color: var(--bs-warning);
background: color-mix(in srgb, var(--bs-warning) 10%, var(--bs-tertiary-bg));
}
.weather-icon-upload.is-invalid .weather-icon-upload-tile {
border-color: var(--bs-danger);
background: color-mix(in srgb, var(--bs-danger) 10%, var(--bs-tertiary-bg));
}
.weather-icon-upload-status {
color: var(--bs-warning-text-emphasis);
font-size: .75rem;
font-weight: 600;
}
.weather-icon-upload.is-invalid .weather-icon-upload-status {
color: var(--bs-danger-text-emphasis);
}
+10 -2
View File
@@ -119,8 +119,16 @@
? 'display:block;width:100%;height:100%;object-fit:contain;'
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
if (options && options.preview && imageConfig.width && imageConfig.height) {
return '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);">' + image + '</span>';
var hasPreviewBounds = options && options.preview && imageConfig.width && imageConfig.height;
if (hasPreviewBounds || imageConfig.verticalAlign) {
var wrapperStyle = 'display:inline-flex;align-items:center;justify-content:center;';
if (hasPreviewBounds) {
wrapperStyle += 'box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);';
}
if (imageConfig.verticalAlign) {
wrapperStyle += 'vertical-align:' + imageConfig.verticalAlign + ';';
}
return '<span style="' + wrapperStyle + '">' + image + '</span>';
}
return image;
}
+10 -2
View File
@@ -138,8 +138,16 @@
? 'display:block;width:100%;height:100%;object-fit:contain;'
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
if (options && options.preview && imageConfig.width && imageConfig.height) {
return '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);">' + image + '</span>';
var hasPreviewBounds = options && options.preview && imageConfig.width && imageConfig.height;
if (hasPreviewBounds || imageConfig.verticalAlign) {
var wrapperStyle = 'display:inline-flex;align-items:center;justify-content:center;';
if (hasPreviewBounds) {
wrapperStyle += 'box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);';
}
if (imageConfig.verticalAlign) {
wrapperStyle += 'vertical-align:' + imageConfig.verticalAlign + ';';
}
return '<span style="' + wrapperStyle + '">' + image + '</span>';
}
return image;
}
+34 -9
View File
@@ -109,7 +109,7 @@
return data;
}
function substituteVariables(html, value) {
function substituteVariables(html, value, iconSetId) {
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();
@@ -117,16 +117,20 @@
if (typeof placeholderUtils.isProgressPlaceholderExpression === 'function' && placeholderUtils.isProgressPlaceholderExpression(rawExpression)) {
return placeholderUtils.renderProgressPlaceholder(value, rawExpression);
}
var iconMatch = normalizedExpression.match(/^(?:current\.weather_code|daily\.weather_code\.\d+|hourly\.weather_code\.\d+)\.icon(?:\((\d+)(?:\s*,\s*(\d+))?\))?$/);
var iconMatch = normalizedExpression.match(/^(?:current\.weather_code|daily\.weather_code\.\d+|hourly\.weather_code\.\d+)\.icon(?:\((\d+)(?:\s*,\s*(\d+))?(?:\s*,\s*(top|middle|bottom))?\))?$/);
if (iconMatch) {
var codeExpression = normalizedExpression.replace(/\.icon(?:\(.*\))?$/, '');
var sizeExpression = rawExpression.match(/\.icon\((\d+)(?:\s*,\s*(\d+))?\)$/);
var sizeExpression = rawExpression.match(/\.icon\((\d+)(?:\s*,\s*(\d+))?(?:\s*,\s*(top|middle|bottom))?\)$/);
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 alignment = sizeExpression && sizeExpression[3] || 'middle';
var source = window.pulseWeatherIcons && window.pulseWeatherIcons.imageForCode(window.pulseWeatherIconSets, iconSetId, weatherCode, window.pulseWeatherIcons.isNightForWeather(value));
if (source) return window.pulseWeatherIcons.renderImage(source, width, height, alignment);
var style = width ? ' style="display:block;font-size:' + width + 'px;line-height:1;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;
var verticalAlign = alignment === 'top' || alignment === 'bottom' ? alignment : 'middle';
return width ? '<span style="display:inline-flex;vertical-align:' + verticalAlign + ';align-items:center;justify-content:center;box-sizing:border-box;line-height:0;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 })));
});
@@ -138,7 +142,7 @@
'<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>' +
'<h3 class="h6 mt-3">Weather icons</h3><p class="small text-body-secondary">Use the icon property to insert a weather icon. Add width, height, and optional top, middle, or bottom alignment 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,96,top)}}</code></li></ul>' +
(window.placeholderInfo ? window.placeholderInfo.renderTextTransforms() : '') +
(window.placeholderInfo ? window.placeholderInfo.renderMathTransforms() : '') +
(window.placeholderInfo ? window.placeholderInfo.renderDateFormatTokens() : '') +
@@ -154,9 +158,10 @@
var snapshot = getSnapshot(location);
if (!location || !snapshot) return '';
var weatherData = withWeatherUnits(snapshot, location);
weatherData.iconSetId = content && content.weather_icon_set_id;
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>';
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, weatherData.iconSetId)) : substituteVariables(value, weatherData, weatherData.iconSetId)) + '</div></div>';
}
var current = snapshot.current || {};
var daily = snapshot.daily || {};
@@ -198,14 +203,34 @@
return { region: context.region, current: context.current || {}, weatherLocations: context.weatherLocations || [] };
}
function renderEditorCardWithIcons(context) {
var html = renderEditorCard(context);
var id = context.region.id;
var selected = context.current && context.current.weather_icon_set_id || '';
var sets = Array.isArray(window.pulseWeatherIconSets) ? window.pulseWeatherIconSets : [];
var options = sets.filter(function (set) { return set.enabled !== false; }).map(function (set) {
return '<option value="' + escapeHtml(set.id) + '"' + (selected === set.id ? ' selected' : '') + '>' + escapeHtml(set.name) + '</option>';
}).join('');
var selectedSet = sets.find(function (set) { return set.id === selected; });
if (selectedSet && selectedSet.enabled === false) {
options += '<option value="' + escapeHtml(selected) + '" selected>' + escapeHtml(selectedSet.name) + '</option>';
} else if (selected && !selectedSet) {
options += '<option value="' + escapeHtml(selected) + '" selected>Unavailable set</option>';
}
var control = '<div class="col-12 col-lg-3"><label class="form-label" for="region_weather_icon_set_id_' + escapeHtml(id) + '">Icon set</label><select class="form-select" id="region_weather_icon_set_id_' + escapeHtml(id) + '" name="region_weather_icon_set_id_' + escapeHtml(id) + '"><option value="">Built-in icons</option>' + options + '</select></div>';
html = html.replace('class="col-12 col-md-8"', 'class="col-12 col-lg-6"').replace('class="col-12 col-md-4"', 'class="col-12 col-lg-3"');
return html.replace('</select></div></div><input type="hidden" name="region_text', '</select></div>' + control + '</div><input type="hidden" name="region_text');
}
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 iconInput = card && card.querySelector('select[name="region_weather_icon_set_id_' + 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 || [] };
return { value: textInput ? textInput.value : textArea ? textArea.value : current.value || '', weather_location_id: input ? input.value : current.weather_location_id, weather_icon_set_id: iconInput ? iconInput.value : current.weather_icon_set_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 });
registry.register('weather', { label: 'Weather', getDefaultStyle: getDefaultStyle, getDefaultRegionSize: function () { return { width: 720, height: 260 }; }, renderPreview: renderPreview, renderEditorCard: renderEditorCardWithIcons, buildEditorCardContext: buildEditorCardContext, buildPreviewRenderContext: buildPreviewRenderContext });
}());
@@ -0,0 +1,59 @@
(function () {
function validateSetForm(form) {
var missing = Array.prototype.slice.call(form.querySelectorAll('[data-weather-icon-upload][data-weather-icon-required]')).filter(function (tile) {
var input = tile.querySelector('[data-weather-icon-file]');
var preview = tile.querySelector('[data-weather-icon-preview]');
return !(input.files && input.files.length) && !preview.getAttribute('src');
});
form.querySelectorAll('[data-weather-icon-upload]').forEach(function (tile) {
tile.classList.toggle('is-invalid', missing.includes(tile));
});
if (!missing.length) return true;
var labels = missing.map(function (tile) {
var label = tile.querySelector('[data-weather-icon-picker]');
return String(label && label.getAttribute('aria-label') || '').replace(/^Upload\s+|\s+icon$/g, '');
}).filter(Boolean);
if (typeof window.showToast === 'function') {
window.showToast('Add images for: ' + labels.join(', ') + '.', 'warning');
}
missing[0].querySelector('[data-weather-icon-picker]').focus();
return false;
}
document.querySelectorAll('[data-weather-icon-upload]').forEach(function (tile) {
var picker = tile.querySelector('[data-weather-icon-picker]');
var input = tile.querySelector('[data-weather-icon-file]');
var preview = tile.querySelector('[data-weather-icon-preview]');
var empty = tile.querySelector('[data-weather-icon-empty]');
var status = tile.querySelector('[data-weather-icon-status]');
var originalSource = preview.getAttribute('src') || '';
var previewUrl = '';
function updatePreview() {
if (previewUrl) URL.revokeObjectURL(previewUrl);
var file = input.files && input.files[0];
previewUrl = file ? URL.createObjectURL(file) : '';
var source = previewUrl || originalSource;
if (source) preview.src = source;
else preview.removeAttribute('src');
preview.hidden = !source;
empty.hidden = Boolean(source);
tile.classList.toggle('is-missing', !source && tile.hasAttribute('data-weather-icon-required'));
tile.classList.remove('is-invalid');
if (status) status.hidden = Boolean(source);
}
picker.addEventListener('click', function () { input.click(); });
input.addEventListener('change', updatePreview);
input.form.addEventListener('reset', function () { setTimeout(updatePreview, 0); });
window.addEventListener('pagehide', function () {
if (previewUrl) URL.revokeObjectURL(previewUrl);
});
updatePreview();
});
document.querySelectorAll('[data-weather-icon-set-form]').forEach(function (form) {
form.addEventListener('submit', function (event) {
if (!validateSetForm(form)) event.preventDefault();
});
});
}());
+2 -2
View File
@@ -58,8 +58,8 @@
function renderImageTransform() {
return '<h3 class="fs-6 mt-4 fw-normal">Image transform</h3>' +
'<p class="small">Use <code>image(width, height)</code> to render an image URL inside a proportional bounding box.</p>' +
'<ul class="small"><li>The preview shows the width and height boundary.</li><li>The player has no boundary.</li><li>The image keeps its original aspect ratio.</li><li>Both dimensions are required for a bounding box.</li></ul>';
'<p class="small">Use <code>image(width, height, alignment)</code> to render an image URL inside a proportional bounding box. Alignment can be <code>top</code>, <code>middle</code>, or <code>bottom</code> to position it relative to surrounding text.</p>' +
'<ul class="small"><li>The preview shows the width and height boundary.</li><li>The player has no boundary.</li><li>The image keeps its original aspect ratio.</li><li>Both dimensions are required for a bounding box.</li><li>Alignment is optional; without it, existing image alignment is unchanged.</li></ul>';
}
root.placeholderInfo = {
@@ -386,9 +386,11 @@
var args = imageTransform && Array.isArray(imageTransform.args) ? imageTransform.args : [];
var width = Number(args[0]);
var height = Number(args[1]);
var verticalAlign = String(args[2] || '').trim().toLowerCase();
return {
width: Number.isFinite(width) && width > 0 ? Math.round(width) : 0,
height: Number.isFinite(height) && height > 0 ? Math.round(height) : 0
height: Number.isFinite(height) && height > 0 ? Math.round(height) : 0,
verticalAlign: ['top', 'middle', 'bottom'].includes(verticalAlign) ? verticalAlign : ''
};
}
+34
View File
@@ -0,0 +1,34 @@
(function (root) {
function conditionForCode(code) {
if (code === null || code === undefined || code === '') return '';
var value = Number(code);
if (value === 0) return 'clear';
if (value === 1 || value === 2) return 'partly-cloudy';
if (value === 3) return 'overcast';
if ([45, 48].includes(value)) return 'fog';
if ([51, 53, 55, 56, 57].includes(value)) return 'drizzle';
if ([61, 63, 65, 66, 67].includes(value)) return 'rain';
if ([71, 73, 75, 77, 85, 86].includes(value)) return 'snow';
if ([80, 81, 82].includes(value)) return 'showers';
if ([95, 96, 99].includes(value)) return 'thunderstorm';
return '';
}
function imageForCode(sets, id, code, isNight) {
var set = (Array.isArray(sets) ? sets : []).find(function (entry) { return entry.id === id; });
var condition = conditionForCode(code);
var source = set && (isNight && set.nightIcons && set.nightIcons[condition] || set.icons && set.icons[condition]);
return typeof source === 'string' && /^\/media\/(?:assets\/)?weather-icons\/[a-f0-9-]+\/[a-z-]+-[a-f0-9]+\.(?:png|svg)$/.test(source) ? source : '';
}
function isNightForWeather(data) {
return Boolean(data && data.current && Number(data.current.is_day) === 0);
}
function renderImage(source, width, height, alignment) {
var iconWidth = width ? width + 'px' : '1em';
var iconHeight = height ? height + 'px' : '1em';
var verticalAlign = alignment === 'top' || alignment === 'bottom' ? alignment : 'middle';
return '<span style="display:inline-flex;vertical-align:' + verticalAlign + ';align-items:center;justify-content:center;box-sizing:border-box;line-height:0;width:' + iconWidth + ';height:' + iconHeight + ';"><img src="' + source + '" alt="" style="display:block;width:100%;height:100%;object-fit:contain;" /></span>';
}
var api = { conditionForCode: conditionForCode, imageForCode: imageForCode, isNightForWeather: isNightForWeather, renderImage: renderImage };
if (typeof module === 'object' && module.exports) module.exports = api;
else root.pulseWeatherIcons = api;
}(typeof window === 'object' ? window : globalThis));
+1
View File
@@ -23,6 +23,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var apiSources = Array.isArray(slideEditorData.apiSources) ? slideEditorData.apiSources : [];
var timetableGroups = Array.isArray(slideEditorData.timetableGroups) ? slideEditorData.timetableGroups : [];
var weatherLocations = Array.isArray(slideEditorData.weatherLocations) ? slideEditorData.weatherLocations : [];
window.pulseWeatherIconSets = Array.isArray(slideEditorData.weatherIconSets) ? slideEditorData.weatherIconSets : [];
var fontStylesheetHref = String(slideEditorData.fontStylesheetHref || '').trim();
var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null;
var existingContent = slideEditorData.existingContent || {};
+2
View File
@@ -3,6 +3,7 @@
const fs = require('fs');
const path = require('path');
const { loadFontLibrary } = require('#src/web/lib/media/font-library');
const { readWeatherIconSets } = require('#src/web/lib/media/weather-icon-library');
const { fetchAppSettings } = require('#src/data/app-settings');
const { convertWeatherSnapshot } = require('#src/data/weather-units');
const { buildAuditChanges } = require('#src/data/audit-log');
@@ -95,6 +96,7 @@ module.exports = function registerContentRoutes(app, deps) {
apiSources: apiSources,
timetableGroups: timetableData.timetableGroups || [],
weatherLocations: weatherLocations,
weatherIconSets: readWeatherIconSets(deps.uploadDir),
mediaAssets: mediaAssets,
fontLibrary: loadFontLibrary(deps.uploadDir),
uploadLimits: {
+2 -1
View File
@@ -2,6 +2,7 @@
const { verifyRequestAuth } = require('#src/request-auth');
const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library');
const { collectWeatherIconSyncOperations } = require('#src/web/lib/media/weather-icon-library');
const { collectLocalControlUsers } = require('#src/web/lib/local-control-users');
function requireRequestAuth(req, res, next) {
@@ -63,7 +64,7 @@ module.exports = function registerInternalSyncRoutes(app, deps) {
const playerIdentifier = String(requestBody.playerIdentifier || requestBody.deviceId || '').trim();
const playerPublicBaseUrl = String(requestBody.playerPublicBaseUrl || '').trim();
const playerInternalBaseUrl = String(requestBody.playerInternalBaseUrl || '').trim();
const operations = collectFontLibrarySyncOperations(mediaDir);
const operations = collectFontLibrarySyncOperations(mediaDir).concat(collectWeatherIconSyncOperations(mediaDir));
const task = await backgroundTaskQueue.enqueueTask({
key: 'player-font-sync',
title: 'Player font sync',
+2
View File
@@ -16,6 +16,7 @@ const registerTimetableRoutes = require('#src/web/routes/data-sources/timetables
const registerWeatherRoutes = require('#src/web/routes/data-sources/weather');
const registerSettingsRoutes = require('#src/web/routes/settings/background-tasks');
const registerFontRoutes = require('#src/web/routes/settings/fonts');
const registerWeatherIconRoutes = require('#src/web/routes/settings/weather-icons');
const registerMediaLibraryRoutes = require('#src/web/routes/settings/media-library');
const registerSettingsPageRoutes = require('#src/web/routes/settings/routes');
const registerAuditLogRoutes = require('#src/web/routes/settings/audit-log');
@@ -26,6 +27,7 @@ const registerOnboardingRoutes = require('#src/web/routes/onboarding');
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
function registerRoutes(app, deps) {
registerWeatherIconRoutes(app, deps);
// Group routes by the dependency bundle they need.
registerAuthAndAccountRoutes(app, deps);
registerOnboardingRoutes(app, {
+1 -1
View File
@@ -231,7 +231,7 @@ module.exports = function registerFontRoutes(app, deps) {
payload: {
uploadDir: uploadDir,
operations: [
removed.fileName ? { type: 'delete', uploadPath: '/media/fonts/' + removed.fileName } : null
removed.fileName ? { type: 'delete', uploadPath: '/media/assets/fonts/' + removed.fileName } : null
].concat(fontLibrary.collectFontLibrarySyncOperations(uploadDir)).filter(Boolean)
},
persist: true
+1
View File
@@ -6,6 +6,7 @@ module.exports = function renderFontsPage(data, message, currentUser) {
return renderView('settings/fonts/list', {
title: 'Fonts',
active: 'fonts',
assetsMenuOpen: true,
message: message,
currentUser: currentUser || null,
fonts: data.fonts || [],
+96
View File
@@ -0,0 +1,96 @@
const multer = require('multer');
const { renderView } = require('#src/web/view');
const library = require('#src/web/lib/media/weather-icon-library');
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
const { buildPagination } = require('#src/web/lib/pagination');
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query');
module.exports = function registerWeatherIconRoutes(app, deps) {
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024, files: library.CONDITIONS.length * 2, fields: 2 } });
const fields = library.CONDITIONS.flatMap(function (condition) {
return [{ name: 'icon_' + condition.key, maxCount: 1 }, { name: 'night_icon_' + condition.key, maxCount: 1 }];
});
async function page(req, res, message, status) {
const [slides] = await deps.pool.query('SELECT id, content_json FROM c_slides');
const search = getSearchQuery(req);
const sort = getSortQuery(req);
const direction = getSortDirectionQuery(req);
const sets = sortRows(library.readWeatherIconSets(deps.mediaDir).map(function (set) {
const usageCount = slides.filter(function (slide) {
const content = typeof slide.content_json === 'string' ? JSON.parse(slide.content_json || '{}') : slide.content_json || {};
return Object.values(content).some(function (region) {
return region && region.weather_icon_set_id === set.id;
});
}).length;
return Object.assign({}, set, { usageCount: usageCount, conditions: library.CONDITIONS.map(function (condition) {
return Object.assign({}, condition, { image: set.icons[condition.key], nightImage: (set.nightIcons || {})[condition.key], setId: set.id });
}) });
}), function (set) { return sort === 'usage' ? set.usageCount : sort === 'status' ? (set.enabled ? 'Enabled' : 'Disabled') : set.name; }, direction)
.filter(createSearchMatcher(search, ['name']));
const pagination = buildPagination(sets.length, parsePageNumber(req.query && req.query.page), 'page', { search: search, sort: sort, direction: direction }, 25, 'icon sets', 'Icon set pages');
const startIndex = (pagination.currentPage - 1) * pagination.pageSize;
res.status(status || 200).send(renderView('settings/weather-icons/list', {
title: 'Weather icons', active: 'weather-icons', assetsMenuOpen: true,
stylesheets: ['/assets/css/weather-icons.css'], scripts: ['js/settings/weather-icons.js'],
currentUser: req.currentUser, message: message || '', sets: sets.slice(startIndex, startIndex + pagination.pageSize), conditions: library.CONDITIONS, pagination: pagination
}));
}
async function sync(extraOperations) {
if (deps.backgroundTaskQueue) {
const players = await fetchPlayerRegistrations(deps.pool);
for (const player of players) {
await deps.backgroundTaskQueue.enqueueTask({ key: 'weather-icon-sync:' + player.identifier + ':' + Date.now(), title: 'Weather icon sync', category: 'media', taskType: 'font-sync',
payload: { uploadDir: deps.mediaDir, operations: library.collectWeatherIconSyncOperations(deps.mediaDir).concat(extraOperations || []), playerIdentifier: player.identifier, requireSuccess: true }, persist: true });
}
const [slides] = await deps.pool.query('SELECT id, content_json FROM c_slides');
for (const slide of slides) {
const content = typeof slide.content_json === 'string' ? JSON.parse(slide.content_json || '{}') : slide.content_json || {};
if (Object.values(content).some(function (region) { return region && region.weather_icon_set_id; })) {
await deps.backgroundTaskQueue.enqueueTask({ key: 'weather-icon-thumbnail:' + slide.id + ':' + Date.now(), title: 'Weather slide thumbnail refresh', category: 'media', taskType: 'slide-thumbnail-refresh', payload: { slideId: slide.id }, persist: true });
}
}
}
}
app.get('/settings/weather-icons', deps.requirePermission('weather-icons.read'), async function (req, res, next) {
try { await page(req, res, req.query.message); } catch (error) { next(error); }
});
async function save(req, res, next) {
try {
const previousSets = library.readWeatherIconSets(deps.mediaDir);
await library.saveWeatherIconSet(deps.mediaDir, req.body.name, Object.values(req.files || {}).flat(), req.params.id);
req.weatherIconRemovalOperations = library.collectWeatherIconRemovalOperations(previousSets, library.readWeatherIconSets(deps.mediaDir));
} catch (error) {
try { return await page(req, res, error.message, 400); } catch (pageError) { return next(pageError); }
}
try {
await sync(req.weatherIconRemovalOperations);
res.redirect('/settings/weather-icons?message=' + encodeURIComponent('Weather icon set saved.'));
} catch (error) { next(error); }
}
app.post('/settings/weather-icons', deps.requirePermission('weather-icons.create'), upload.fields(fields), save);
app.post('/settings/weather-icons/:id', deps.requirePermission('weather-icons.update'), upload.fields(fields), save);
app.post('/settings/weather-icons/:id/toggle', deps.requirePermission('weather-icons.update'), async function (req, res, next) {
try {
const enabled = String(req.body && req.body.enabled || '').trim() !== '0';
const updated = library.setWeatherIconSetEnabled(deps.mediaDir, req.params.id, enabled);
if (!updated) return res.redirect('/settings/weather-icons?message=' + encodeURIComponent('That weather icon set was not found.'));
await sync();
res.redirect('/settings/weather-icons?message=' + encodeURIComponent(enabled ? 'Weather icon set enabled.' : 'Weather icon set disabled.'));
} catch (error) { next(error); }
});
app.post('/settings/weather-icons/:id/delete', deps.requirePermission('weather-icons.delete'), async function (req, res, next) {
try {
const [slides] = await deps.pool.query('SELECT id, content_json FROM c_slides');
const used = slides.some(function (slide) {
const content = typeof slide.content_json === 'string' ? JSON.parse(slide.content_json || '{}') : slide.content_json || {};
return Object.values(content).some(function (region) {
return region && region.weather_icon_set_id === req.params.id;
});
});
if (used) return await page(req, res, 'That icon set is still used by one or more slides.', 400);
const removed = library.deleteWeatherIconSet(deps.mediaDir, req.params.id);
await sync(library.collectWeatherIconRemovalOperations([removed], []));
res.redirect('/settings/weather-icons?message=' + encodeURIComponent('Weather icon set deleted.'));
} catch (error) { next(error); }
});
};
@@ -51,6 +51,7 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) {
apiSources: apiSources,
timetableGroups: timetableGroups,
weatherLocations: weatherLocations,
weatherIconSets: data && data.weatherIconSets || [],
fontStylesheetHref: fontLibrary && fontLibrary.stylesheetHref ? fontLibrary.stylesheetHref : '',
fontFamilyFormats: fontLibrary && fontLibrary.fontFamilyFormats ? fontLibrary.fontFamilyFormats : '',
existingTemplateId: viewSlide && viewSlide.template_id ? viewSlide.template_id : null,
+1
View File
@@ -134,6 +134,7 @@ module.exports = function registerSlidesRoutes(app, deps) {
const payload = buildThumbnailPreviewPayload(slide, {
baseUrl: webBaseUrl || (String(req.headers.host || '').trim() ? `${req.protocol}://${String(req.headers.host).trim()}` : ''),
fontStylesheetHref: getFontStylesheetHref(mediaDir),
weatherIconSets: require('#src/web/lib/media/weather-icon-library').readWeatherIconSets(mediaDir),
apiSources: apiSources,
getItem: getItem,
getCachedImagePath: getCachedImagePath
@@ -0,0 +1,179 @@
<div class="page-header">
<div>
<h2>Weather icons</h2>
<p>Upload and manage shared icon sets for weather regions.</p>
</div>
</div>
{{#if (hasPermission currentUser 'weather-icons.create')}}
<div class="card card-outline card-primary mb-4 collapsed-card">
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0">Upload icon set</h3>
<div class="card-tools d-flex align-items-center ms-auto me-0 flex-shrink-0">
<button type="button" class="btn btn-tool p-0 text-body-secondary" data-lte-toggle="card-collapse" aria-label="Toggle upload icon set" title="Toggle upload icon set">
<i class="bi bi-plus-lg" aria-hidden="true" data-lte-icon="expand"></i>
<i class="bi bi-dash-lg" aria-hidden="true" data-lte-icon="collapse"></i>
</button>
</div>
</div>
<form method="post" action="/settings/weather-icons" enctype="multipart/form-data" class="card-body p-0" data-weather-icon-set-form>
<div class="p-3">
<div class="row g-3 mb-3">
<div class="col-12 col-lg-6">
<label class="form-label" for="new-set-name">Name</label>
<input id="new-set-name" name="name" class="form-control" maxlength="100" required>
</div>
</div>
<div class="weather-icon-upload-section">
<div class="weather-icon-upload-row-label">Day icons</div>
<div class="weather-icon-upload-grid">
{{#each conditions}}
<div class="weather-icon-upload" data-weather-icon-upload data-weather-icon-required>
<button type="button" class="weather-icon-upload-tile" data-weather-icon-picker aria-label="Upload {{label}} day icon" title="Upload {{label}} day icon">
<span class="weather-icon-upload-preview"><img data-weather-icon-preview alt="{{label}}" hidden><i class="bi bi-cloud-arrow-up" data-weather-icon-empty aria-hidden="true"></i></span>
<span class="weather-icon-upload-label">{{label}} day</span>
<span class="weather-icon-upload-status" data-weather-icon-status>Required</span>
</button>
<input id="new-icon-{{key}}" name="icon_{{key}}" type="file" accept="image/png,image/webp,image/svg+xml" hidden aria-label="{{label}} icon" data-weather-icon-file>
</div>
{{/each}}
</div>
</div>
<div class="weather-icon-upload-section">
<div class="weather-icon-upload-row-label">Night icons <span>Optional</span></div>
<div class="weather-icon-upload-grid">
{{#each conditions}}
<div class="weather-icon-upload" data-weather-icon-upload>
<button type="button" class="weather-icon-upload-tile" data-weather-icon-picker aria-label="Upload {{label}} night icon" title="Upload {{label}} night icon">
<span class="weather-icon-upload-preview"><img data-weather-icon-preview alt="{{label}} night" hidden><i class="bi bi-moon-stars" data-weather-icon-empty aria-hidden="true"></i></span>
<span class="weather-icon-upload-label">{{label}} night</span>
</button>
<input id="new-night-icon-{{key}}" name="night_icon_{{key}}" type="file" accept="image/png,image/webp,image/svg+xml" hidden aria-label="{{label}} night icon" data-weather-icon-file>
</div>
{{/each}}
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
<button class="btn btn-primary" type="submit">Create set</button>
</div>
</form>
</div>
{{/if}}
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
<div class="card-header">
<h3 class="card-title">Managed icon sets</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(14rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search icon sets" aria-label="Search icon sets" data-table-search>
</div>
</div>
</div>
<div class="card-body table-responsive p-0">
<table class="table table-striped align-middle mb-0" data-table-searchable>
<thead><tr>
<th data-table-sort-key="name">Name</th>
<th>Preview</th>
<th data-table-sort-key="usage">Slides</th>
<th data-table-sort-key="status">Status</th>
{{#if (anyPermission currentUser 'weather-icons.update' 'weather-icons.delete')}}<th>Actions</th>{{/if}}
</tr></thead>
<tbody>
{{#each sets}}
<tr data-table-search-row>
<td data-label="Name" class="fw-semibold text-break">{{name}}</td>
<td data-label="Preview">
<div class="d-flex flex-wrap gap-2" style="min-width:144px;">
{{#each conditions}}
<div class="d-flex align-items-center justify-content-center border rounded bg-body-tertiary" style="width:40px;height:40px;flex-shrink:0;" title="{{label}}">
{{#if image}}<img src="{{image}}" alt="{{label}}" width="32" height="32" style="object-fit:contain;">
{{else}}<i class="bi bi-cloud text-body-secondary" aria-label="{{label}}: built-in fallback"></i>{{/if}}
</div>
{{/each}}
</div>
</td>
<td data-label="Slides">{{usageCount}}</td>
<td data-label="Status">{{#if enabled}}<span class="badge text-bg-success">Enabled</span>{{else}}<span class="badge text-bg-secondary">Disabled</span>{{/if}}</td>
{{#if (anyPermission ../currentUser 'weather-icons.update' 'weather-icons.delete')}}
<td data-label="Actions" class="text-nowrap">
{{#if (hasPermission ../currentUser 'weather-icons.update')}}
<button type="button" class="btn btn-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#edit-set-{{id}}">Edit</button>
<form method="post" action="/settings/weather-icons/{{id}}/toggle" class="d-inline ms-1">
<input type="hidden" name="enabled" value="{{#if enabled}}0{{else}}1{{/if}}">
<button type="submit" class="btn btn-secondary btn-sm">{{#if enabled}}Disable{{else}}Enable{{/if}}</button>
</form>
{{/if}}
{{#if (hasPermission ../currentUser 'weather-icons.delete')}}
<form method="post" action="/settings/weather-icons/{{id}}/delete" class="d-inline ms-1" data-confirm-message="Delete this weather icon set?">
<button type="submit" class="btn {{#if usageCount}}btn-outline-danger{{else}}btn-danger{{/if}} btn-sm" {{#if usageCount}}disabled{{/if}}>Delete</button>
</form>
{{/if}}
</td>
{{/if}}
</tr>
{{else}}
<tr data-table-search-empty-default><td colspan="{{#if (anyPermission currentUser 'weather-icons.update' 'weather-icons.delete')}}5{{else}}4{{/if}}" class="empty">No uploaded icon sets.</td></tr>
{{/each}}
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/settings/weather-icons" alwaysShow=true}}
</div>
{{#each sets}}
{{#if (hasPermission ../currentUser 'weather-icons.update')}}
<div class="modal fade" id="edit-set-{{id}}" tabindex="-1" aria-labelledby="edit-set-title-{{id}}" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable"><div class="modal-content">
<div class="modal-header">
<h3 class="modal-title fs-5 text-break" id="edit-set-title-{{id}}">Edit {{name}}</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="/settings/weather-icons/{{id}}" enctype="multipart/form-data" class="d-flex flex-column overflow-hidden" data-weather-icon-set-form>
<div class="modal-body">
<div class="row g-3 mb-3">
<div class="col-12 col-lg-6">
<label class="form-label" for="set-name-{{id}}">Name</label>
<input id="set-name-{{id}}" name="name" value="{{name}}" class="form-control" maxlength="100" required>
</div>
</div>
<div class="weather-icon-upload-section">
<div class="weather-icon-upload-row-label">Day icons</div>
<div class="weather-icon-upload-grid">
{{#each conditions}}
<div class="weather-icon-upload" data-weather-icon-upload data-weather-icon-required>
<button type="button" class="weather-icon-upload-tile" data-weather-icon-picker aria-label="Upload {{label}} day icon" title="Upload {{label}} day icon">
<span class="weather-icon-upload-preview"><img data-weather-icon-preview alt="{{label}}" {{#if image}}src="{{image}}"{{else}}hidden{{/if}}><i class="bi bi-cloud-arrow-up" data-weather-icon-empty aria-hidden="true" {{#if image}}hidden{{/if}}></i></span>
<span class="weather-icon-upload-label">{{label}} day</span>
<span class="weather-icon-upload-status" data-weather-icon-status {{#if image}}hidden{{/if}}>Required</span>
</button>
<input id="icon-{{setId}}-{{key}}" name="icon_{{key}}" type="file" accept="image/png,image/webp,image/svg+xml" hidden aria-label="{{label}} icon" data-weather-icon-file>
</div>
{{/each}}
</div>
</div>
<div class="weather-icon-upload-section">
<div class="weather-icon-upload-row-label">Night icons <span>Optional</span></div>
<div class="weather-icon-upload-grid">
{{#each conditions}}
<div class="weather-icon-upload" data-weather-icon-upload>
<button type="button" class="weather-icon-upload-tile" data-weather-icon-picker aria-label="Upload {{label}} night icon" title="Upload {{label}} night icon">
<span class="weather-icon-upload-preview"><img data-weather-icon-preview alt="{{label}} night" {{#if nightImage}}src="{{nightImage}}"{{else}}hidden{{/if}}><i class="bi bi-moon-stars" data-weather-icon-empty aria-hidden="true" {{#if nightImage}}hidden{{/if}}></i></span>
<span class="weather-icon-upload-label">{{label}} night</span>
</button>
<input id="night-icon-{{setId}}-{{key}}" name="night_icon_{{key}}" type="file" accept="image/png,image/webp,image/svg+xml" hidden aria-label="{{label}} night icon" data-weather-icon-file>
</div>
{{/each}}
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-primary" type="submit">Save</button>
</div>
</form>
</div></div>
</div>
{{/if}}
{{/each}}
+19 -9
View File
@@ -282,17 +282,9 @@
</a>
</li>
{{/if}}
{{#if (anyPermission currentUser 'system-settings.read' 'audit-log.read' 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read' 'invitations.read' 'fonts.read')}}
{{#if (anyPermission currentUser 'system-settings.read' 'audit-log.read' 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read' 'invitations.read' 'fonts.read' 'weather-icons.read')}}
<li class="nav-header">SETTINGS</li>
{{/if}}
{{#if (hasPermission currentUser 'fonts.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'fonts')}}active{{/if}}" href="/settings/fonts">
<i class="nav-icon bi bi-type"></i>
<p>Fonts</p>
</a>
</li>
{{/if}}
{{#if (anyPermission currentUser 'users.read' 'rbac.read' 'invitations.read')}}
<li class="nav-item has-treeview{{#if userManagementMenuOpen}} menu-open{{/if}}">
<a class="nav-link{{#if userManagementMenuOpen}} active{{/if}}" href="#">
@@ -330,6 +322,24 @@
</ul>
</li>
{{/if}}
{{#if (anyPermission currentUser 'fonts.read' 'weather-icons.read')}}
<li class="nav-item has-treeview{{#if assetsMenuOpen}} menu-open{{/if}}">
<a class="nav-link{{#if assetsMenuOpen}} active{{/if}}" href="#"><i class="nav-icon bi bi-folder2-open"></i><p>Assets<i class="nav-arrow bi bi-chevron-right"></i></p></a>
<ul class="nav nav-treeview">
{{#if (hasPermission currentUser 'fonts.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'fonts')}}active{{/if}}" href="/settings/fonts">
<i class="nav-icon bi bi-type"></i>
<p>Fonts</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'weather-icons.read')}}
<li class="nav-item"><a class="nav-link {{#if (eq active 'weather-icons')}}active{{/if}}" href="/settings/weather-icons"><i class="nav-icon bi bi-cloud-sun"></i><p>Weather icons</p></a></li>
{{/if}}
</ul>
</li>
{{/if}}
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read')}}
<li class="nav-item has-treeview{{#if backgroundTasksMenuOpen}} menu-open{{/if}}">
<a class="nav-link{{#if backgroundTasksMenuOpen}} active{{/if}}" href="#">