Display: Add support for color theme per access_url & custom logo - refs BT#21621 #5578
@ -0,0 +1,50 @@ |
||||
<script setup> |
||||
import BaseSelect from "../basecomponents/BaseSelect.vue" |
||||
import { ref } from "vue" |
||||
import themeService from "../../services/colorThemeService" |
||||
import { useI18n } from "vue-i18n" |
||||
import { useNotification } from "../../composables/notification" |
||||
|
||||
const modelValue = defineModel({ |
||||
required: true, |
||||
type: Object, |
||||
}) |
||||
|
||||
const { t } = useI18n() |
||||
const { showErrorNotification } = useNotification() |
||||
|
||||
const serverThemes = ref([]) |
||||
const isServerThemesLoading = ref(true) |
||||
|
||||
const loadThemes = async () => { |
||||
try { |
||||
const { items } = await themeService.findAllByCurrentUrl() |
||||
|
||||
serverThemes.value = items.map((accessUrlRelColorTheme) => accessUrlRelColorTheme.colorTheme) |
||||
|
||||
modelValue.value = items.find((accessUrlRelColorTheme) => accessUrlRelColorTheme.active)?.colorTheme["@id"] |
||||
} catch (e) { |
||||
showErrorNotification(t("We could not retrieve the themes")) |
||||
} finally { |
||||
isServerThemesLoading.value = false |
||||
} |
||||
} |
||||
|
||||
defineExpose({ |
||||
loadThemes, |
||||
}) |
||||
|
||||
loadThemes() |
||||
</script> |
||||
|
||||
<template> |
||||
<BaseSelect |
||||
v-model="modelValue" |
||||
:is-loading="isServerThemesLoading" |
||||
:label="t('Color theme selected')" |
||||
:options="serverThemes" |
||||
allow-clear |
||||
option-label="title" |
||||
option-value="@id" |
||||
/> |
||||
</template> |
@ -0,0 +1,98 @@ |
||||
<?php |
||||
|
||||
/* For licensing terms, see /license.txt */ |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Chamilo\CoreBundle\Entity; |
||||
|
||||
use ApiPlatform\Metadata\ApiResource; |
||||
use ApiPlatform\Metadata\GetCollection; |
||||
use ApiPlatform\Metadata\Post; |
||||
use Chamilo\CoreBundle\Repository\AccessUrlRelColorThemeRepository; |
||||
use Chamilo\CoreBundle\State\AccessUrlRelColorThemeStateProcessor; |
||||
use Chamilo\CoreBundle\State\AccessUrlRelColorThemeStateProvider; |
||||
use Doctrine\ORM\Mapping as ORM; |
||||
use Gedmo\Timestampable\Traits\TimestampableEntity; |
||||
use Symfony\Component\Serializer\Attribute\Groups; |
||||
|
||||
#[ApiResource( |
||||
operations: [ |
||||
new Post(), |
||||
new GetCollection(), |
||||
], |
||||
normalizationContext: [ |
||||
'groups' => ['access_url_rel_color_theme:read'], |
||||
], |
||||
denormalizationContext: [ |
||||
'groups' => ['access_url_rel_color_theme:write'], |
||||
], |
||||
paginationEnabled: false, |
||||
security: "is_granted('ROLE_ADMIN')", |
||||
provider: AccessUrlRelColorThemeStateProvider::class, |
||||
processor: AccessUrlRelColorThemeStateProcessor::class, |
||||
)] |
||||
#[ORM\Entity(repositoryClass: AccessUrlRelColorThemeRepository::class)] |
||||
class AccessUrlRelColorTheme |
||||
{ |
||||
use TimestampableEntity; |
||||
|
||||
#[ORM\Id] |
||||
#[ORM\GeneratedValue] |
||||
#[ORM\Column] |
||||
private ?int $id = null; |
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'colorThemes')] |
||||
#[ORM\JoinColumn(nullable: false)] |
||||
private ?AccessUrl $url = null; |
||||
|
||||
#[Groups(['access_url_rel_color_theme:write', 'access_url_rel_color_theme:read'])] |
||||
#[ORM\ManyToOne(inversedBy: 'urls')] |
||||
#[ORM\JoinColumn(nullable: false)] |
||||
private ?ColorTheme $colorTheme = null; |
||||
|
||||
#[Groups(['access_url_rel_color_theme:read'])] |
||||
#[ORM\Column] |
||||
private bool $active = false; |
||||
|
||||
public function getId(): ?int |
||||
{ |
||||
return $this->id; |
||||
} |
||||
|
||||
public function getUrl(): ?AccessUrl |
||||
{ |
||||
return $this->url; |
||||
} |
||||
|
||||
public function setUrl(?AccessUrl $url): static |
||||
{ |
||||
$this->url = $url; |
||||
|
||||
return $this; |
||||
} |
||||
|
||||
public function getColorTheme(): ?ColorTheme |
||||
{ |
||||
return $this->colorTheme; |
||||
} |
||||
|
||||
public function setColorTheme(?ColorTheme $colorTheme): static |
||||
{ |
||||
$this->colorTheme = $colorTheme; |
||||
|
||||
return $this; |
||||
} |
||||
|
||||
public function isActive(): bool |
||||
{ |
||||
return $this->active; |
||||
} |
||||
|
||||
public function setActive(bool $active): static |
||||
{ |
||||
$this->active = $active; |
||||
|
||||
return $this; |
||||
} |
||||
} |
@ -0,0 +1,48 @@ |
||||
<?php |
||||
|
||||
namespace Chamilo\CoreBundle\Migrations\Schema\V200; |
||||
|
||||
use Chamilo\CoreBundle\Migrations\AbstractMigrationChamilo; |
||||
use Doctrine\DBAL\Schema\Schema; |
||||
use Symfony\Component\Filesystem\Filesystem; |
||||
use Symfony\Component\Finder\Finder; |
||||
|
||||
class Version20240704185300 extends AbstractMigrationChamilo |
||||
{ |
||||
public function getDescription(): string |
||||
{ |
||||
return "Fix stylesheet and theme settings and move theme directory during development"; |
||||
} |
||||
|
||||
/** |
||||
* @inheritDoc |
||||
*/ |
||||
public function up(Schema $schema): void |
||||
{ |
||||
$this->addSql("DELETE FROM settings WHERE variable IN ('stylesheets', 'theme')"); |
||||
|
||||
$kernel = $this->container->get('kernel'); |
||||
$rootPath = $kernel->getProjectDir(); |
||||
|
||||
$themeDirectory = $rootPath.'/var/theme'; |
||||
$themesDirectory = $rootPath.'/var/themes'; |
||||
|
||||
$finder = new Finder(); |
||||
$filesystem = new Filesystem(); |
||||
|
||||
$finder->directories()->in($themeDirectory)->depth('== 0'); |
||||
|
||||
foreach ($finder as $entry) { |
||||
if ($entry->isDir()) { |
||||
error_log( |
||||
sprintf( |
||||
"Moving theme directory: %s %s", |
||||
$entry->getRealPath(), |
||||
$themesDirectory.'/' |
||||
) |
||||
); |
||||
$filesystem->rename($entry->getRealPath(), $themesDirectory.'/'.$entry->getRelativePathname()); |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,27 @@ |
||||
<?php |
||||
|
||||
/* For licensing terms, see /license.txt */ |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Chamilo\CoreBundle\Repository; |
||||
|
||||
use Chamilo\CoreBundle\Entity\AccessUrlRelColorTheme; |
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; |
||||
use Doctrine\Persistence\ManagerRegistry; |
||||
|
||||
/** |
||||
* @extends ServiceEntityRepository<AccessUrlRelColorTheme> |
||||
* |
||||
* @method AccessUrlRelColorTheme|null find($id, $lockMode = null, $lockVersion = null) |
||||
* @method AccessUrlRelColorTheme|null findOneBy(array $criteria, array $orderBy = null) |
||||
* @method AccessUrlRelColorTheme[] findAll() |
||||
* @method AccessUrlRelColorTheme[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) |
||||
*/ |
||||
final class AccessUrlRelColorThemeRepository extends ServiceEntityRepository |
||||
{ |
||||
public function __construct(ManagerRegistry $registry) |
||||
{ |
||||
parent::__construct($registry, AccessUrlRelColorTheme::class); |
||||
} |
||||
} |
@ -0,0 +1,103 @@ |
||||
<?php |
||||
|
||||
/* For licensing terms, see /license.txt */ |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Chamilo\CoreBundle\ServiceHelper; |
||||
|
||||
use Chamilo\CoreBundle\Settings\SettingsManager; |
||||
use Chamilo\CourseBundle\Settings\SettingsCourseManager; |
||||
use League\Flysystem\FilesystemException; |
||||
use League\Flysystem\FilesystemOperator; |
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire; |
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; |
||||
use Symfony\Component\Routing\RouterInterface; |
||||
|
||||
final class ThemeHelper |
||||
{ |
||||
public const DEFAULT_THEME = 'chamilo'; |
||||
|
||||
public function __construct( |
||||
private readonly AccessUrlHelper $accessUrlHelper, |
||||
private readonly SettingsManager $settingsManager, |
||||
private readonly UserHelper $userHelper, |
||||
private readonly CidReqHelper $cidReqHelper, |
||||
private readonly SettingsCourseManager $settingsCourseManager, |
||||
private readonly RouterInterface $router, |
||||
#[Autowire(service: 'oneup_flysystem.themes_filesystem')] private readonly FilesystemOperator $filesystem, |
||||
) {} |
||||
|
||||
/** |
||||
* Returns the name of the color theme configured to be applied on the current page. |
||||
* The returned name depends on the platform, course or user settings. |
||||
*/ |
||||
public function getVisualTheme(): string |
||||
{ |
||||
static $visualTheme; |
||||
|
||||
global $lp_theme_css; |
||||
|
||||
if (isset($visualTheme)) { |
||||
return $visualTheme; |
||||
} |
||||
|
||||
$accessUrl = $this->accessUrlHelper->getCurrent(); |
||||
|
||||
$visualTheme = $accessUrl->getActiveColorTheme()?->getColorTheme()->getSlug(); |
||||
|
||||
if ('true' == $this->settingsManager->getSetting('profile.user_selected_theme')) { |
||||
$visualTheme = $this->userHelper->getCurrent()?->getTheme(); |
||||
} |
||||
|
||||
if ('true' == $this->settingsManager->getSetting('course.allow_course_theme')) { |
||||
$course = $this->cidReqHelper->getCourseEntity(); |
||||
|
||||
if ($course) { |
||||
$this->settingsCourseManager->setCourse($course); |
||||
|
||||
$visualTheme = $this->settingsCourseManager->getCourseSettingValue('course_theme'); |
||||
|
||||
if (1 === (int) $this->settingsCourseManager->getCourseSettingValue('allow_learning_path_theme')) { |
||||
$visualTheme = $lp_theme_css; |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (empty($visualTheme)) { |
||||
return self::DEFAULT_THEME; |
||||
} |
||||
|
||||
return $visualTheme; |
||||
} |
||||
|
||||
public function getThemeAssetUrl(string $path, bool $absolute = false): string |
||||
{ |
||||
$themeName = $this->getVisualTheme(); |
||||
|
||||
try { |
||||
if (!$this->filesystem->fileExists($themeName.DIRECTORY_SEPARATOR.$path)) { |
||||
return ''; |
||||
} |
||||
} catch (FilesystemException) { |
||||
return ''; |
||||
} |
||||
|
||||
return $this->router->generate( |
||||
'theme_asset', |
||||
['name' => $themeName, 'path' => $path], |
||||
$absolute ? UrlGeneratorInterface::ABSOLUTE_URL : UrlGeneratorInterface::ABSOLUTE_PATH |
||||
); |
||||
} |
||||
|
||||
public function getThemeAssetLinkTag(string $path, bool $absoluteUrl = false): string |
||||
{ |
||||
$url = $this->getThemeAssetUrl($path, $absoluteUrl); |
||||
|
||||
if (empty($url)) { |
||||
return ''; |
||||
} |
||||
|
||||
return sprintf('<link rel="stylesheet" href="%s">', $url); |
||||
} |
||||
} |
@ -1,76 +0,0 @@ |
||||
<?php |
||||
|
||||
/* For licensing terms, see /license.txt */ |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Chamilo\CoreBundle\Settings; |
||||
|
||||
use Sylius\Bundle\SettingsBundle\Schema\AbstractSettingsBuilder; |
||||
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; |
||||
use Symfony\Component\Finder\Finder; |
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; |
||||
use Symfony\Component\Form\FormBuilderInterface; |
||||
use Symfony\Contracts\Service\Attribute\Required; |
||||
|
||||
class StylesheetsSettingsSchema extends AbstractSettingsSchema |
||||
{ |
||||
private ParameterBagInterface $parameterBag; |
||||
|
||||
#[Required] |
||||
public function setParameterBag(ParameterBagInterface $parameterBag): void |
||||
{ |
||||
$this->parameterBag = $parameterBag; |
||||
} |
||||
|
||||
public function buildSettings(AbstractSettingsBuilder $builder): void |
||||
{ |
||||
$builder |
||||
->setDefaults( |
||||
[ |
||||
'stylesheets' => 'chamilo', |
||||
] |
||||
) |
||||
; |
||||
$allowedTypes = [ |
||||
'stylesheets' => ['string'], |
||||
]; |
||||
$this->setMultipleAllowedTypes($allowedTypes, $builder); |
||||
} |
||||
|
||||
public function buildForm(FormBuilderInterface $builder): void |
||||
{ |
||||
$builder |
||||
->add('stylesheets', ChoiceType::class, [ |
||||
'choices' => $this->getThemeChoices(), |
||||
'label' => 'Select Stylesheet Theme', |
||||
]) |
||||
; |
||||
|
||||
$this->updateFormFieldsFromSettingsInfo($builder); |
||||
} |
||||
|
||||
private function getThemeChoices(): array |
||||
{ |
||||
$projectDir = $this->parameterBag->get('kernel.project_dir'); |
||||
$themesDirectory = $projectDir.'/assets/css/themes/'; |
||||
|
||||
$finder = new Finder(); |
||||
$choices = []; |
||||
|
||||
$finder->directories()->in($themesDirectory)->depth('== 0'); |
||||
if ($finder->hasResults()) { |
||||
foreach ($finder as $folder) { |
||||
$folderName = $folder->getRelativePathname(); |
||||
$choices[$this->formatFolderName($folderName)] = $folderName; |
||||
} |
||||
} |
||||
|
||||
return $choices; |
||||
} |
||||
|
||||
private function formatFolderName(string $name): string |
||||
{ |
||||
return ucwords(str_replace('_', ' ', $name)); |
||||
} |
||||
} |
@ -0,0 +1,45 @@ |
||||
<?php |
||||
|
||||
/* For licensing terms, see /license.txt */ |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Chamilo\CoreBundle\State; |
||||
|
||||
use ApiPlatform\Metadata\Operation; |
||||
use ApiPlatform\State\ProcessorInterface; |
||||
use Chamilo\CoreBundle\Entity\AccessUrlRelColorTheme; |
||||
use Chamilo\CoreBundle\ServiceHelper\AccessUrlHelper; |
||||
use Doctrine\ORM\EntityManagerInterface; |
||||
|
||||
final class AccessUrlRelColorThemeStateProcessor implements ProcessorInterface |
||||
{ |
||||
public function __construct( |
||||
private readonly AccessUrlHelper $accessUrlHelper, |
||||
private readonly EntityManagerInterface $entityManager, |
||||
) {} |
||||
|
||||
public function process($data, Operation $operation, array $uriVariables = [], array $context = []): AccessUrlRelColorTheme |
||||
{ |
||||
assert($data instanceof AccessUrlRelColorTheme); |
||||
|
||||
$accessUrl = $this->accessUrlHelper->getCurrent(); |
||||
$accessUrl->getActiveColorTheme()?->setActive(false); |
||||
|
||||
$accessUrlRelColorTheme = $accessUrl->getColorThemeByTheme($data->getColorTheme()); |
||||
|
||||
if ($accessUrlRelColorTheme) { |
||||
$accessUrlRelColorTheme->setActive(true); |
||||
} else { |
||||
$data->setActive(true); |
||||
|
||||
$accessUrl->addColorTheme($data); |
||||
|
||||
$accessUrlRelColorTheme = $data; |
||||
} |
||||
|
||||
$this->entityManager->flush(); |
||||
|
||||
return $accessUrlRelColorTheme; |
||||
} |
||||
} |
@ -0,0 +1,32 @@ |
||||
<?php |
||||
|
||||
namespace Chamilo\CoreBundle\State; |
||||
|
||||
use ApiPlatform\Metadata\Operation; |
||||
use ApiPlatform\State\ProviderInterface; |
||||
use Chamilo\CoreBundle\Entity\AccessUrlRelColorTheme; |
||||
use Chamilo\CoreBundle\ServiceHelper\AccessUrlHelper; |
||||
|
||||
/** |
||||
* @template-implements ProviderInterface<AccessUrlRelColorTheme> |
||||
*/ |
||||
class AccessUrlRelColorThemeStateProvider implements ProviderInterface |
||||
{ |
||||
public function __construct( |
||||
private readonly AccessUrlHelper $accessUrlHelper, |
||||
) {} |
||||
|
||||
/** |
||||
* @inheritdoc |
||||
*/ |
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []) |
||||
{ |
||||
$colorThemes = $this->accessUrlHelper->getCurrent()->getColorThemes(); |
||||
|
||||
if (0 == $colorThemes->count()) { |
||||
$colorThemes = $this->accessUrlHelper->getFirstAccessUrl()->getColorThemes(); |
||||
} |
||||
|
||||
return $colorThemes; |
||||
} |
||||
} |
@ -0,0 +1,45 @@ |
||||
<?php |
||||
|
||||
/* For licensing terms, see /license.txt */ |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Chamilo\Tests\CoreBundle\Controller; |
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
|
||||
class ThemeControllerTest extends WebTestCase |
||||
{ |
||||
|
||||
public function testValidAccess(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
|
||||
$client->request('GET', '/themes/chamilo/colors.css'); |
||||
|
||||
$this->assertResponseIsSuccessful(); |
||||
} |
||||
|
||||
public function testInvalidAccess(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
|
||||
$client->request('GET', '/themes/chamilo/default.css'); |
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); |
||||
} |
||||
|
||||
public function testAccessToSystemFiles(): void |
||||
{ |
||||
$client = static::createClient(); |
||||
$client->request('GET', '/themes/chamilo/../../../../../../etc/passwd'); |
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_INTERNAL_SERVER_ERROR); |
||||
|
||||
|
||||
$client->request('GET', 'themes/chamilo/../../../.env'); |
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_INTERNAL_SERVER_ERROR); |
||||
} |
||||
} |
@ -0,0 +1,32 @@ |
||||
:root { |
||||
--color-primary-base: 97 53 131; |
||||
--color-primary-gradient: 36 77 103; |
||||
--color-primary-button-text: 46 117 163; |
||||
--color-primary-button-alternative-text: 255 255 255; |
||||
|
||||
--color-secondary-base: 243 126 47; |
||||
--color-secondary-gradient: 224 100 16; |
||||
--color-secondary-button-text: 255 255 255; |
||||
|
||||
--color-tertiary-base: 51 51 51; |
||||
--color-tertiary-gradient: 0 0 0; |
||||
--color-tertiary-button-text: 255 255 255; |
||||
|
||||
--color-success-base: 119 170 12; |
||||
--color-success-gradient: 83 127 0; |
||||
--color-success-button-text: 255 255 255; |
||||
|
||||
--color-info-base: 13 123 253; |
||||
--color-info-gradient: 0 84 211; |
||||
--color-info-button-text: 255 255 255; |
||||
|
||||
--color-warning-base: 245 206 1; |
||||
--color-warning-gradient: 186 152 0; |
||||
--color-warning-button-text: 0 0 0; |
||||
|
||||
--color-danger-base: 223 59 59; |
||||
--color-danger-gradient: 180 0 21; |
||||
--color-danger-button-text: 255 255 255; |
||||
|
||||
--color-form-base: 46 117 163; |
||||
} |
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.0 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |