mirror of https://github.com/rancher/dashboard.git
suse brand
This commit is contained in:
parent
f0b6651f2b
commit
fc49ee549f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
<script>
|
||||
export default {
|
||||
middleware: ['authenticated'],
|
||||
|
||||
mounted() {
|
||||
this.$store.dispatch('prefs/setBrand');
|
||||
},
|
||||
head() {
|
||||
const theme = this.$store.getters['prefs/theme'];
|
||||
|
||||
|
|
|
|||
|
|
@ -180,6 +180,10 @@ export default {
|
|||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$store.dispatch('prefs/setBrand');
|
||||
},
|
||||
|
||||
created() {
|
||||
this.queueUpdate = debounce(this.getGroups, 500);
|
||||
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ export default {
|
|||
name: this.$route.name,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('prefs/setBrand');
|
||||
},
|
||||
|
||||
head() {
|
||||
const theme = this.$store.getters['prefs/theme'];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue