Start of themes development

This commit is contained in:
mrkmntal 2026-04-08 12:49:21 -04:00
commit fc97cda830
16 changed files with 149 additions and 11 deletions

View file

@ -19,6 +19,7 @@ import dartSass from 'sass';
import gulpSass from 'gulp-sass';
import sourceMaps from 'gulp-sourcemaps';
import OVERRIDES from '../src/overrides.mjs';
import { discoverThemes } from '../src/theme-discovery.mjs';
// get cloudfront
import reader from '../src/playlist-reader.mjs';
@ -111,6 +112,7 @@ const htmlSources = [
];
const packageJson = await readFile('package.json');
let { version } = JSON.parse(packageJson);
const { themes, themeAssets } = await discoverThemes();
const previewVersion = async () => {
// generate a relatively unique timestamp for cache invalidation of the preview site
const now = new Date();
@ -123,6 +125,8 @@ const compressHtml = async () => src(htmlSources)
production: version,
serverAvailable: false,
version,
themes,
themeAssets,
OVERRIDES,
query: {},
}))
@ -138,6 +142,9 @@ const otherFiles = [
const copyOtherFiles = () => src(otherFiles, { base: 'server/', encoding: false })
.pipe(dest('./dist'));
const copyThemes = () => src('themes/**', { base: '.', encoding: false })
.pipe(dest('./dist'));
// Copy JSON data files for static hosting
const copyDataFiles = () => src([
'datagenerators/output/travelcities.json',
@ -220,7 +227,7 @@ const logVersion = async () => {
log(`Version Published: ${version}`);
};
const buildDist = series(clean, parallel(buildJs, compressJsVendor, buildCss, compressHtml, copyOtherFiles, copyDataFiles, copyImageSources, buildPlaylist));
const buildDist = series(clean, parallel(buildJs, compressJsVendor, buildCss, compressHtml, copyOtherFiles, copyThemes, copyDataFiles, copyImageSources, buildPlaylist));
// upload_images could be in parallel with upload, but _images logs a lot and has little changes
// by running upload last the majority of the changes will be at the bottom of the log for easy viewing
@ -231,6 +238,7 @@ export default publishFrontend;
export {
buildDist,
copyThemes,
invalidate,
stageFrontend,
};

View file

@ -19,6 +19,7 @@ import playlist from './src/playlist.mjs';
import OVERRIDES from './src/overrides.mjs';
import cache from './proxy/cache.mjs';
import devTools from './src/com.chrome.devtools.mjs';
import { discoverThemes } from './src/theme-discovery.mjs';
const execAsync = promisify(exec);
@ -90,6 +91,7 @@ const travelCities = JSON.parse(await readFile('./datagenerators/output/travelci
const regionalCities = JSON.parse(await readFile('./datagenerators/output/regionalcities.json'));
const stationInfo = JSON.parse(await readFile('./datagenerators/output/stations.json'));
const radarCities = JSON.parse(await readFile('./datagenerators/output/radarcities.json'));
const { themes, themeAssets } = await discoverThemes();
const app = express();
const port = process.env.WS4KP_PORT ?? 8080;
@ -134,6 +136,8 @@ const renderIndex = (req, res, production = false) => {
production,
serverAvailable: !process.env?.STATIC, // Disable caching proxy server in static mode
version,
themes,
themeAssets,
OVERRIDES,
query: req.query,
});
@ -266,6 +270,7 @@ if (!process.env?.STATIC) {
app.use('/rainviewer/', rainViewerProxy);
app.use('/arcgis-server/', arcGisServerProxy);
app.use('/arcgis-services/', arcGisServicesProxy);
app.use('/themes', express.static('./themes', staticOptions));
// Playlist route is available in server mode (not in static mode)
app.get('/playlist.json', playlist);
@ -295,6 +300,7 @@ if (process.env?.DIST === '1') {
app.use('/scripts', express.static('./server/scripts', staticOptions));
app.use('/geoip', geoip);
app.use('/music', express.static('./server/music', staticOptions));
app.use('/themes', express.static('./dist/themes', staticOptions));
// render the EJS template in production mode (serve compressed files from dist directory)
app.get('/', (req, res) => { renderIndex(req, res, true); });
@ -305,6 +311,7 @@ if (process.env?.DIST === '1') {
app.get('/index.html', index);
app.use('/geoip', geoip);
app.use('/resources', express.static('./server/scripts/modules'));
app.use('/themes', express.static('./themes', staticOptions));
app.get('/', index);
app.get('/.well-known/appspecific/com.chrome.devtools.json', devTools);
app.get('*name', express.static('./server', staticOptions));

View file

@ -6,6 +6,7 @@ import {
import { round2 } from './modules/utils/units.mjs';
import { registerHiddenSetting } from './modules/share.mjs';
import settings from './modules/settings.mjs';
import './modules/utils/theme.mjs';
import AutoComplete from './modules/autocomplete.mjs';
import { loadAllData } from './modules/utils/data-loader.mjs';
import { debugFlag } from './modules/utils/debug.mjs';

View file

@ -0,0 +1,69 @@
const THEME_STORAGE_KEY = 'settings-theme-select';
const DEFAULT_THEME = 'default';
const BUILTIN_ASSETS = {
background1: '../images/backgrounds/1.png',
background2: '../images/backgrounds/2.png',
logoCorner: 'images/logos/logo-corner.png',
};
const getThemeAssets = () => window.WS4KP_THEME_ASSETS ?? {};
const getAvailableThemes = () => window.WS4KP_THEMES ?? [DEFAULT_THEME];
const getStoredTheme = () => {
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY) ?? DEFAULT_THEME;
return getAvailableThemes().includes(storedTheme) ? storedTheme : DEFAULT_THEME;
};
const getThemeAssetUrl = (themeName, assetKey) => {
if (themeName === DEFAULT_THEME) {
return BUILTIN_ASSETS[assetKey];
}
const themeAssetAvailability = getThemeAssets()[themeName] ?? {};
if (!themeAssetAvailability[assetKey]) {
return BUILTIN_ASSETS[assetKey];
}
switch (assetKey) {
case 'background1':
return `../themes/${themeName}/1.png`;
case 'background2':
return `../themes/${themeName}/2.png`;
case 'logoCorner':
return `themes/${themeName}/logo-corner.png`;
default:
return BUILTIN_ASSETS[assetKey];
}
};
const applyTheme = (themeName) => {
const selectedTheme = getAvailableThemes().includes(themeName) ? themeName : DEFAULT_THEME;
localStorage.setItem(THEME_STORAGE_KEY, selectedTheme);
document.documentElement.style.setProperty('--theme-background-1', `url('${getThemeAssetUrl(selectedTheme, 'background1')}')`);
document.documentElement.style.setProperty('--theme-background-2', `url('${getThemeAssetUrl(selectedTheme, 'background2')}')`);
document.querySelectorAll('.theme-logo').forEach((img) => {
img.src = getThemeAssetUrl(selectedTheme, 'logoCorner');
});
const select = document.querySelector('#theme-select');
if (select && select.value !== selectedTheme) {
select.value = selectedTheme;
}
return selectedTheme;
};
document.addEventListener('DOMContentLoaded', () => {
applyTheme(getStoredTheme());
const select = document.querySelector('#theme-select');
if (!select) return;
select.addEventListener('change', (event) => applyTheme(event.target.value));
select.value = getStoredTheme();
applyTheme(select.value);
});
export {
applyTheme,
getStoredTheme,
};

View file

@ -2,7 +2,7 @@
@use 'shared/_utils'as u;
#extended-forecast-html.weather-display {
background-image: url('../images/backgrounds/2.png');
background-image: var(--theme-background-2);
}
.weather-display .main.extended-forecast {
@ -69,4 +69,4 @@
}
}
}
}
}

View file

@ -328,6 +328,11 @@ body {
font-display: swap;
}
:root {
--theme-background-1: url('../images/backgrounds/1.png');
--theme-background-2: url('../images/backgrounds/2.png');
}
#display {
font-family: "Star4000";
margin: 0 0 0 0;
@ -339,7 +344,7 @@ body {
width: 640px;
height: 480px;
// overflow: hidden;
background-image: url(../images/backgrounds/1.png);
background-image: var(--theme-background-1);
transform-origin: 0 0;
background-repeat: no-repeat;
}
@ -821,4 +826,4 @@ body.kiosk #loading .instructions {
display: grid;
grid-template-columns: 1fr 1fr;
max-width: 250px;
}
}

View file

@ -6,7 +6,7 @@
height: 480px;
overflow: hidden;
position: relative;
background-image: url(../images/backgrounds/1.png);
background-image: var(--theme-background-1);
/* this method is required to hide blocks so they can be measured while off screen */
height: 0px;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

36
src/theme-discovery.mjs Normal file
View file

@ -0,0 +1,36 @@
import { readdir } from 'fs/promises';
import path from 'path';
const THEMES_DIR = path.resolve('./themes');
const discoverThemes = async () => {
try {
const entries = await readdir(THEMES_DIR, { withFileTypes: true });
const directories = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
const themeAssets = {};
await Promise.all(directories.map(async (themeName) => {
const files = await readdir(path.join(THEMES_DIR, themeName));
themeAssets[themeName] = {
background1: files.includes('1.png'),
background2: files.includes('2.png'),
logoCorner: files.includes('logo-corner.png'),
};
}));
return {
themes: ['default', ...directories],
themeAssets,
};
} catch {
return {
themes: ['default'],
themeAssets: {},
};
}
};
export {
THEMES_DIR,
discoverThemes,
};

BIN
themes/oceanview/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

BIN
themes/oceanview/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -31,6 +31,10 @@
window.WS4KP_SERVER_AVAILABLE = true;
</script>
<% } %>
<script type="text/javascript">
window.WS4KP_THEMES = <%- JSON.stringify(themes ?? ['default']) %>;
window.WS4KP_THEME_ASSETS = <%- JSON.stringify(themeAssets ?? {}) %>;
</script>
<% if (production) { %>
<link rel="stylesheet" type="text/css" href="resources/ws.min.css?_=<%=production%>" />
@ -180,6 +184,14 @@
<div class="info">
<a href="https://mentalnet.xyz/forgejo/markmental/ws4kp-linhanced">More information</a>
</div>
<div class="info">
<label for="theme-select">Theme</label>
<select id="theme-select" name="theme-select">
<% (themes ?? ['default']).forEach((themeName) => { %>
<option value="<%= themeName %>"><%= themeName === 'default' ? 'Default' : themeName %></option>
<% }) %>
</select>
</div>
<div class="media"></div>
<div class='heading'>Selected displays</div>

View file

@ -1,5 +1,5 @@
<div class="header">
<div class="logo"><img src="images/logos/logo-corner.png" /></div>
<div class="logo"><img class="theme-logo" src="images/logos/logo-corner.png" /></div>
<% if (locals?.titleDual) { %>
<div class="title dual">
<div class="top">
@ -25,4 +25,4 @@
<img src="images/logos/noaa.gif" />
</div>
<%}%>
</div>
</div>

View file

@ -1,5 +1,5 @@
<div class="header">
<div class="logo"><img src="images/logos/logo-corner.png" /></div>
<div class="logo"><img class="theme-logo" src="images/logos/logo-corner.png" /></div>
<div class="title dual">
<div class="top">
Local