diff --git a/assets/vue/router/index.js b/assets/vue/router/index.js
index 05a2f790e7c..71082ba477d 100644
--- a/assets/vue/router/index.js
+++ b/assets/vue/router/index.js
@@ -54,6 +54,64 @@ import { customVueTemplateEnabled } from "../config/env"
import { useCourseSettings } from "../store/courseSettingStore"
import { checkIsAllowedToEdit, useUserSessionSubscription } from "../composables/userPermissions"
+/**
+ * Applies "page-*" marker classes on both the DOM marker and the
.
+ * This keeps stable theming hooks during SPA navigation (issue #6047).
+ *
+ * Note: Twig/PageHelper already sets initial classes on first load. This only syncs on route changes.
+ */
+function applyPageTypeClasses(classes) {
+ const marker = document.querySelector(".page-marker")
+ const body = document.body
+
+ const clearPageClasses = (el) => {
+ if (!el) return
+ ;[...el.classList].forEach((c) => {
+ if (c.startsWith("page-")) el.classList.remove(c)
+ })
+ }
+
+ clearPageClasses(marker)
+ clearPageClasses(body)
+ ;(classes || []).forEach((c) => {
+ if (!c || typeof c !== "string") return
+ if (marker) marker.classList.add(c)
+ body.classList.add(c)
+ })
+}
+
+/**
+ * Derives stable page marker classes from the current route.
+ * We intentionally avoid hardcoding all routes. PageHelper handles legacy PHP pages.
+ *
+ * @returns {string[]}
+ */
+function derivePageTypeClasses(to) {
+ const p = String(to?.path || "/")
+
+ // Canonical aliases requested by the issue
+ if (p === "/" || p.startsWith("/home")) return ["page-home"]
+ if (p.startsWith("/courses")) return ["page-my-courses"]
+ if (p.startsWith("/catalogue")) return ["page-catalogue"]
+ if (p.startsWith("/social")) return ["page-social"]
+ if (p.startsWith("/account")) return ["page-account-security"]
+ if (p.startsWith("/admin-dashboard")) return ["page-administration", "page-administration-session"]
+ if (p.startsWith("/admin")) return ["page-administration", "page-administration-platform"]
+ if (p.startsWith("/tracking")) return ["page-tracking"]
+
+ // Vue "resources" module routes -> optional tool markers (documents, lp, attendance, etc.)
+ if (p.startsWith("/resources/")) {
+ const segs = p.split("/").filter(Boolean) // ["resources", "", ...]
+ const tool = segs[1] || "generic"
+ const toolSlug = tool.replace(/[^a-z0-9\-_]+/gi, "-").toLowerCase()
+ return ["page-tool", `page-tool-${toolSlug}`]
+ }
+
+ // Generic fallback: page-
+ const seg0 = p.split("/").filter(Boolean)[0] || "generic"
+ return [`page-${seg0.replace(/[^a-z0-9\-_]+/gi, "-").toLowerCase()}`]
+}
+
const router = createRouter({
history: createWebHistory(),
routes: [
@@ -311,9 +369,7 @@ router.beforeEach(async (to, from, next) => {
// Determine what the route requires
const needsAuth = to.matched.some((record) => record.meta?.requiresAuth === true)
const wantsAdmin = to.matched.some((record) => record.meta?.requiresAdmin === true)
- const wantsSessionAdmin = to.matched.some(
- (record) => record.meta?.requiresSessionAdmin === true,
- )
+ const wantsSessionAdmin = to.matched.some((record) => record.meta?.requiresSessionAdmin === true)
const mustBeLogged = needsAuth || wantsAdmin || wantsSessionAdmin
@@ -357,8 +413,18 @@ router.beforeEach(async (to, from, next) => {
next()
})
-router.afterEach(() => {
+router.afterEach((to) => {
+ // Always remove the loading cursor.
document.body.classList.remove("cursor-wait")
+
+ // Keep page marker classes in sync for SPA navigation.
+ // This is required because Twig/PageHelper does not run on client-side route changes.
+ try {
+ applyPageTypeClasses(derivePageTypeClasses(to))
+ } catch (e) {
+ // Never block navigation because of marker updates.
+ console.error("Error applying page marker classes:", e)
+ }
})
router.beforeResolve(async (to) => {
diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml
index 2493d77bb4a..dc5ab923d1c 100644
--- a/config/packages/twig.yaml
+++ b/config/packages/twig.yaml
@@ -12,6 +12,7 @@ twig:
globals:
software_name: Chamilo
+ page_helper: '@Chamilo\CoreBundle\Helpers\PageHelper'
messages_count:
message_link:
execution_stats:
diff --git a/src/CoreBundle/Helpers/PageHelper.php b/src/CoreBundle/Helpers/PageHelper.php
index b803e1afabd..48776dc8a9d 100644
--- a/src/CoreBundle/Helpers/PageHelper.php
+++ b/src/CoreBundle/Helpers/PageHelper.php
@@ -13,8 +13,11 @@
use Chamilo\CoreBundle\Repository\PageCategoryRepository;
use Chamilo\CoreBundle\Repository\PageRepository;
use Chamilo\CoreBundle\Repository\SysAnnouncementRepository;
+use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\User\UserInterface;
+use const PHP_URL_PATH;
+
class PageHelper
{
protected PageRepository $pageRepository;
@@ -208,4 +211,212 @@ public function isFilePathExposedByVisibleAnnouncement(
return false;
}
+
+ /**
+ * Returns CSS classes that identify the current "global page type".
+ *
+ * @return string[]
+ */
+ public function getPageTypeCssClasses(Request $request): array
+ {
+ $pathInfo = rtrim((string) $request->getPathInfo(), '/');
+ if ('' === $pathInfo) {
+ $pathInfo = '/';
+ }
+
+ // For legacy PHP script URLs (/main/.../*.php), Symfony may return "/" as pathInfo.
+ // In that case, rely on REQUEST_URI (path part only) to infer the real page type.
+ $requestUri = (string) $request->server->get('REQUEST_URI', '');
+ $uriPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?? '');
+ $uriPath = rtrim($uriPath, '/');
+ if ('' === $uriPath) {
+ $uriPath = '/';
+ }
+
+ // Use REQUEST_URI path when pathInfo is "/" but the actual URL path is not "/".
+ $effectivePath = $pathInfo;
+ if ('/' === $pathInfo && '/' !== $uriPath) {
+ $effectivePath = $uriPath;
+ }
+
+ $segments = array_values(array_filter(
+ explode('/', trim($effectivePath, '/')),
+ static fn ($v) => '' !== $v
+ ));
+ $seg0 = $segments[0] ?? '';
+ $seg1 = $segments[1] ?? '';
+ $seg2 = $segments[2] ?? '';
+
+ // Home (only when the real URL path is "/")
+ if ('/' === $effectivePath) {
+ return ['page-home'];
+ }
+
+ if ('home' === $seg0) {
+ return ['page-home'];
+ }
+
+ if ('courses' === $seg0) {
+ return ['page-my-courses'];
+ }
+
+ if ('catalogue' === $seg0) {
+ return ['page-catalogue'];
+ }
+
+ if ('agenda' === $seg0 || 'calendar' === $seg0) {
+ return ['page-agenda'];
+ }
+
+ if ('tracking' === $seg0) {
+ return ['page-tracking'];
+ }
+
+ if ('social' === $seg0) {
+ return ['page-social'];
+ }
+
+ if ('account' === $seg0) {
+ return ['page-account-security'];
+ }
+
+ if ('admin-dashboard' === $seg0) {
+ return ['page-administration', 'page-administration-session'];
+ }
+
+ // Administration + sub-blocks (Vue)
+ if ('admin' === $seg0) {
+ $classes = ['page-administration'];
+
+ if ('' !== $seg1) {
+ // Example: /admin/settings -> page-administration page-administration-settings
+ $classes[] = 'page-administration-'.$this->slugCss($seg1);
+ }
+
+ // Most Vue admin pages are platform-level.
+ if (!\in_array('page-administration-platform', $classes, true)) {
+ $classes[] = 'page-administration-platform';
+ }
+
+ return array_values(array_unique($classes));
+ }
+
+ // Vue "resources" routes -> optional tool markers
+ // Example: /resources/document/... -> page-tool page-tool-document
+ if ('resources' === $seg0 && '' !== $seg1) {
+ return ['page-tool', 'page-tool-'.$this->slugCss($seg1)];
+ }
+
+ // Legacy PHP pages under /main/*
+ if ('main' === $seg0 && '' !== $seg1) {
+ // Tracking must share the same marker across all its pages.
+ if ('tracking' === $seg1) {
+ return ['page-tracking'];
+ }
+
+ // Legacy administration pages are NOT tools.
+ // Examples:
+ // - /main/admin/user_list.php -> page-administration page-administration-user
+ // - /main/admin/course_add.php -> page-administration page-administration-course
+ // - /main/admin/session_list.php -> page-administration page-administration-session
+ if ('admin' === $seg1) {
+ $classes = ['page-administration'];
+
+ // Try to detect admin sub-block from the script filename (seg2), or fallback to SCRIPT_NAME.
+ $scriptFile = $seg2;
+ if ('' === $scriptFile) {
+ $scriptName = (string) $request->server->get('SCRIPT_NAME', '');
+ $scriptFile = basename($scriptName);
+ }
+
+ $block = $this->detectLegacyAdminBlock($scriptFile);
+ $classes[] = 'page-administration-'.$block;
+
+ // Ensure we always have a stable "platform" marker when no specific block applies.
+ if (!\in_array('page-administration-platform', $classes, true)
+ && !\in_array('page-administration-user', $classes, true)
+ && !\in_array('page-administration-course', $classes, true)
+ && !\in_array('page-administration-session', $classes, true)
+ ) {
+ $classes[] = 'page-administration-platform';
+ }
+
+ return array_values(array_unique($classes));
+ }
+
+ // Other legacy tools: /main//* -> page-tool + page-tool-
+ return ['page-tool', 'page-tool-'.$this->slugCss($seg1)];
+ }
+
+ // Legacy tools fallback by script name (extra safety)
+ $script = (string) $request->server->get('SCRIPT_NAME', '');
+ if (preg_match('#/main/([a-z_]+)/#', $script, $m)) {
+ $tool = (string) $m[1];
+
+ if ('tracking' === $tool) {
+ return ['page-tracking'];
+ }
+
+ // Legacy administration pages are NOT tools.
+ if ('admin' === $tool) {
+ $classes = ['page-administration'];
+ $classes[] = 'page-administration-'.$this->detectLegacyAdminBlock(basename($script));
+
+ // Safe default when no specific block applies.
+ if (!\in_array('page-administration-platform', $classes, true)
+ && !\in_array('page-administration-user', $classes, true)
+ && !\in_array('page-administration-course', $classes, true)
+ && !\in_array('page-administration-session', $classes, true)
+ ) {
+ $classes[] = 'page-administration-platform';
+ }
+
+ return array_values(array_unique($classes));
+ }
+
+ return ['page-tool', 'page-tool-'.$this->slugCss($tool)];
+ }
+
+ // Generic fallback: page-
+ if ('' !== $seg0) {
+ return ['page-'.$this->slugCss($seg0)];
+ }
+
+ return ['page-generic'];
+ }
+
+ /**
+ * Detect legacy admin block from filename.
+ * Keeps theming markers stable without hardcoding every admin file.
+ */
+ private function detectLegacyAdminBlock(string $scriptFile): string
+ {
+ $file = strtolower($scriptFile);
+
+ if (str_starts_with($file, 'user_') || str_starts_with($file, 'user-')) {
+ return 'user';
+ }
+
+ if (str_starts_with($file, 'course_') || str_starts_with($file, 'course-')) {
+ return 'course';
+ }
+
+ if (str_starts_with($file, 'session_') || str_starts_with($file, 'session-')) {
+ return 'session';
+ }
+
+ return 'platform';
+ }
+
+ /**
+ * Converts a string into a safe CSS class fragment.
+ */
+ private function slugCss(string $value): string
+ {
+ $value = strtolower(trim($value));
+ $value = preg_replace('/[^a-z0-9\-_]+/', '-', $value) ?? $value;
+ $value = trim($value, '-');
+
+ return '' !== $value ? $value : 'generic';
+ }
}
diff --git a/src/CoreBundle/Resources/views/Layout/base-layout.html.twig b/src/CoreBundle/Resources/views/Layout/base-layout.html.twig
index 408d2fc9006..a9e2b8ee9d3 100644
--- a/src/CoreBundle/Resources/views/Layout/base-layout.html.twig
+++ b/src/CoreBundle/Resources/views/Layout/base-layout.html.twig
@@ -10,13 +10,15 @@
{% block chamilo_head %}
{%- include "@ChamiloCore/Layout/head.html.twig" %}
{% endblock %}
-
{%- block chamilo_wrap -%}
+
{%- block page_content %}
{% endblock %}
{% endblock -%}