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 -%}