suse brand

This commit is contained in:
Nancy Butler 2021-04-21 07:16:35 -07:00
parent f0b6651f2b
commit fc49ee549f
10 changed files with 525 additions and 3 deletions

View File

@ -4142,6 +4142,15 @@ featureFlags:
promptDeactivate: Please confirm that you want to deactivate the feature flag "{flag}"
restartRequired: "Note: Updating this feature flag requires a restart"
branding:
label: Branding
directoryName: Brand Asset Directory Name
options:
default: Default Rancher Theme
suse: SUSE Theme
custom: Define a Custom Theme
##############################
### Support Page
##############################
@ -4156,6 +4165,7 @@ support:
pricing: Contact us for pricing
suse:
title: "Great News - You're covered"
editBrand: Customize UI Theme
promos:
one:
title: 24x7 Support

41
components/BrandImage.vue Normal file
View File

@ -0,0 +1,41 @@
<script>
import LazyImage from '@/components/LazyImage';
import { MANAGEMENT } from '@/config/types';
export default {
components: { LazyImage },
props: {
fileName: {
type: String,
required: true
}
},
async fetch() {
this.brandSetting = await this.$store.dispatch('management/find', { type: MANAGEMENT.SETTING, id: 'brand' });
},
data() {
return { brandSetting: null };
},
computed: {
pathToBrandedImage() {
let out = require(`~/assets/images/pl/${ this.fileName }`);
if (!this.brandSetting?.value) {
return out;
} else {
try {
out = require(`~/assets/brand/${ this.brandSetting.value }/${ this.fileName }`);
} catch {
}
return out ;
}
},
pathToRancherFallback() {
return require(`~/assets/images/pl/${ this.fileName }`);
}
}
};
</script>
<template>
<LazyImage v-bind="$attrs" :initial-src="pathToBrandedImage" :src="pathToBrandedImage" :error-src="pathToRancherFallback" />
</template>

View File

@ -59,9 +59,20 @@ export function init(store) {
}
});
virtualType({
ifHaveType: MANAGEMENT.SETTING,
labelKey: 'branding.label',
name: 'brand',
namespaced: false,
weight: 98,
icon: 'folder',
route: { name: 'c-cluster-settings-brand' },
});
basicType([
'settings',
'features',
'brand'
]);
configureType(MANAGEMENT.SETTING, {

View File

@ -1,7 +1,9 @@
<script>
export default {
middleware: ['authenticated'],
mounted() {
this.$store.dispatch('prefs/setBrand');
},
head() {
const theme = this.$store.getters['prefs/theme'];

View File

@ -180,6 +180,10 @@ export default {
},
},
mounted() {
this.$store.dispatch('prefs/setBrand');
},
created() {
this.queueUpdate = debounce(this.getGroups, 500);

View File

@ -6,13 +6,15 @@ export default {
components: { Header },
middleware: ['authenticated'],
data() {
return {
// Assume home pages have routes where the name is the key to use for string lookup
name: this.$route.name
};
},
mounted() {
this.$store.dispatch('prefs/setBrand');
},
head() {
const theme = this.$store.getters['prefs/theme'];

View File

@ -23,6 +23,9 @@ export default {
name: this.$route.name,
};
},
mounted() {
this.$store.dispatch('prefs/setBrand');
},
head() {
const theme = this.$store.getters['prefs/theme'];

View File

@ -0,0 +1,132 @@
<script>
import { MANAGEMENT } from '@/config/types';
import RadioGroup from '@/components/form/RadioGroup';
import LabeledInput from '@/components/form/LabeledInput';
import AsyncButton from '@/components/AsyncButton';
import Banner from '@/components/Banner';
import { stringify } from '@/utils/error';
export default {
components: {
RadioGroup,
LabeledInput,
AsyncButton,
Banner
},
async fetch() {
let brandSetting;
try {
brandSetting = await this.$store.dispatch('management/find', { type: MANAGEMENT.SETTING, id: 'brand' });
} catch {
const schema = this.$store.getters['management/schemaFor'](MANAGEMENT.SETTING);
const url = schema.linkFor('collection');
brandSetting = await this.$store.dispatch('management/create', {
type: MANAGEMENT.SETTING, metadata: { name: 'brand' }, value: '', default: ''
});
brandSetting.save({ url });
}
if (!brandSetting.value || brandSetting.value === '' || brandSetting.value === 'suse') {
this.themeSource = brandSetting.value || 'default';
} else {
this.themeSource = 'custom';
this.brand = brandSetting.value;
}
this.uiplSetting = await this.$store.dispatch('management/find', { type: MANAGEMENT.SETTING, id: 'ui-pl' });
this.brandSetting = brandSetting;
},
data() {
return {
brandSetting: null, uiplSetting: '', brand: '', themeSource: '', primaryColorString: '', errors: []
};
},
watch: {
themeSource(neu, old) {
if (neu === 'default' && old !== 'default' && old !== '') {
this.brand = '';
this.updateBrand();
} else if (neu === 'suse') {
this.brand = neu;
this.updateBrand();
}
},
},
methods: {
updateTheme() {
const theme = this.$store.getters['prefs/theme'];
this.$store.dispatch('prefs/setBrand', theme === 'dark');
},
async updateBrand(btnCB = () => {}) {
this.brandSetting.value = this.brand;
await this.brandSetting.save();
const uiPLUpdated = await this.updateUIPL();
if (uiPLUpdated) {
this.errors.push(stringify(uiPLUpdated));
btnCB(false);
}
this.updateTheme();
this.errors = [];
btnCB(true);
},
async updateUIPL() {
try {
const brandMeta = require(`~/assets/brand/${ this.brand }/metadata.json`);
const uiPL = brandMeta['ui-pl'] || this.uiplSetting.default;
this.uiplSetting.value = uiPL;
await this.uiplSetting.save();
return;
} catch (err) {
return err;
}
},
stringify
}
};
</script>
<template>
<div>
<div class="row mb-20">
<RadioGroup
v-model="themeSource"
label="Choose a new UI Theme"
name="theme-source"
:options="[{value: 'default', label:t('branding.options.default')}, {value: 'suse', label:t('branding.options.suse')}, {value: 'custom', label:t('branding.options.custom')}]"
/>
</div>
<template v-if="themeSource === 'custom'">
<div class="row">
<div class="col span-3">
<LabeledInput v-model="brand" :placeholder="'e.g. my-company'" :label="t('branding.directoryName')" />
</div>
<!-- <div class="col span-3">
<LabeledInput v-model="primaryColorString" :placeholder="'e.g. rgb(65, 152, 211)'" label="Primary Color" />
</div> -->
<AsyncButton @click="updateBrand" />
</div>
</template>
<div
v-for="(err, idx) in errors"
:key="idx"
>
<Banner
color="error"
:label="stringify(err)"
/>
</div>
</div>
</template>

View File

@ -1,6 +1,7 @@
import Vue from 'vue';
import { STEVE } from '@/config/types';
import { MANAGEMENT, STEVE } from '@/config/types';
import { clone } from '@/utils/object';
import { colorVariables, parseColorString, RGBToHSL } from '@/utils/color';
const definitions = {};
@ -264,6 +265,11 @@ export const actions = {
}
},
async setTheme({ dispatch }, val) {
await dispatch('set', { key: THEME, value: val });
dispatch('setBrand', val === 'dark');
},
loadCookies({ state, commit }) {
if ( state.cookiesLoaded ) {
return;
@ -331,6 +337,7 @@ export const actions = {
function changed(value) {
// console.log('Prefers Theme:', value);
dispatch('set', { key: PREFERS_SCHEME, value });
dispatch('setBrand', value);
}
function fromClock() {
@ -410,4 +417,43 @@ export const actions = {
return dispatch('set', { key: THEME, value });
},
setBrand({ rootState, rootGetters }, dark = false) {
if (rootState.managementReady) {
try {
const brandSetting = rootGetters['management/byId'](MANAGEMENT.SETTING, 'brand');
if (brandSetting) {
if (!brandSetting.value || brandSetting.value === '') {
const colorVars = colorVariables( {
primary: [0, 0, 0],
link: { default: [0, 0, 0], text: [0, 0, 0] }
}, dark);
for (const cssVar in colorVars) {
document.body.style.removeProperty(cssVar);
}
} else {
const brand = brandSetting.value;
const brandMeta = require(`~/assets/brand/${ brand }/metadata.json`);
const rgbPrimaryString = brandMeta.primary;
if (rgbPrimaryString) {
const hslPrimary = RGBToHSL(...parseColorString(rgbPrimaryString));
const colorVars = colorVariables( {
primary: hslPrimary,
/* link: { default: hslPrimary, text: hslPrimary } */
}, dark);
for (const cssVar in colorVars) {
document.body.style.setProperty(cssVar, colorVars[cssVar]);
}
}
}
}
} catch {}
}
}
};

271
utils/color.js Normal file
View File

@ -0,0 +1,271 @@
import { isArray } from '@/utils/array';
// https://css-tricks.com/converting-color-spaces-in-javascript/
export function RGBToHSL(r, g, b) {
// Make r, g, and b fractions of 1
r = r / 255;
g = g / 255;
b = b / 255;
// Find greatest and smallest channel values
const cmin = Math.min(r, g, b);
const cmax = Math.max(r, g, b);
const delta = cmax - cmin;
let h = 0;
let s = 0;
let l = 0;
// Calculate hue
// No difference
if (delta == 0) {
h = 0;
}
// Red is max
else if (cmax == r) {
h = ((g - b) / delta) % 6;
}
// Green is max
else if (cmax == g) {
h = (b - r) / delta + 2;
}
// Blue is max
else {
h = (r - g) / delta + 4;
}
h = Math.round(h * 60);
// Make negative hues positive behind 360°
if (h < 0) {
h += 360;
}
// Calculate lightness
l = (cmax + cmin) / 2;
// Calculate saturation
s = delta == 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
// Multiply l and s by 100
s = +(s * 100).toFixed(1);
l = +(l * 100).toFixed(1);
return [h, s, l];
}
export function HSLToRGB(h, s, l) {
// Must be fractions of 1
s /= 100;
l /= 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
const m = l - c / 2;
let r = 0;
let g = 0;
let b = 0;
if (h >= 0 && h < 60) {
r = c; g = x; b = 0;
} else if (h >= 60 && h < 120) {
r = x; g = c; b = 0;
} else if (h >= 120 && h < 180) {
r = 0; g = c; b = x;
} else if (h >= 180 && h < 240) {
r = 0; g = x; b = c;
} else if (h >= 240 && h < 300) {
r = x; g = 0; b = c;
} else if (h >= 300 && h < 360) {
r = c; g = 0; b = x;
}
r = Math.round((r + m) * 255);
g = Math.round((g + m) * 255);
b = Math.round((b + m) * 255);
return [r, g, b];
}
// convert "rgb(x,y,z)" or "hsl(x,y,z)" to [x, y, z]
export function parseColorString(str) {
str = str.replace(/(rgb\()|(hsl\()|, |\)/g, substr => substr === ', ' ? ',' : '');
return str.split(',').map(str => parseInt(str.trim()));
}
// All color/colors here refers to [h,s,l]
const darkContrastedColors = {
dark: [0, 0, 100],
light: [240, 11, 9]
};
const lightContrastedColors = {
dark: [240, 11, 9],
light: [0, 0, 100],
};
function contrastColor(color, dark = false) {
let contrastedColors = lightContrastedColors;
if (dark) {
contrastedColors = darkContrastedColors;
}
return Math.abs(color[2] - contrastedColors.light[2]) > Math.abs(color[2] - contrastedColors.dark[2]) ? contrastedColors.light : contrastedColors.dark;
}
function lighten(color, amount) {
color[2] = color[2] / (1 - amount);
return color;
}
function darken(color, amount) {
const out = [...color];
out[2] = color[2] * (1 - amount);
return out;
}
function saturate(color, amount) {
}
/*
see @/assets/styles/base/_color.scss
Generates primary, active, warning, info etc color variants
*/
function colorStateVariables(colors, dark = false) {
const classes = [
'default',
'text',
'hover-text',
'active-text',
'default',
'hover-bg',
'active-bg',
'border',
'banner'
];
const out = {};
for (const key in colors) {
let colorVars;
if (!isArray(colors[key])) {
const map = colors[key];
const defaultColor = map.default;
colorVars = {
[`--${ key }`]: defaultColor,
[`--${ key }-text`]: contrastColor(defaultColor, dark),
[`--${ key }-hover-bg`]: darken(defaultColor, 0.1),
[`--${ key }-active-bg`]: darken(defaultColor, 0.25),
[`--${ key }-active-text`]: contrastColor(darken(defaultColor, 0.75), dark),
[`--${ key }-border`]: defaultColor,
[`--${ key }-banner-bg`]: `hsla(${ defaultColor.join(',') }, 0.15)`
};
classes.forEach((str) => {
if (map[str]) {
colorVars[`--${ key }-${ str }`] = map[str];
}
});
} else {
const defaultColor = colors[key];
colorVars = {
[`--${ key }`]: defaultColor,
[`--${ key }-text`]: contrastColor(defaultColor, dark),
[`--${ key }-hover-bg`]: darken(defaultColor, 0.1),
[`--${ key }-active-bg`]: darken(defaultColor, 0.25),
[`--${ key }-active-text`]: contrastColor(darken(defaultColor, 0.25), dark),
[`--${ key }-border`]: defaultColor,
[`--${ key }-banner-bg`]: [...defaultColor, 0.15]
};
}
out[key] = colorVars;
}
for (const key in out) {
for (const cssVar in out[key]) {
const colorArray = out[key][cssVar];
if (colorArray.length === 4) {
out[cssVar] = `hsla(${ colorArray[0] }, ${ colorArray[1] }%, ${ colorArray[2] }%, ${ colorArray[3] })`;
} else {
out[cssVar] = `hsl(${ colorArray[0] }, ${ colorArray[1] }%, ${ colorArray[2] }%)`;
}
}
delete out[key];
}
return out;
}
/*
see @/assets/styles/base/_light.scss
css vars referencing $primary in _light:
$link = $primary
$selected = rgba($primary, 0.5)
--accent-btn : #{rgba($primary, 0.2)};
--accent-btn-hover : #{rgba($primary, 0.5)};
--card-header : #{rgba($primary, 0.2)};
--dropdown-hover-bg : #{rgba($primary, 0.2)};
--tooltip-bg : #{lighten($primary, 35%)};
--tag-bg : #{rgba($primary, 0.2)};
--tag-primary : #{$primary};
--glance-divider : #{rgba($primary, 0.5)};
--dropdown-text : #{$link};
--dropdown-active-text : #{darken($link, 1%)};
--dropdown-hover-text : #{lighten($link, 1%)};
--checkbox-ticked-bg : #{$link};
--dropdown-active-bg : #{$selected};
--terminal-selection : #{$selected};
*/
function rootPrimaryColorVariables(primary, dark = false) {
const out = {
'--accent-btn': [...primary, 0.2],
'--accent-btn-hover': [...primary, 0.5],
'--card-header': [...primary, 0.2],
'--dropdown-hover-bg': [...primary, 0.2],
'--tooltip-bg': lighten(primary, 0.35),
'--tag-bg': [...primary, 0.2],
'--tag-primary': [...primary],
'--glance-divider': [...primary, 0.5],
'--dropdown-text': [...primary],
'--dropdown-active-text': darken(primary, 0.01),
'--dropdown-hover-text': lighten(primary, 0.01),
'--checkbox-ticked-bg': [...primary],
'--dropdown-active-bg': [...primary, 0.5],
'--terminal-selection': [...primary]
};
for (const cssVar in out) {
const colorArray = out[cssVar];
if (colorArray.length === 4) {
out[cssVar] = `hsla(${ colorArray[0] }, ${ colorArray[1] }%, ${ colorArray[2] }%, ${ colorArray[3] })`;
} else {
out[cssVar] = `hsl(${ colorArray[0] }, ${ colorArray[1] }%, ${ colorArray[2] }%)`;
}
}
return out;
}
export function colorVariables(colors, dark = false) {
const primaryColor = colors.primary;
const out = colorStateVariables(colors, dark);
Object.assign(out, rootPrimaryColorVariables(primaryColor, dark));
return out;
}