diff --git a/public/language/en-GB/admin/appearance/skins.json b/public/language/en-GB/admin/appearance/skins.json index a4c6f150de..c98482f27b 100644 --- a/public/language/en-GB/admin/appearance/skins.json +++ b/public/language/en-GB/admin/appearance/skins.json @@ -1,5 +1,12 @@ { "skins": "Skins", + "bootswatch-skins": "Bootswatch Skins", + "custom-skins": "Custom Skins", + "add-skin": "Add Skin", + "save-custom-skins": "Save Custom Skins", + "save-custom-skins-success": "Custom skins saved successfully", + "custom-skin-name": "Custom Skin Name", + "custom-skin-variables": "Custom Skin Variables", "loading": "Loading Skins...", "homepage": "Homepage", "select-skin": "Select Skin", diff --git a/public/src/admin/appearance/skins.js b/public/src/admin/appearance/skins.js index eb3574206d..9d3b7b92d3 100644 --- a/public/src/admin/appearance/skins.js +++ b/public/src/admin/appearance/skins.js @@ -1,7 +1,9 @@ 'use strict'; -define('admin/appearance/skins', ['translator', 'alerts'], function (translator, alerts) { +define('admin/appearance/skins', [ + 'translator', 'alerts', 'settings', +], function (translator, alerts, settings) { const Skins = {}; Skins.init = function () { @@ -11,6 +13,15 @@ define('admin/appearance/skins', ['translator', 'alerts'], function (translator, url: 'https://bootswatch.com/api/5.json', }).done(Skins.render); + settings.load('custom-skins', $('.custom-skin-settings')); + $('#save-custom-skins').on('click', function () { + settings.save('custom-skins', $('.custom-skin-settings'), function () { + alerts.success('[[admin/appearance/skins:save-custom-skins-success]]'); + }); + return false; + }); + + $('#skins').on('click', function (e) { let target = $(e.target); diff --git a/src/controllers/accounts/settings.js b/src/controllers/accounts/settings.js index 250738186a..46b54dd271 100644 --- a/src/controllers/accounts/settings.js +++ b/src/controllers/accounts/settings.js @@ -14,6 +14,7 @@ const notifications = require('../../notifications'); const db = require('../../database'); const helpers = require('../helpers'); const accountHelpers = require('./helpers'); +const slugify = require('../../slugify'); const settingsController = module.exports; @@ -39,13 +40,15 @@ settingsController.get = async function (req, res, next) { uid: req.uid, }); - const [notificationSettings, routes] = await Promise.all([ + const [notificationSettings, routes, bsSkinOptions] = await Promise.all([ getNotificationSettings(userData), getHomePageRoutes(userData), + getSkinOptions(userData), ]); userData.customSettings = data.customSettings; userData.homePageRoutes = routes; + userData.bootswatchSkinOptions = bsSkinOptions; userData.notificationSettings = notificationSettings; userData.disableEmailSubscriptions = meta.config.disableEmailSubscriptions; @@ -57,8 +60,6 @@ settingsController.get = async function (req, res, next) { { value: 'month', name: '[[user:digest_monthly]]', selected: userData.settings.dailyDigestFreq === 'month' }, ]; - getSkinOptions(userData); - userData.languages.forEach((language) => { language.selected = language.code === userData.settings.userLang; }); @@ -220,17 +221,28 @@ async function getHomePageRoutes(userData) { return routes; } -function getSkinOptions(userData) { +async function getSkinOptions(userData) { const defaultSkin = _.capitalize(meta.config.bootswatchSkin) || '[[user:no-skin]]'; - userData.bootswatchSkinOptions = [ + const bootswatchSkinOptions = [ { name: '[[user:no-skin]]', value: 'noskin' }, { name: `[[user:default, ${defaultSkin}]]`, value: '' }, ]; - userData.bootswatchSkinOptions.push( + const customSkins = await meta.settings.get('custom-skins'); + if (customSkins && Array.isArray(customSkins['custom-skin-list'])) { + customSkins['custom-skin-list'].forEach((customSkin) => { + bootswatchSkinOptions.push({ + name: customSkin['custom-skin-name'], + value: slugify(customSkin['custom-skin-name']), + }); + }); + } + + bootswatchSkinOptions.push( ...meta.css.supportedSkins.map(skin => ({ name: _.capitalize(skin), value: skin })) ); - userData.bootswatchSkinOptions.forEach((skin) => { + bootswatchSkinOptions.forEach((skin) => { skin.selected = skin.value === userData.settings.bootswatchSkin; }); + return bootswatchSkinOptions; } diff --git a/src/controllers/api.js b/src/controllers/api.js index 5ba40c1566..e0f23206f9 100644 --- a/src/controllers/api.js +++ b/src/controllers/api.js @@ -117,7 +117,7 @@ apiController.loadConfig = async function (req) { if (!config.disableCustomUserSkins && settings.bootswatchSkin) { if (settings.bootswatchSkin === 'noskin') { config.bootswatchSkin = ''; - } else if (settings.bootswatchSkin !== '') { + } else if (settings.bootswatchSkin !== '' && await meta.css.isSkinValid(settings.bootswatchSkin)) { config.bootswatchSkin = settings.bootswatchSkin; } } diff --git a/src/meta/css.js b/src/meta/css.js index f7b4386870..72079c891c 100644 --- a/src/meta/css.js +++ b/src/meta/css.js @@ -1,5 +1,6 @@ 'use strict'; +const _ = require('lodash'); const winston = require('winston'); const nconf = require('nconf'); const fs = require('fs'); @@ -48,11 +49,19 @@ const buildImports = { function boostrapImport(themeData) { // see https://getbootstrap.com/docs/5.0/customize/sass/#variable-defaults // for an explanation of this order and https://bootswatch.com/help/ - const { bootswatchSkin, bsVariables } = themeData; + const { bootswatchSkin, bsVariables, isCustomSkin } = themeData; + function bsvariables() { + if (bootswatchSkin) { + if (isCustomSkin) { + return themeData._variables || ''; + } + return `@import "bootswatch/dist/${bootswatchSkin}/variables";`; + } + return bsVariables; + } + return [ - bootswatchSkin ? - `@import "bootswatch/dist/${bootswatchSkin}/variables";` : - bsVariables, + bsvariables(), '@import "bootstrap/scss/mixins/banner";', '@include bsBanner("");', // functions must be included first @@ -115,7 +124,7 @@ function boostrapImport(themeData) { '@import "generics";', '@import "client";', // core page styles '@import "./theme";', // rest of the theme scss - bootswatchSkin ? `@import "bootswatch/dist/${bootswatchSkin}/bootswatch";` : '', + bootswatchSkin && !isCustomSkin ? `@import "bootswatch/dist/${bootswatchSkin}/bootswatch";` : '', ].join('\n'); } @@ -171,11 +180,14 @@ async function getBundleMetadata(target) { // Skin support let skin; + let isCustomSkin = false; if (target.startsWith('client-')) { - skin = target.split('-')[1]; - - if (CSS.supportedSkins.includes(skin)) { - target = 'client'; + skin = target.split('-').slice(1).join('-'); + const isBootswatchSkin = CSS.supportedSkins.includes(skin); + isCustomSkin = !isBootswatchSkin && await CSS.isCustomSkin(skin); + target = 'client'; + if (!isBootswatchSkin && !isCustomSkin) { + skin = ''; // invalid skin or deleted use default } } @@ -191,6 +203,9 @@ async function getBundleMetadata(target) { paths.unshift(`${baseThemePath}/node_modules`); themeData.bsVariables = parseInt(themeData.useBSVariables, 10) === 1 ? (themeData.bsVariables || '') : ''; themeData.bootswatchSkin = skin; + themeData.isCustomSkin = isCustomSkin; + const customSkin = isCustomSkin ? await CSS.getCustomSkin(skin) : null; + themeData._variables = customSkin && customSkin._variables; } const [scssImports, cssImports, acpScssImports] = await Promise.all([ @@ -210,6 +225,80 @@ async function getBundleMetadata(target) { return { paths: paths, imports: imports }; } +CSS.getSkinSwitcherOptions = async function (uid) { + const user = require('../user'); + const meta = require('./index'); + const [userSettings, customSkins] = await Promise.all([ + user.getSettings(uid), + CSS.getCustomSkins(), + ]); + + const defaultSkin = _.capitalize(meta.config.bootswatchSkin) || '[[user:no-skin]]'; + const defaultSkins = [ + { name: `[[user:default, ${defaultSkin}]]`, value: '', selected: userSettings.bootswatchSkin === '' }, + { name: '[[user:no-skin]]', value: 'noskin', selected: userSettings.bootswatchSkin === 'noskin' }, + ]; + const lightSkins = [ + 'cerulean', 'cosmo', 'flatly', 'journal', 'litera', + 'lumen', 'lux', 'materia', 'minty', 'morph', 'pulse', 'sandstone', + 'simplex', 'sketchy', 'spacelab', 'united', 'yeti', 'zephyr', + ]; + const darkSkins = [ + 'cyborg', 'darkly', 'quartz', 'slate', 'solar', 'superhero', 'vapor', + ]; + function parseSkins(skins) { + skins = skins.map(skin => ({ + name: _.capitalize(skin), + value: skin, + })); + skins.forEach((skin) => { + skin.selected = skin.value === userSettings.bootswatchSkin; + }); + return skins; + } + + return { + default: defaultSkins, + custom: customSkins.map(s => ({...s, selected: s.value === userSettings.bootswatchSkin })), + light: parseSkins(lightSkins), + dark: parseSkins(darkSkins), + }; +}; + +CSS.getCustomSkins = async function (opts = {}) { + const meta = require('./index'); + const slugify = require('../slugify'); + const { loadVariables } = opts; + const customSkins = await meta.settings.get('custom-skins'); + const returnSkins = []; + if (customSkins && Array.isArray(customSkins['custom-skin-list'])) { + customSkins['custom-skin-list'].forEach((customSkin) => { + if (customSkin) { + returnSkins.push({ + name: customSkin['custom-skin-name'], + value: slugify(customSkin['custom-skin-name']), + _variables: loadVariables ? customSkin._variables : undefined, + }); + } + }); + } + return returnSkins; +}; + +CSS.isSkinValid = async function (skin) { + return CSS.supportedSkins.includes(skin) || await CSS.isCustomSkin(skin); +}; + +CSS.isCustomSkin = async function (skin) { + const skins = await CSS.getCustomSkins(); + return !!skins.find(s => s.value === skin); +}; + +CSS.getCustomSkin = async function (skin) { + const skins = await CSS.getCustomSkins({ loadVariables: true }); + return skins.find(s => s.value === skin); +}; + CSS.buildBundle = async function (target, fork) { if (target === 'client') { let files = await fs.promises.readdir(path.join(__dirname, '../../build/public')); diff --git a/src/middleware/index.js b/src/middleware/index.js index adf53c8c5e..a2554b161b 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -229,13 +229,19 @@ middleware.delayLoading = function delayLoading(req, res, next) { middleware.buildSkinAsset = helpers.try(async (req, res, next) => { // If this middleware is reached, a skin was requested, so it is built on-demand - const target = path.basename(req.originalUrl).match(/(client-[a-z]+)/); - if (!target) { + const targetSkin = path.basename(req.originalUrl).split('.css')[0]; + if (!targetSkin) { + return next(); + } + + const skins = (await meta.css.getCustomSkins()).map(skin => skin.value); + const found = skins.concat(meta.css.supportedSkins).find(skin => `client-${skin}` === targetSkin); + if (!found) { return next(); } await plugins.prepareForBuild(['client side styles']); - const [ltr, rtl] = await meta.css.buildBundle(target[0], true); + const [ltr, rtl] = await meta.css.buildBundle(targetSkin, true); require('../meta/minifier').killAll(); res.status(200).type('text/css').send(req.originalUrl.includes('-rtl') ? rtl : ltr); }); diff --git a/src/routes/index.js b/src/routes/index.js index fb4733a501..4008f1565a 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -191,11 +191,8 @@ function addCoreRoutes(app, router, middleware, mounts) { res.redirect(`${relativePath}/assets/plugins${req.path}${req._parsedUrl.search || ''}`); }); - // Skins - meta.css.supportedSkins.forEach((skin) => { - app.use(`${relativePath}/assets/client-${skin}.css`, middleware.buildSkinAsset); - app.use(`${relativePath}/assets/client-${skin}-rtl.css`, middleware.buildSkinAsset); - }); + app.use(`${relativePath}/assets/client-*.css`, middleware.buildSkinAsset); + app.use(`${relativePath}/assets/client-*-rtl.css`, middleware.buildSkinAsset); app.use(controllers['404'].handle404); app.use(controllers.errors.handleURIErrors); diff --git a/src/views/admin/appearance/skins.tpl b/src/views/admin/appearance/skins.tpl index 89c1edf003..6b64335d0e 100644 --- a/src/views/admin/appearance/skins.tpl +++ b/src/views/admin/appearance/skins.tpl @@ -1,7 +1,11 @@