Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 70 additions & 4 deletions assets/vue/router/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <body>.
* 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", "<tool>", ...]
const tool = segs[1] || "generic"
const toolSlug = tool.replace(/[^a-z0-9\-_]+/gi, "-").toLowerCase()
return ["page-tool", `page-tool-${toolSlug}`]
}

// Generic fallback: page-<first segment>
const seg0 = p.split("/").filter(Boolean)[0] || "generic"
return [`page-${seg0.replace(/[^a-z0-9\-_]+/gi, "-").toLowerCase()}`]
}

const router = createRouter({
history: createWebHistory(),
routes: [
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions config/packages/twig.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ twig:

globals:
software_name: Chamilo
page_helper: '@Chamilo\CoreBundle\Helpers\PageHelper'
messages_count:
message_link:
execution_stats:
Expand Down
211 changes: 211 additions & 0 deletions src/CoreBundle/Helpers/PageHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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/<tool>/* -> page-tool + page-tool-<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-<first segment>
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';
}
}
4 changes: 3 additions & 1 deletion src/CoreBundle/Resources/views/Layout/base-layout.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
{% block chamilo_head %}
{%- include "@ChamiloCore/Layout/head.html.twig" %}
{% endblock %}
<body class="font-sans antialiased {{ section_name }}"
{%- set page_type_classes = page_helper.getPageTypeCssClasses(app.request) -%}
<body class="font-sans antialiased {{ section_name }} {{ page_type_classes|join(' ') }}"
data-in-course="{{ course ? 'true' : 'false' }}"
data-course-id="{{ course ? course.id : '' }}"
data-session-id="{{ session ? session.id : '' }}"
>
<noscript>{{ "Your browser does not support Javascript"|trans }}</noscript>
{%- block chamilo_wrap -%}
<div class="page-marker {{ page_type_classes|join(' ') }}" aria-hidden="true"></div>
{%- block page_content %}
{% endblock %}
{% endblock -%}
Expand Down
Loading