Merge branch 'master' of github.com:chamilo/chamilo-lms

pull/5671/head
Yannick Warnier 1 year ago
commit e56e7ac0cc
  1. 30
      assets/css/scss/_admin_index.scss
  2. 2
      assets/css/scss/atoms/_color_picker.scss
  3. 6
      assets/css/scss/atoms/_form.scss
  4. 7
      assets/css/scss/organisms/_section_header.scss
  5. 433
      assets/vue/components/admin/ColorThemeForm.vue
  6. 3
      assets/vue/components/basecomponents/BaseSelect.vue
  7. 18
      assets/vue/components/layout/SectionHeader.vue
  8. 13
      assets/vue/components/platform/ColorThemeSelector.vue
  9. 65
      assets/vue/composables/theme.js
  10. 30
      assets/vue/services/colorThemeService.js
  11. 426
      assets/vue/views/admin/AdminConfigureColors.vue
  12. 3
      src/CoreBundle/Component/Utils/ChamiloApi.php
  13. 2
      src/CoreBundle/Entity/ColorTheme.php
  14. 3
      src/CoreBundle/Form/PermissionType.php
  15. 2
      src/CoreBundle/Migrations/Schema/V200/Version20230215072918.php
  16. 9
      src/CoreBundle/Migrations/Schema/V200/Version20240713125400.php
  17. 4
      src/CoreBundle/Migrations/Schema/V200/Version20240715183456.php
  18. 10
      src/CoreBundle/State/ColorThemeStateProcessor.php

@ -13,30 +13,18 @@
.admin-colors { .admin-colors {
&__container { &__container {
@apply flex flex-col mt-6; @apply flex flex-col md:flex-row mt-6;
} }
&__settings { &__form {
@apply flex flex-col gap-4 @apply w-full md:w-3/5;
md:flex-row; }
&-form {
@apply flex-1;
&-title {
@apply mb-4;
}
}
.row-group { &__form-fields {
@apply flex flex-col gap-4 items-start @apply mt-4;
sm:grid sm:grid-cols-2 }
md:items-end
xl:grid-cols-3;
}
&-preview { &__preview {
@apply space-y-4 flex-1; @apply flex w-full md:w-2/5;
}
} }
} }

@ -1,5 +1,5 @@
.color-picker { .color-picker {
@apply flex flex-col justify-center gap-0 relative; @apply flex flex-col flex-grow-0 flex-shrink-0 basis-0 justify-center gap-0 relative;
label { label {
@apply absolute -top-2.5 left-2 text-caption px-1 bg-white text-primary z-[2] max-w-full truncate; @apply absolute -top-2.5 left-2 text-caption px-1 bg-white text-primary z-[2] max-w-full truncate;

@ -58,10 +58,14 @@
> small { > small {
@apply text-caption text-primary; @apply text-caption text-primary;
} }
&-group {
@apply flex gap-4 flex-wrap mb-4 items-start;
}
} }
.p-error { .p-error {
@apply text-error; @apply text-error text-caption;
} }
.p-disabled { .p-disabled {

@ -3,11 +3,14 @@
md:flex-row sm:items-center; md:flex-row sm:items-center;
&--h2 { &--h2 {
@apply pb-6 ; @apply pb-6 mb-6;
} }
&--h3,
&--h4,
&--h5,
&--h6 { &--h6 {
@apply pb-4; @apply pb-4 mb-4;
} }
&__title { &__title {

@ -0,0 +1,433 @@
<script setup>
import { ref, watch } from "vue"
import { useI18n } from "vue-i18n"
import Color from "colorjs.io"
import SectionHeader from "../layout/SectionHeader.vue"
import ColorThemeSelector from "../platform/ColorThemeSelector.vue"
import BaseColorPicker from "../basecomponents/BaseColorPicker.vue"
import BaseButton from "../basecomponents/BaseButton.vue"
import BaseDialogConfirmCancel from "../basecomponents/BaseDialogConfirmCancel.vue"
import BaseInputText from "../basecomponents/BaseInputText.vue"
import colorThemeService from "../../services/colorThemeService"
import { useNotification } from "../../composables/notification"
import { useTheme } from "../../composables/theme"
const { showSuccessNotification, showErrorNotification } = useNotification()
const { getColorTheme, getColors, setColors, makeGradient, makeTextWithContrast, checkColorContrast } = useTheme()
const { t } = useI18n()
const isAdvancedMode = ref(false)
const themeSelectorEl = ref()
const selectedThemeIri = ref()
const newThemeSelected = ref()
let colorPrimary = getColorTheme("--color-primary-base")
let colorPrimaryGradient = getColorTheme("--color-primary-gradient")
let colorPrimaryButtonText = getColorTheme("--color-primary-button-text")
let colorPrimaryButtonAlternativeText = getColorTheme("--color-primary-button-alternative-text")
let colorSecondary = getColorTheme("--color-secondary-base")
let colorSecondaryGradient = getColorTheme("--color-secondary-gradient")
let colorSecondaryButtonText = getColorTheme("--color-secondary-button-text")
let colorTertiary = getColorTheme("--color-tertiary-base")
let colorTertiaryGradient = getColorTheme("--color-tertiary-gradient")
let colorTertiaryButtonText = getColorTheme("--color-tertiary-button-text")
let colorSuccess = getColorTheme("--color-success-base")
let colorSuccessGradient = getColorTheme("--color-success-gradient")
let colorSuccessButtonText = getColorTheme("--color-success-button-text")
let colorInfo = getColorTheme("--color-info-base")
let colorInfoGradient = getColorTheme("--color-info-gradient")
let colorInfoButtonText = getColorTheme("--color-info-button-text")
let colorWarning = getColorTheme("--color-warning-base")
let colorWarningGradient = getColorTheme("--color-warning-gradient")
let colorWarningButtonText = getColorTheme("--color-warning-button-text")
let colorDanger = getColorTheme("--color-danger-base")
let colorDangerGradient = getColorTheme("--color-danger-gradient")
let colorDangerButtonText = getColorTheme("--color-danger-button-text")
let formColor = getColorTheme("--color-form-base")
function onChangeTheme(colorTheme) {
newThemeSelected.value = colorTheme
if (colorTheme) {
setColors(colorTheme.variables)
}
}
const dialogCreateVisible = ref(false)
const themeTitle = ref()
async function onClickSelectColorTheme() {
if (selectedThemeIri.value) {
await colorThemeService.changePlatformColorTheme(selectedThemeIri.value)
}
}
async function onClickUpdate() {
try {
const updatedTheme = await colorThemeService.update({
iri: selectedThemeIri.value,
title: themeTitle.value,
colors: getColors(),
})
showSuccessNotification(t("Color updated"))
await themeSelectorEl.value.loadThemes()
selectedThemeIri.value = updatedTheme["@id"]
} catch (error) {
showErrorNotification(error)
}
}
async function onClickCreate() {
try {
if (!themeTitle.value.trim()) {
return
}
const updatedTheme = await colorThemeService.create({
title: themeTitle.value,
colors: getColors(),
})
showSuccessNotification(t("Color updated"))
await themeSelectorEl.value.loadThemes()
selectedThemeIri.value = updatedTheme["@id"]
} catch (error) {
showErrorNotification(error)
}
dialogCreateVisible.value = false
}
watch(colorPrimary, (newValue) => {
colorPrimaryGradient.value = makeGradient(newValue)
colorPrimaryButtonText.value = newValue
colorPrimaryButtonAlternativeText.value = makeTextWithContrast(newValue)
colorPrimaryButtonAlternativeTextError.value = checkColorContrast(newValue, colorPrimaryButtonText.value)
})
watch(colorSecondary, (newValue) => {
colorSecondaryGradient.value = makeGradient(newValue)
colorSecondaryButtonText.value = makeTextWithContrast(newValue)
colorSecondaryButtonTextError.value = checkColorContrast(newValue, colorSecondaryButtonText.value)
})
watch(colorTertiary, (newValue) => {
colorTertiaryButtonText.value = newValue
colorTertiaryGradient.value = makeGradient(newValue)
})
watch(colorSuccess, (newValue) => {
colorSuccessGradient.value = makeGradient(newValue)
colorSuccessButtonText.value = makeTextWithContrast(newValue)
colorSuccessButtonTextError.value = checkColorContrast(newValue, colorSuccessButtonText.value)
})
watch(colorInfo, (newValue) => {
colorInfoGradient.value = makeGradient(newValue)
colorInfoButtonText.value = makeTextWithContrast(newValue)
colorInfoButtonTextError.value = checkColorContrast(newValue, colorInfoButtonText.value)
})
watch(colorWarning, (newValue) => {
colorWarningGradient.value = makeGradient(newValue)
colorWarningButtonText.value = makeTextWithContrast(newValue)
colorWarningButtonTextError.value = checkColorContrast(newValue, colorWarningButtonText.value)
})
watch(colorDanger, (newValue) => {
colorDangerGradient.value = makeGradient(newValue)
colorDangerButtonText.value = makeTextWithContrast(newValue)
})
// check for contrast of text
const colorPrimaryButtonTextError = ref("")
watch(
colorPrimaryButtonText,
(newValue) => (colorPrimaryButtonTextError.value = checkColorContrast(new Color("white"), newValue)),
)
const colorPrimaryButtonAlternativeTextError = ref("")
watch(
colorPrimaryButtonAlternativeText,
(newValue) => (colorPrimaryButtonAlternativeTextError.value = checkColorContrast(colorPrimary.value, newValue)),
)
const colorSecondaryButtonTextError = ref("")
watch(
colorSecondaryButtonText,
(newValue) => (colorSecondaryButtonTextError.value = checkColorContrast(colorSecondary.value, newValue)),
)
const colorTertiaryButtonTextError = ref("")
watch(
colorTertiaryButtonText,
(newValue) => (colorTertiaryButtonTextError.value = checkColorContrast(colorTertiary.value, newValue)),
)
const colorSuccessButtonTextError = ref("")
watch(
colorSuccessButtonText,
(newValue) => (colorSuccessButtonTextError.value = checkColorContrast(colorSuccess.value, newValue)),
)
const colorInfoButtonTextError = ref("")
watch(
colorInfoButtonText,
(newValue) => (colorInfoButtonTextError.value = checkColorContrast(colorInfo.value, newValue)),
)
const colorWarningButtonTextError = ref("")
watch(
colorWarningButtonText,
(newValue) => (colorWarningButtonTextError.value = checkColorContrast(colorWarning.value, newValue)),
)
const colorDangerButtonTextError = ref("")
watch(
colorDangerButtonText,
(newValue) => (colorDangerButtonTextError.value = checkColorContrast(new Color("white"), newValue)),
)
</script>
<template>
<ColorThemeSelector
ref="themeSelectorEl"
v-model="selectedThemeIri"
@change="onChangeTheme"
/>
<SectionHeader
:title="t('Modify color theme')"
size="6"
>
<BaseButton
:label="isAdvancedMode ? t('Hide advanced mode') : t('Show advanced mode')"
icon="cog"
type="black"
@click="isAdvancedMode = !isAdvancedMode"
/>
</SectionHeader>
<form class="admin-colors__form-fields">
<!-- Advanced mode -->
<div v-show="isAdvancedMode">
<div class="field-group">
<BaseColorPicker
v-model="colorPrimary"
:label="t('Primary color')"
/>
<BaseColorPicker
v-model="colorPrimaryGradient"
:label="t('Primary color hover/background')"
/>
<BaseColorPicker
v-model="colorPrimaryButtonText"
:error="colorPrimaryButtonTextError"
:label="t('Primary color button text')"
/>
<BaseColorPicker
v-model="colorPrimaryButtonAlternativeText"
:error="colorPrimaryButtonAlternativeTextError"
:label="t('Primary color button alternative text')"
/>
</div>
<div class="field-group">
<BaseColorPicker
v-model="colorSecondary"
:label="t('Secondary color')"
/>
<BaseColorPicker
v-model="colorSecondaryGradient"
:label="t('Secondary color hover/background')"
/>
<BaseColorPicker
v-model="colorSecondaryButtonText"
:error="colorSecondaryButtonTextError"
:label="t('Secondary color button text')"
/>
</div>
<div class="field-group">
<BaseColorPicker
v-model="colorTertiary"
:label="t('Tertiary color')"
/>
<BaseColorPicker
v-model="colorTertiaryGradient"
:label="t('Tertiary color hover/background')"
/>
</div>
<div class="field-group">
<BaseColorPicker
v-model="colorSuccess"
:label="t('Success color')"
/>
<BaseColorPicker
v-model="colorSuccessGradient"
:label="t('Success color hover/background')"
/>
<BaseColorPicker
v-model="colorSuccessButtonText"
:error="colorSuccessButtonTextError"
:label="t('Success color button text')"
/>
</div>
<div class="field-group">
<BaseColorPicker
v-model="colorInfo"
:label="t('Info color')"
/>
<BaseColorPicker
v-model="colorInfoGradient"
:label="t('Info color hover/background')"
/>
<BaseColorPicker
v-model="colorInfoButtonText"
:error="colorInfoButtonTextError"
:label="t('Info color button text')"
/>
</div>
<div class="field-group">
<BaseColorPicker
v-model="colorWarning"
:label="t('Warning color')"
/>
<BaseColorPicker
v-model="colorWarningGradient"
:label="t('Warning color hover/background')"
/>
<BaseColorPicker
v-model="colorWarningButtonText"
:error="colorWarningButtonTextError"
:label="t('Warning color button text')"
/>
</div>
<div class="field-group">
<BaseColorPicker
v-model="colorDanger"
:label="t('Danger color')"
/>
<BaseColorPicker
v-model="colorDangerGradient"
:label="t('Danger color hover/background')"
/>
</div>
<div class="field-group">
<BaseColorPicker
v-model="formColor"
:label="t('Form outline color')"
/>
</div>
</div>
<!-- Simple mode -->
<div v-show="!isAdvancedMode">
<div class="field-group">
<BaseColorPicker
v-model="colorPrimary"
:label="t('Primary color')"
/>
<BaseColorPicker
v-model="colorSecondary"
:label="t('Secondary color')"
/>
<BaseColorPicker
v-model="colorTertiary"
:label="t('Tertiary color')"
/>
</div>
<div class="field-group">
<BaseColorPicker
v-model="colorSuccess"
:label="t('Success color')"
/>
<BaseColorPicker
v-model="colorInfo"
:label="t('Info color')"
/>
<BaseColorPicker
v-model="colorWarning"
:label="t('Warning color')"
/>
<BaseColorPicker
v-model="colorDanger"
:label="t('Danger color')"
/>
</div>
<div class="field-group">
<BaseColorPicker
v-model="formColor"
:label="t('Form outline color')"
/>
</div>
</div>
<div class="field-group">
<BaseButton
:disabled="!selectedThemeIri"
:label="t('Select as current theme')"
icon="save"
type="primary"
@click="onClickSelectColorTheme"
/>
<BaseButton
:disabled="!selectedThemeIri"
:label="t('Save')"
icon="send"
type="primary"
@click="onClickUpdate"
/>
<BaseButton
:label="t('Save as new theme')"
icon="send"
type="primary"
@click="dialogCreateVisible = true"
/>
</div>
</form>
<BaseDialogConfirmCancel
v-model:is-visible="dialogCreateVisible"
:cancel-label="t('Cancel')"
:confirm-label="t('Save')"
:title="t('New color theme')"
@confirm-clicked="onClickCreate"
@cancel-clicked="dialogCreateVisible = false"
>
<BaseInputText
v-model="themeTitle"
:label="t('Title')"
/>
</BaseDialogConfirmCancel>
</template>

@ -11,6 +11,7 @@
:loading="isLoading" :loading="isLoading"
:show-clear="allowClear" :show-clear="allowClear"
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
@change="emit('change', $event)"
> >
<template #emptyfilter>--</template> <template #emptyfilter>--</template>
<template #empty> <template #empty>
@ -84,7 +85,7 @@ const props = defineProps({
}, },
}) })
const emit = defineEmits(["update:modelValue"]) const emit = defineEmits(["update:modelValue", "change"])
const realOptions = computed(() => { const realOptions = computed(() => {
if (props.hastEmptyValue) { if (props.hastEmptyValue) {

@ -8,6 +8,11 @@ defineProps({
type: String, type: String,
required: true, required: true,
}, },
size: {
type: String,
required: false,
default: "2",
},
}) })
const cidReqStore = useCidReqStore() const cidReqStore = useCidReqStore()
@ -16,11 +21,16 @@ const { course } = storeToRefs(cidReqStore)
</script> </script>
<template> <template>
<div class="section-header section-header--h2"> <div
<h2 class="section-header"
:class="`section-header--h${size}`"
>
<component
:is="`h${size}`"
class="section-header__title" class="section-header__title"
v-text="title" >
/> {{ title }}
</component>
<div class="section-header__actions"> <div class="section-header__actions">
<StudentViewButton v-if="course" /> <StudentViewButton v-if="course" />

@ -1,6 +1,6 @@
<script setup> <script setup>
import BaseSelect from "../basecomponents/BaseSelect.vue" import BaseSelect from "../basecomponents/BaseSelect.vue"
import { ref } from "vue" import { computed, ref } from "vue"
import themeService from "../../services/colorThemeService" import themeService from "../../services/colorThemeService"
import { useI18n } from "vue-i18n" import { useI18n } from "vue-i18n"
import { useNotification } from "../../composables/notification" import { useNotification } from "../../composables/notification"
@ -34,17 +34,26 @@ defineExpose({
loadThemes, loadThemes,
}) })
const emit = defineEmits(["change"])
loadThemes() loadThemes()
function onChange({ value }) {
const themeSelected = serverThemes.value.find((accessUrlRelColorTheme) => accessUrlRelColorTheme["@id"] === value)
emit("change", themeSelected)
}
</script> </script>
<template> <template>
<BaseSelect <BaseSelect
v-model="modelValue" v-model="modelValue"
:is-loading="isServerThemesLoading" :is-loading="isServerThemesLoading"
:label="t('Color theme selected')" :label="t('Theme title')"
:options="serverThemes" :options="serverThemes"
allow-clear allow-clear
option-label="title" option-label="title"
option-value="@id" option-value="@id"
@change="onChange"
/> />
</template> </template>

@ -1,8 +1,11 @@
import { onMounted, ref, watch } from "vue" import { onMounted, ref, watch } from "vue"
import { useI18n } from "vue-i18n"
import Color from "colorjs.io" import Color from "colorjs.io"
import { usePlatformConfig } from "../store/platformConfig" import { usePlatformConfig } from "../store/platformConfig"
export const useTheme = () => { export const useTheme = () => {
const { t } = useI18n()
let colors = {} let colors = {}
onMounted(() => { onMounted(() => {
@ -27,7 +30,7 @@ export const useTheme = () => {
const getCssVariableValue = (variableName) => { const getCssVariableValue = (variableName) => {
const colorVariable = getComputedStyle(document.body).getPropertyValue(variableName) const colorVariable = getComputedStyle(document.body).getPropertyValue(variableName)
return colorFromCSSVariable(colorVariable) return getColorFromCSSVariable(colorVariable)
} }
const setCssVariableValue = (variableName, color) => { const setCssVariableValue = (variableName, color) => {
@ -48,7 +51,7 @@ export const useTheme = () => {
console.error(`Color with key ${key} is on color set`) console.error(`Color with key ${key} is on color set`)
continue continue
} }
colors[key].value = colorFromCSSVariable(colorsObj[key]) colors[key].value = getColorFromCSSVariable(colorsObj[key])
} }
} }
@ -62,28 +65,78 @@ export const useTheme = () => {
return `${r} ${g} ${b}` return `${r} ${g} ${b}`
} }
const colorFromCSSVariable = (variable) => { const getColorFromCSSVariable = (variable) => {
return new Color(`rgb(${variable})`) return new Color(`rgb(${variable})`)
} }
/**
* @param {Color} color
* @returns {Color}
*/
function makeGradient(color) {
const light = color.clone().to("oklab").l
// when color is light (lightness > 0.5), darken gradient color
// when color is dark, lighten gradient color
// The values 0.5 and 1.6 were chosen through experimentation, there could be a better way to do this
if (light > 0.5) {
return color
.clone()
.set({ "oklab.l": (l) => l * 0.8 })
.to("srgb")
}
return color
.clone()
.set({ "oklab.l": (l) => l * 1.6 })
.to("srgb")
}
/**
* @param {Color} color
* @returns {Color}
*/
function makeTextWithContrast(color) {
// according to colorjs library https://colorjs.io/docs/contrast#accessible-perceptual-contrast-algorithm-apca
// this algorithm is better than WCAGG 2.1 to check for contrast
// "APCA is being evaluated for use in version 3 of the W3C Web Content Accessibility Guidelines (WCAG)"
let onWhite = Math.abs(color.contrast("white", "APCA"))
let onBlack = Math.abs(color.contrast("black", "APCA"))
return onWhite > onBlack ? new Color("white") : new Color("black")
}
function checkColorContrast(background, foreground) {
// using APCA for text contrast in buttons. In chamilo buttons the text
// has a font size of 16px and weight of 600
// Lc 60 The minimum level recommended for content text that is not body, column, or block text
// https://git.apcacontrast.com/documentation/APCA_in_a_Nutshell#use-case--size-ranges
let contrast = Math.abs(background.contrast(foreground, "APCA"))
if (contrast < 60) {
return t("Does not have enough contrast against background")
}
return ""
}
return { return {
getColorTheme, getColorTheme,
getColors, getColors,
setColors, setColors,
makeGradient,
makeTextWithContrast,
checkColorContrast,
} }
} }
export function useVisualTheme() { export function useVisualTheme() {
const platformConfigStore = usePlatformConfig() const platformConfigStore = usePlatformConfig()
const themeName = platformConfigStore.visualTheme
function getThemeAssetUrl(path) { function getThemeAssetUrl(path) {
return `/themes/${platformConfigStore.visualTheme}/${path}` return `/themes/${platformConfigStore.visualTheme}/${path}`
} }
return { return {
themeName,
getThemeAssetUrl, getThemeAssetUrl,
} }
} }

@ -12,27 +12,34 @@ async function findAllByCurrentUrl() {
} }
/** /**
* Update or create a theme with the title * Create a color theme
* *
* @param {string|null} iri
* @param {string} title * @param {string} title
* @param {Object} colors * @param {Object} colors
* @returns {Promise<Object>} * @returns {Promise<Object>}
*/ */
async function updateTheme({ iri = null, title, colors }) { async function create({ title, colors }) {
if (iri) {
return await baseService.put(iri, {
title,
variables: colors,
})
}
return await baseService.post(url, { return await baseService.post(url, {
title, title,
variables: colors, variables: colors,
}) })
} }
/**
* Update a color theme
*
* @param {string} iri
* @param {string} title
* @param {Object} colors
* @returns {Promise<Object>}
*/
async function update({ iri, title, colors }) {
return await baseService.put(iri, {
title,
variables: colors,
})
}
/** /**
* @param {string} iri * @param {string} iri
* @returns {Promise<Object>} * @returns {Promise<Object>}
@ -44,7 +51,8 @@ async function changePlatformColorTheme(iri) {
} }
export default { export default {
updateTheme, create,
update,
findAllByCurrentUrl, findAllByCurrentUrl,
changePlatformColorTheme, changePlatformColorTheme,
} }

@ -3,214 +3,11 @@
<SectionHeader :title="t('Configure Chamilo colors')" /> <SectionHeader :title="t('Configure Chamilo colors')" />
<div class="admin-colors__container"> <div class="admin-colors__container">
<form class="admin-colors__form"> <div class="admin-colors__form">
<ColorThemeSelector <ColorThemeForm />
ref="themeSelectorEl" </div>
v-model="selectedTheme"
/>
<BaseButton
:label="t('Save')"
icon="save"
type="primary"
@click="onClickChangeColorTheme"
/>
</form>
<BaseDivider />
<div class="admin-colors__settings">
<div class="admin-colors__settings-form">
<h3
v-t="selectedTheme ? 'Edit color theme' : 'Create color theme'"
class="admin-colors__settings-form-title"
/>
<BaseInputText
v-model="themeTitle"
:disabled="!!selectedTheme"
:label="t('Title')"
/>
<!-- Advanced mode -->
<div v-show="isAdvancedMode">
<div class="row-group">
<BaseColorPicker
v-model="colorPrimary"
:label="t('Primary color')"
/>
<BaseColorPicker
v-model="colorPrimaryGradient"
:label="t('Primary color hover/background')"
/>
<BaseColorPicker
v-model="colorPrimaryButtonText"
:error="colorPrimaryButtonTextError"
:label="t('Primary color button text')"
/>
<BaseColorPicker
v-model="colorPrimaryButtonAlternativeText"
:error="colorPrimaryButtonAlternativeTextError"
:label="t('Primary color button alternative text')"
/>
</div>
<div class="row-group">
<BaseColorPicker
v-model="colorSecondary"
:label="t('Secondary color')"
/>
<BaseColorPicker
v-model="colorSecondaryGradient"
:label="t('Secondary color hover/background')"
/>
<BaseColorPicker
v-model="colorSecondaryButtonText"
:error="colorSecondaryButtonTextError"
:label="t('Secondary color button text')"
/>
</div>
<div class="row-group">
<BaseColorPicker
v-model="colorTertiary"
:label="t('Tertiary color')"
/>
<BaseColorPicker
v-model="colorTertiaryGradient"
:label="t('Tertiary color hover/background')"
/>
</div>
<div class="row-group">
<BaseColorPicker
v-model="colorSuccess"
:label="t('Success color')"
/>
<BaseColorPicker
v-model="colorSuccessGradient"
:label="t('Success color hover/background')"
/>
<BaseColorPicker
v-model="colorSuccessButtonText"
:error="colorSuccessButtonTextError"
:label="t('Success color button text')"
/>
</div>
<div class="row-group">
<BaseColorPicker
v-model="colorInfo"
:label="t('Info color')"
/>
<BaseColorPicker
v-model="colorInfoGradient"
:label="t('Info color hover/background')"
/>
<BaseColorPicker
v-model="colorInfoButtonText"
:error="colorInfoButtonTextError"
:label="t('Info color button text')"
/>
</div>
<div class="row-group">
<BaseColorPicker
v-model="colorWarning"
:label="t('Warning color')"
/>
<BaseColorPicker
v-model="colorWarningGradient"
:label="t('Warning color hover/background')"
/>
<BaseColorPicker
v-model="colorWarningButtonText"
:error="colorWarningButtonTextError"
:label="t('Warning color button text')"
/>
</div>
<div class="row-group">
<BaseColorPicker
v-model="colorDanger"
:label="t('Danger color')"
/>
<BaseColorPicker
v-model="colorDangerGradient"
:label="t('Danger color hover/background')"
/>
</div>
<div class="row-group">
<BaseColorPicker
v-model="formColor"
:label="t('Form outline color')"
/>
</div>
</div>
<!-- Simple mode -->
<div v-show="!isAdvancedMode">
<div class="row-group">
<BaseColorPicker
v-model="colorPrimary"
:label="t('Primary color')"
/>
<BaseColorPicker
v-model="colorSecondary"
:label="t('Secondary color')"
/>
<BaseColorPicker
v-model="colorTertiary"
:label="t('Tertiary color')"
/>
</div>
<div class="row-group">
<BaseColorPicker
v-model="colorSuccess"
:label="t('Success color')"
/>
<BaseColorPicker
v-model="colorInfo"
:label="t('Info color')"
/>
<BaseColorPicker
v-model="colorWarning"
:label="t('Warning color')"
/>
<BaseColorPicker
v-model="colorDanger"
:label="t('Danger color')"
/>
</div>
<div class="row-group">
<BaseColorPicker
v-model="formColor"
:label="t('Form outline color')"
/>
</div>
</div>
<div class="field">
<BaseButton
:label="isAdvancedMode ? t('Hide advanced mode') : t('Show advanced mode')"
icon="cog"
type="black"
@click="isAdvancedMode = !isAdvancedMode"
/>
</div>
<div class="field">
<BaseButton
:label="t('Save')"
icon="send"
type="primary"
@click="saveColors"
/>
</div>
</div>
<div class="admin-colors__preview">
<BaseDivider layout="vertical" /> <BaseDivider layout="vertical" />
<ColorThemePreview /> <ColorThemePreview />
@ -220,224 +17,11 @@
</template> </template>
<script setup> <script setup>
import BaseButton from "../../components/basecomponents/BaseButton.vue"
import { useI18n } from "vue-i18n" import { useI18n } from "vue-i18n"
import { ref, watch } from "vue"
import BaseInputText from "../../components/basecomponents/BaseInputText.vue"
import BaseColorPicker from "../../components/basecomponents/BaseColorPicker.vue"
import { useTheme } from "../../composables/theme"
import { useNotification } from "../../composables/notification"
import Color from "colorjs.io"
import themeService from "../../services/colorThemeService"
import BaseDivider from "../../components/basecomponents/BaseDivider.vue" import BaseDivider from "../../components/basecomponents/BaseDivider.vue"
import SectionHeader from "../../components/layout/SectionHeader.vue" import SectionHeader from "../../components/layout/SectionHeader.vue"
import ColorThemeSelector from "../../components/platform/ColorThemeSelector.vue"
import ColorThemePreview from "../../components/admin/ColorThemePreview.vue" import ColorThemePreview from "../../components/admin/ColorThemePreview.vue"
import colorThemeService from "../../services/colorThemeService" import ColorThemeForm from "../../components/admin/ColorThemeForm.vue"
const { t } = useI18n() const { t } = useI18n()
const { getColorTheme, getColors, setColors } = useTheme()
const { showSuccessNotification, showErrorNotification } = useNotification()
const themeSelectorEl = ref()
let colorPrimary = getColorTheme("--color-primary-base")
let colorPrimaryGradient = getColorTheme("--color-primary-gradient")
let colorPrimaryButtonText = getColorTheme("--color-primary-button-text")
let colorPrimaryButtonAlternativeText = getColorTheme("--color-primary-button-alternative-text")
let colorSecondary = getColorTheme("--color-secondary-base")
let colorSecondaryGradient = getColorTheme("--color-secondary-gradient")
let colorSecondaryButtonText = getColorTheme("--color-secondary-button-text")
let colorTertiary = getColorTheme("--color-tertiary-base")
let colorTertiaryGradient = getColorTheme("--color-tertiary-gradient")
let colorTertiaryButtonText = getColorTheme("--color-tertiary-button-text")
let colorSuccess = getColorTheme("--color-success-base")
let colorSuccessGradient = getColorTheme("--color-success-gradient")
let colorSuccessButtonText = getColorTheme("--color-success-button-text")
let colorInfo = getColorTheme("--color-info-base")
let colorInfoGradient = getColorTheme("--color-info-gradient")
let colorInfoButtonText = getColorTheme("--color-info-button-text")
let colorWarning = getColorTheme("--color-warning-base")
let colorWarningGradient = getColorTheme("--color-warning-gradient")
let colorWarningButtonText = getColorTheme("--color-warning-button-text")
let colorDanger = getColorTheme("--color-danger-base")
let colorDangerGradient = getColorTheme("--color-danger-gradient")
let colorDangerButtonText = getColorTheme("--color-danger-button-text")
let formColor = getColorTheme("--color-form-base")
const themeTitle = ref()
const selectedTheme = ref()
const saveColors = async () => {
try {
const updatedTheme = await themeService.updateTheme({
iri: selectedTheme.value || undefined,
title: themeTitle.value,
colors: getColors(),
})
showSuccessNotification(t("Color updated"))
await themeSelectorEl.value.loadThemes()
selectedTheme.value = updatedTheme["@id"]
} catch (error) {
showErrorNotification(error)
console.error(error)
}
}
const isAdvancedMode = ref(false)
watch(colorPrimary, (newValue) => {
if (!isAdvancedMode.value) {
colorPrimaryGradient.value = makeGradient(newValue)
colorPrimaryButtonText.value = newValue
colorPrimaryButtonAlternativeText.value = makeTextWithContrast(newValue)
}
checkColorContrast(newValue, colorPrimaryButtonText.value, colorPrimaryButtonAlternativeTextError)
})
watch(colorSecondary, (newValue) => {
if (!isAdvancedMode.value) {
colorSecondaryGradient.value = makeGradient(newValue)
colorSecondaryButtonText.value = makeTextWithContrast(newValue)
}
checkColorContrast(newValue, colorSecondaryButtonText.value, colorSecondaryButtonTextError)
})
watch(colorTertiary, (newValue) => {
if (!isAdvancedMode.value) {
colorTertiaryButtonText.value = newValue
colorTertiaryGradient.value = makeGradient(newValue)
}
})
watch(colorSuccess, (newValue) => {
if (!isAdvancedMode.value) {
colorSuccessGradient.value = makeGradient(newValue)
colorSuccessButtonText.value = makeTextWithContrast(newValue)
}
checkColorContrast(newValue, colorSuccessButtonText.value, colorSuccessButtonTextError)
})
watch(colorInfo, (newValue) => {
if (!isAdvancedMode.value) {
colorInfoGradient.value = makeGradient(newValue)
colorInfoButtonText.value = makeTextWithContrast(newValue)
}
checkColorContrast(newValue, colorInfoButtonText.value, colorInfoButtonTextError)
})
watch(colorWarning, (newValue) => {
if (!isAdvancedMode.value) {
colorWarningGradient.value = makeGradient(newValue)
colorWarningButtonText.value = makeTextWithContrast(newValue)
}
checkColorContrast(newValue, colorWarningButtonText.value, colorWarningButtonTextError)
})
watch(colorDanger, (newValue) => {
if (!isAdvancedMode.value) {
colorDangerGradient.value = makeGradient(newValue)
colorDangerButtonText.value = makeTextWithContrast(newValue)
}
})
function makeGradient(color) {
const light = color.clone().to("oklab").l
// when color is light (lightness > 0.5), darken gradient color
// when color is dark, lighten gradient color
// The values 0.5 and 1.6 were chosen through experimentation, there could be a better way to do this
if (light > 0.5) {
return color
.clone()
.set({ "oklab.l": (l) => l * 0.8 })
.to("srgb")
} else {
return color
.clone()
.set({ "oklab.l": (l) => l * 1.6 })
.to("srgb")
}
}
function makeTextWithContrast(color) {
// according to colorjs library https://colorjs.io/docs/contrast#accessible-perceptual-contrast-algorithm-apca
// this algorithm is better than WCAGG 2.1 to check for contrast
// "APCA is being evaluated for use in version 3 of the W3C Web Content Accessibility Guidelines (WCAG)"
let onWhite = Math.abs(color.contrast("white", "APCA"))
let onBlack = Math.abs(color.contrast("black", "APCA"))
return onWhite > onBlack ? new Color("white") : new Color("black")
}
// check for contrast of text
const colorPrimaryButtonTextError = ref("")
watch(colorPrimaryButtonText, (newValue) => {
checkColorContrast(new Color("white"), newValue, colorPrimaryButtonTextError)
})
const colorPrimaryButtonAlternativeTextError = ref("")
watch(colorPrimaryButtonAlternativeText, (newValue) => {
checkColorContrast(colorPrimary.value, newValue, colorPrimaryButtonAlternativeTextError)
})
const colorSecondaryButtonTextError = ref("")
watch(colorSecondaryButtonText, (newValue) => {
checkColorContrast(colorSecondary.value, newValue, colorSecondaryButtonTextError)
})
const colorTertiaryButtonTextError = ref("")
watch(colorTertiaryButtonText, (newValue) => {
checkColorContrast(colorTertiary.value, newValue, colorTertiaryButtonTextError)
})
const colorSuccessButtonTextError = ref("")
watch(colorSuccessButtonText, (newValue) => {
checkColorContrast(colorSuccess.value, newValue, colorSuccessButtonTextError)
})
const colorInfoButtonTextError = ref("")
watch(colorInfoButtonText, (newValue) => {
checkColorContrast(colorInfo.value, newValue, colorInfoButtonTextError)
})
const colorWarningButtonTextError = ref("")
watch(colorWarningButtonText, (newValue) => {
checkColorContrast(colorWarning.value, newValue, colorWarningButtonTextError)
})
const colorDangerButtonTextError = ref("")
watch(colorDangerButtonText, (newValue) => {
checkColorContrast(new Color("white"), newValue, colorDangerButtonTextError)
})
function checkColorContrast(background, foreground, textErrorRef) {
if (isAdvancedMode.value) {
// using APCA for text contrast in buttons. In chamilo buttons the text
// has a font size of 16px and weight of 600
// Lc 60 The minimum level recommended for content text that is not body, column, or block text
// https://git.apcacontrast.com/documentation/APCA_in_a_Nutshell#use-case--size-ranges
let contrast = Math.abs(background.contrast(foreground, "APCA"))
console.log(`Contrast ${contrast}`)
if (contrast < 60) {
textErrorRef.value = t("Does not have enough contrast against background")
} else {
textErrorRef.value = ""
}
}
}
async function onClickChangeColorTheme() {
if (selectedTheme.value) {
await colorThemeService.changePlatformColorTheme(selectedTheme.value)
}
}
</script> </script>

@ -6,6 +6,7 @@ declare(strict_types=1);
namespace Chamilo\CoreBundle\Component\Utils; namespace Chamilo\CoreBundle\Component\Utils;
use Chamilo\CoreBundle\Framework\Container;
use ChamiloSession as Session; use ChamiloSession as Session;
use Database; use Database;
use DateInterval; use DateInterval;
@ -156,7 +157,7 @@ class ChamiloApi
): string { ): string {
$logoPath = Container::getThemeHelper()->getThemeAssetUrl('images/header-logo.svg'); $logoPath = Container::getThemeHelper()->getThemeAssetUrl('images/header-logo.svg');
if (empty($logo)) { if (empty($logoPath)) {
$logoPath = Container::getThemeHelper()->getThemeAssetUrl('images/header-logo.png'); $logoPath = Container::getThemeHelper()->getThemeAssetUrl('images/header-logo.png');
} }

@ -46,7 +46,7 @@ class ColorTheme
/** /**
* @var array<string, mixed> * @var array<string, mixed>
*/ */
#[Groups(['color_theme:write'])] #[Groups(['color_theme:write', 'access_url_rel_color_theme:read'])]
#[ORM\Column] #[ORM\Column]
private array $variables = []; private array $variables = [];

@ -11,6 +11,9 @@ use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @template-extends AbstractType<string>
*/
class PermissionType extends AbstractType class PermissionType extends AbstractType
{ {
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void

@ -50,7 +50,7 @@ final class Version20230215072918 extends AbstractMigrationChamilo
if (!empty($items)) { if (!empty($items)) {
foreach ($items as $item) { foreach ($items as $item) {
if (!($item['to_user_id'] === NULL || $item['to_user_id'] === 0)) { if (!(null === $item['to_user_id'] || 0 === $item['to_user_id'])) {
$sessionId = $item['session_id'] ?? 0; $sessionId = $item['session_id'] ?? 0;
$userId = $item['to_user_id'] ?? 0; $userId = $item['to_user_id'] ?? 0;
$session = $sessionRepo->find($sessionId); $session = $sessionRepo->find($sessionId);

@ -25,9 +25,9 @@ final class Version20240713125400 extends AbstractMigrationChamilo
foreach ($users as $user) { foreach ($users as $user) {
$roles = unserialize($user['roles']); $roles = unserialize($user['roles']);
if ($roles !== false) { if (false !== $roles) {
$updatedRoles = array_map(function ($role) { $updatedRoles = array_map(function ($role) {
return $role === 'ROLE_RRHH' ? 'ROLE_HR' : $role; return 'ROLE_RRHH' === $role ? 'ROLE_HR' : $role;
}, $roles); }, $roles);
$newRolesSerialized = serialize($updatedRoles); $newRolesSerialized = serialize($updatedRoles);
@ -35,7 +35,6 @@ final class Version20240713125400 extends AbstractMigrationChamilo
'UPDATE user SET roles = ? WHERE id = ?', 'UPDATE user SET roles = ? WHERE id = ?',
[$newRolesSerialized, $user['id']] [$newRolesSerialized, $user['id']]
); );
} }
} }
} }
@ -49,9 +48,9 @@ final class Version20240713125400 extends AbstractMigrationChamilo
foreach ($users as $user) { foreach ($users as $user) {
$roles = unserialize($user['roles']); $roles = unserialize($user['roles']);
if ($roles !== false) { if (false !== $roles) {
$updatedRoles = array_map(function ($role) { $updatedRoles = array_map(function ($role) {
return $role === 'ROLE_HR' ? 'ROLE_RRHH' : $role; return 'ROLE_HR' === $role ? 'ROLE_RRHH' : $role;
}, $roles); }, $roles);
$newRolesSerialized = serialize($updatedRoles); $newRolesSerialized = serialize($updatedRoles);

@ -31,7 +31,5 @@ final class Version20240715183456 extends AbstractMigration
$this->addSql("DELETE FROM extra_field WHERE variable = 'special_course' AND item_type = $extraFieldType"); $this->addSql("DELETE FROM extra_field WHERE variable = 'special_course' AND item_type = $extraFieldType");
} }
public function down(Schema $schema): void public function down(Schema $schema): void {}
{
}
} }

@ -42,9 +42,15 @@ final class ColorThemeStateProcessor implements ProcessorInterface
$colorTheme = $this->persistProcessor->process($data, $operation, $uriVariables, $context); $colorTheme = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
if ($colorTheme) { if ($colorTheme) {
$accessUrlRelColorTheme = (new AccessUrlRelColorTheme())->setColorTheme($colorTheme); $accessUrl = $this->accessUrlHelper->getCurrent();
$this->accessUrlHelper->getCurrent()->addColorTheme($accessUrlRelColorTheme); $accessUrlRelColorTheme = $accessUrl->getColorThemeByTheme($colorTheme);
if (!$accessUrlRelColorTheme) {
$accessUrlRelColorTheme = (new AccessUrlRelColorTheme())->setColorTheme($colorTheme);
$this->accessUrlHelper->getCurrent()->addColorTheme($accessUrlRelColorTheme);
}
$this->entityManager->flush(); $this->entityManager->flush();

Loading…
Cancel
Save