Release v2.14.2
This commit is contained in:
1 parent
c3299abaac
commit
ddcc665f81
47 files changed
+1209
-66
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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 || [],
|
||||
|
||||
@@ -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 };
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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/');
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}());
|
||||
@@ -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 : ''
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}}
|
||||
@@ -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="#">
|
||||
|
||||
Reference in new issue
Block a user