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",
|
"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...",
|
"loading": "Loading Skins...",
|
||||||
"homepage": "Homepage",
|
"homepage": "Homepage",
|
||||||
"select-skin": "Select Skin",
|
"select-skin": "Select Skin",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|
||||||
define('admin/appearance/skins', ['translator', 'alerts'], function (translator, alerts) {
|
define('admin/appearance/skins', [
|
||||||
|
'translator', 'alerts', 'settings',
|
||||||
|
], function (translator, alerts, settings) {
|
||||||
const Skins = {};
|
const Skins = {};
|
||||||
|
|
||||||
Skins.init = function () {
|
Skins.init = function () {
|
||||||
@@ -11,6 +13,15 @@ define('admin/appearance/skins', ['translator', 'alerts'], function (translator,
|
|||||||
url: 'https://bootswatch.com/api/5.json',
|
url: 'https://bootswatch.com/api/5.json',
|
||||||
}).done(Skins.render);
|
}).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) {
|
$('#skins').on('click', function (e) {
|
||||||
let target = $(e.target);
|
let target = $(e.target);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const notifications = require('../../notifications');
|
|||||||
const db = require('../../database');
|
const db = require('../../database');
|
||||||
const helpers = require('../helpers');
|
const helpers = require('../helpers');
|
||||||
const accountHelpers = require('./helpers');
|
const accountHelpers = require('./helpers');
|
||||||
|
const slugify = require('../../slugify');
|
||||||
|
|
||||||
const settingsController = module.exports;
|
const settingsController = module.exports;
|
||||||
|
|
||||||
@@ -39,13 +40,15 @@ settingsController.get = async function (req, res, next) {
|
|||||||
uid: req.uid,
|
uid: req.uid,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [notificationSettings, routes] = await Promise.all([
|
const [notificationSettings, routes, bsSkinOptions] = await Promise.all([
|
||||||
getNotificationSettings(userData),
|
getNotificationSettings(userData),
|
||||||
getHomePageRoutes(userData),
|
getHomePageRoutes(userData),
|
||||||
|
getSkinOptions(userData),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
userData.customSettings = data.customSettings;
|
userData.customSettings = data.customSettings;
|
||||||
userData.homePageRoutes = routes;
|
userData.homePageRoutes = routes;
|
||||||
|
userData.bootswatchSkinOptions = bsSkinOptions;
|
||||||
userData.notificationSettings = notificationSettings;
|
userData.notificationSettings = notificationSettings;
|
||||||
userData.disableEmailSubscriptions = meta.config.disableEmailSubscriptions;
|
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' },
|
{ value: 'month', name: '[[user:digest_monthly]]', selected: userData.settings.dailyDigestFreq === 'month' },
|
||||||
];
|
];
|
||||||
|
|
||||||
getSkinOptions(userData);
|
|
||||||
|
|
||||||
userData.languages.forEach((language) => {
|
userData.languages.forEach((language) => {
|
||||||
language.selected = language.code === userData.settings.userLang;
|
language.selected = language.code === userData.settings.userLang;
|
||||||
});
|
});
|
||||||
@@ -220,17 +221,28 @@ async function getHomePageRoutes(userData) {
|
|||||||
return routes;
|
return routes;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSkinOptions(userData) {
|
async function getSkinOptions(userData) {
|
||||||
const defaultSkin = _.capitalize(meta.config.bootswatchSkin) || '[[user:no-skin]]';
|
const defaultSkin = _.capitalize(meta.config.bootswatchSkin) || '[[user:no-skin]]';
|
||||||
userData.bootswatchSkinOptions = [
|
const bootswatchSkinOptions = [
|
||||||
{ name: '[[user:no-skin]]', value: 'noskin' },
|
{ name: '[[user:no-skin]]', value: 'noskin' },
|
||||||
{ name: `[[user:default, ${defaultSkin}]]`, value: '' },
|
{ 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 }))
|
...meta.css.supportedSkins.map(skin => ({ name: _.capitalize(skin), value: skin }))
|
||||||
);
|
);
|
||||||
|
|
||||||
userData.bootswatchSkinOptions.forEach((skin) => {
|
bootswatchSkinOptions.forEach((skin) => {
|
||||||
skin.selected = skin.value === userData.settings.bootswatchSkin;
|
skin.selected = skin.value === userData.settings.bootswatchSkin;
|
||||||
});
|
});
|
||||||
|
return bootswatchSkinOptions;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ apiController.loadConfig = async function (req) {
|
|||||||
if (!config.disableCustomUserSkins && settings.bootswatchSkin) {
|
if (!config.disableCustomUserSkins && settings.bootswatchSkin) {
|
||||||
if (settings.bootswatchSkin === 'noskin') {
|
if (settings.bootswatchSkin === 'noskin') {
|
||||||
config.bootswatchSkin = '';
|
config.bootswatchSkin = '';
|
||||||
} else if (settings.bootswatchSkin !== '') {
|
} else if (settings.bootswatchSkin !== '' && await meta.css.isSkinValid(settings.bootswatchSkin)) {
|
||||||
config.bootswatchSkin = settings.bootswatchSkin;
|
config.bootswatchSkin = settings.bootswatchSkin;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
src/meta/css.js
107
src/meta/css.js
@@ -1,5 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const _ = require('lodash');
|
||||||
const winston = require('winston');
|
const winston = require('winston');
|
||||||
const nconf = require('nconf');
|
const nconf = require('nconf');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@@ -48,11 +49,19 @@ const buildImports = {
|
|||||||
function boostrapImport(themeData) {
|
function boostrapImport(themeData) {
|
||||||
// see https://getbootstrap.com/docs/5.0/customize/sass/#variable-defaults
|
// see https://getbootstrap.com/docs/5.0/customize/sass/#variable-defaults
|
||||||
// for an explanation of this order and https://bootswatch.com/help/
|
// 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 [
|
return [
|
||||||
bootswatchSkin ?
|
bsvariables(),
|
||||||
`@import "bootswatch/dist/${bootswatchSkin}/variables";` :
|
|
||||||
bsVariables,
|
|
||||||
'@import "bootstrap/scss/mixins/banner";',
|
'@import "bootstrap/scss/mixins/banner";',
|
||||||
'@include bsBanner("");',
|
'@include bsBanner("");',
|
||||||
// functions must be included first
|
// functions must be included first
|
||||||
@@ -115,7 +124,7 @@ function boostrapImport(themeData) {
|
|||||||
'@import "generics";',
|
'@import "generics";',
|
||||||
'@import "client";', // core page styles
|
'@import "client";', // core page styles
|
||||||
'@import "./theme";', // rest of the theme scss
|
'@import "./theme";', // rest of the theme scss
|
||||||
bootswatchSkin ? `@import "bootswatch/dist/${bootswatchSkin}/bootswatch";` : '',
|
bootswatchSkin && !isCustomSkin ? `@import "bootswatch/dist/${bootswatchSkin}/bootswatch";` : '',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,11 +180,14 @@ async function getBundleMetadata(target) {
|
|||||||
|
|
||||||
// Skin support
|
// Skin support
|
||||||
let skin;
|
let skin;
|
||||||
|
let isCustomSkin = false;
|
||||||
if (target.startsWith('client-')) {
|
if (target.startsWith('client-')) {
|
||||||
skin = target.split('-')[1];
|
skin = target.split('-').slice(1).join('-');
|
||||||
|
const isBootswatchSkin = CSS.supportedSkins.includes(skin);
|
||||||
if (CSS.supportedSkins.includes(skin)) {
|
isCustomSkin = !isBootswatchSkin && await CSS.isCustomSkin(skin);
|
||||||
target = 'client';
|
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`);
|
paths.unshift(`${baseThemePath}/node_modules`);
|
||||||
themeData.bsVariables = parseInt(themeData.useBSVariables, 10) === 1 ? (themeData.bsVariables || '') : '';
|
themeData.bsVariables = parseInt(themeData.useBSVariables, 10) === 1 ? (themeData.bsVariables || '') : '';
|
||||||
themeData.bootswatchSkin = skin;
|
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([
|
const [scssImports, cssImports, acpScssImports] = await Promise.all([
|
||||||
@@ -210,6 +225,80 @@ async function getBundleMetadata(target) {
|
|||||||
return { paths: paths, imports: imports };
|
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) {
|
CSS.buildBundle = async function (target, fork) {
|
||||||
if (target === 'client') {
|
if (target === 'client') {
|
||||||
let files = await fs.promises.readdir(path.join(__dirname, '../../build/public'));
|
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) => {
|
middleware.buildSkinAsset = helpers.try(async (req, res, next) => {
|
||||||
// If this middleware is reached, a skin was requested, so it is built on-demand
|
// 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]+)/);
|
const targetSkin = path.basename(req.originalUrl).split('.css')[0];
|
||||||
if (!target) {
|
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();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
await plugins.prepareForBuild(['client side styles']);
|
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();
|
require('../meta/minifier').killAll();
|
||||||
res.status(200).type('text/css').send(req.originalUrl.includes('-rtl') ? rtl : ltr);
|
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 || ''}`);
|
res.redirect(`${relativePath}/assets/plugins${req.path}${req._parsedUrl.search || ''}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Skins
|
app.use(`${relativePath}/assets/client-*.css`, middleware.buildSkinAsset);
|
||||||
meta.css.supportedSkins.forEach((skin) => {
|
app.use(`${relativePath}/assets/client-*-rtl.css`, middleware.buildSkinAsset);
|
||||||
app.use(`${relativePath}/assets/client-${skin}.css`, middleware.buildSkinAsset);
|
|
||||||
app.use(`${relativePath}/assets/client-${skin}-rtl.css`, middleware.buildSkinAsset);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use(controllers['404'].handle404);
|
app.use(controllers['404'].handle404);
|
||||||
app.use(controllers.errors.handleURIErrors);
|
app.use(controllers.errors.handleURIErrors);
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<div id="skins" class="d-flex flex-column gap-2 px-lg-4">
|
<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="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>
|
<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>
|
||||||
<div class="d-flex align-items-center gap-1">
|
<div class="d-flex align-items-center gap-1">
|
||||||
<div data-type="bootswatch" data-theme="" data-css="">
|
<div data-type="bootswatch" data-theme="" data-css="">
|
||||||
@@ -9,9 +13,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="skins px-2">
|
<div class="tab-content">
|
||||||
<div class="directory row text-center" id="bootstrap_themes">
|
<div class="tab-pane fade show active" role="tabpanel" id="skins-tab">
|
||||||
<i class="fa fa-refresh fa-spin"></i> [[admin/appearance/skins:loading]]
|
<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>
|
</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