mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-10-29 18:16:17 +01:00
feat: custom skins panel in acp
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
105
src/meta/css.js
105
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)) {
|
||||
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'));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<div id="skins" class="d-flex flex-column gap-2 px-lg-4">
|
||||
<div class="d-flex border-bottom py-2 m-0 sticky-top acp-page-main-header align-items-center justify-content-between flex-wrap gap-2">
|
||||
<div class="">
|
||||
<div class="d-flex flex-wrap gap-3 align-items-center">
|
||||
<h4 class="fw-bold tracking-tight mb-0">[[admin/appearance/skins:skins]]</h4>
|
||||
<ul class="nav nav-pills d-flex gap-1 text-sm">
|
||||
<li class="nav-item"><a href="#" class="nav-link active px-2 py-1" data-bs-target="#skins-tab" data-bs-toggle="tab">[[admin/appearance/skins:bootswatch-skins]]</a></li>
|
||||
<li class="nav-item"><a href="#" class="nav-link px-2 py-1" data-bs-target="#custom-skins-tab" data-bs-toggle="tab">[[admin/appearance/skins:custom-skins]]</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<div data-type="bootswatch" data-theme="" data-css="">
|
||||
@@ -9,9 +13,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" role="tabpanel" id="skins-tab">
|
||||
<div class="skins px-2">
|
||||
<div class="directory row text-center" id="bootstrap_themes">
|
||||
<i class="fa fa-refresh fa-spin"></i> [[admin/appearance/skins:loading]]
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" role="tabpanel" id="custom-skins-tab">
|
||||
<form role="form" class="custom-skin-settings">
|
||||
<div class="mb-3" data-type="sorted-list" data-sorted-list="custom-skin-list" data-item-template="admin/partials/appearance/skins/item-custom-skin" data-form-template="admin/partials/appearance/skins/form-custom-skin">
|
||||
<input hidden="text" name="custom-skin-list">
|
||||
<div class="d-flex gap-1 mb-3 justify-content-between">
|
||||
<button type="button" data-type="add" class="btn btn-sm btn-light">[[admin/appearance/skins:add-skin]]</button>
|
||||
<button id="save-custom-skins" class="btn btn-sm btn-primary">[[admin/appearance/skins:save-custom-skins]]</button>
|
||||
</div>
|
||||
|
||||
<ul data-type="list" class="list-group mb-3"></ul>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<form>
|
||||
<div class="mb-3">
|
||||
<label for="name">[[admin/appearance/skins:custom-skin-name]]</label>
|
||||
<input type="text" name="custom-skin-name" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="_variables">[[admin/appearance/skins:custom-skin-variables]]</label>
|
||||
<textarea name="_variables" class="form-control" rows="20"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,11 @@
|
||||
<li data-type="item" class="list-group-item">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="">
|
||||
<strong>{custom-skin-name}</strong>
|
||||
</div>
|
||||
<div class="">
|
||||
<button type="button" data-type="edit" class="btn btn-sm btn-light"><i class="fa fa-edit text-primary"></i></button>
|
||||
<button type="button" data-type="remove" class="btn btn-sm btn-light"><i class="fa fa-trash-o text-danger"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
Reference in New Issue
Block a user