Skip to content

Commit ca8dfd4

Browse files
Internal: Add global page-type CSS markers (SPA + legacy) - refs #6047
1 parent 1314026 commit ca8dfd4

4 files changed

Lines changed: 285 additions & 5 deletions

File tree

assets/vue/router/index.js

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,64 @@ import { customVueTemplateEnabled } from "../config/env"
5454
import { useCourseSettings } from "../store/courseSettingStore"
5555
import { checkIsAllowedToEdit, useUserSessionSubscription } from "../composables/userPermissions"
5656

57+
/**
58+
* Applies "page-*" marker classes on both the DOM marker and the <body>.
59+
* This keeps stable theming hooks during SPA navigation (issue #6047).
60+
*
61+
* Note: Twig/PageHelper already sets initial classes on first load. This only syncs on route changes.
62+
*/
63+
function applyPageTypeClasses(classes) {
64+
const marker = document.querySelector(".page-marker")
65+
const body = document.body
66+
67+
const clearPageClasses = (el) => {
68+
if (!el) return
69+
;[...el.classList].forEach((c) => {
70+
if (c.startsWith("page-")) el.classList.remove(c)
71+
})
72+
}
73+
74+
clearPageClasses(marker)
75+
clearPageClasses(body)
76+
;(classes || []).forEach((c) => {
77+
if (!c || typeof c !== "string") return
78+
if (marker) marker.classList.add(c)
79+
body.classList.add(c)
80+
})
81+
}
82+
83+
/**
84+
* Derives stable page marker classes from the current route.
85+
* We intentionally avoid hardcoding all routes. PageHelper handles legacy PHP pages.
86+
*
87+
* @returns {string[]}
88+
*/
89+
function derivePageTypeClasses(to) {
90+
const p = String(to?.path || "/")
91+
92+
// Canonical aliases requested by the issue
93+
if (p === "/" || p.startsWith("/home")) return ["page-home"]
94+
if (p.startsWith("/courses")) return ["page-my-courses"]
95+
if (p.startsWith("/catalogue")) return ["page-catalogue"]
96+
if (p.startsWith("/social")) return ["page-social"]
97+
if (p.startsWith("/account")) return ["page-account-security"]
98+
if (p.startsWith("/admin-dashboard")) return ["page-administration", "page-administration-session"]
99+
if (p.startsWith("/admin")) return ["page-administration", "page-administration-platform"]
100+
if (p.startsWith("/tracking")) return ["page-tracking"]
101+
102+
// Vue "resources" module routes -> optional tool markers (documents, lp, attendance, etc.)
103+
if (p.startsWith("/resources/")) {
104+
const segs = p.split("/").filter(Boolean) // ["resources", "<tool>", ...]
105+
const tool = segs[1] || "generic"
106+
const toolSlug = tool.replace(/[^a-z0-9\-_]+/gi, "-").toLowerCase()
107+
return ["page-tool", `page-tool-${toolSlug}`]
108+
}
109+
110+
// Generic fallback: page-<first segment>
111+
const seg0 = p.split("/").filter(Boolean)[0] || "generic"
112+
return [`page-${seg0.replace(/[^a-z0-9\-_]+/gi, "-").toLowerCase()}`]
113+
}
114+
57115
const router = createRouter({
58116
history: createWebHistory(),
59117
routes: [
@@ -311,9 +369,7 @@ router.beforeEach(async (to, from, next) => {
311369
// Determine what the route requires
312370
const needsAuth = to.matched.some((record) => record.meta?.requiresAuth === true)
313371
const wantsAdmin = to.matched.some((record) => record.meta?.requiresAdmin === true)
314-
const wantsSessionAdmin = to.matched.some(
315-
(record) => record.meta?.requiresSessionAdmin === true,
316-
)
372+
const wantsSessionAdmin = to.matched.some((record) => record.meta?.requiresSessionAdmin === true)
317373

318374
const mustBeLogged = needsAuth || wantsAdmin || wantsSessionAdmin
319375

@@ -357,8 +413,18 @@ router.beforeEach(async (to, from, next) => {
357413
next()
358414
})
359415

360-
router.afterEach(() => {
416+
router.afterEach((to) => {
417+
// Always remove the loading cursor.
361418
document.body.classList.remove("cursor-wait")
419+
420+
// Keep page marker classes in sync for SPA navigation.
421+
// This is required because Twig/PageHelper does not run on client-side route changes.
422+
try {
423+
applyPageTypeClasses(derivePageTypeClasses(to))
424+
} catch (e) {
425+
// Never block navigation because of marker updates.
426+
console.error("Error applying page marker classes:", e)
427+
}
362428
})
363429

364430
router.beforeResolve(async (to) => {

config/packages/twig.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ twig:
1212

1313
globals:
1414
software_name: Chamilo
15+
page_helper: '@Chamilo\CoreBundle\Helpers\PageHelper'
1516
messages_count:
1617
message_link:
1718
execution_stats:

src/CoreBundle/Helpers/PageHelper.php

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@
1313
use Chamilo\CoreBundle\Repository\PageCategoryRepository;
1414
use Chamilo\CoreBundle\Repository\PageRepository;
1515
use Chamilo\CoreBundle\Repository\SysAnnouncementRepository;
16+
use Symfony\Component\HttpFoundation\Request;
1617
use Symfony\Component\Security\Core\User\UserInterface;
1718

19+
use const PHP_URL_PATH;
20+
1821
class PageHelper
1922
{
2023
protected PageRepository $pageRepository;
@@ -211,4 +214,212 @@ public function isFilePathExposedByVisibleAnnouncement(
211214

212215
return false;
213216
}
217+
218+
/**
219+
* Returns CSS classes that identify the current "global page type".
220+
*
221+
* @return string[]
222+
*/
223+
public function getPageTypeCssClasses(Request $request): array
224+
{
225+
$pathInfo = rtrim((string) $request->getPathInfo(), '/');
226+
if ('' === $pathInfo) {
227+
$pathInfo = '/';
228+
}
229+
230+
// For legacy PHP script URLs (/main/.../*.php), Symfony may return "/" as pathInfo.
231+
// In that case, rely on REQUEST_URI (path part only) to infer the real page type.
232+
$requestUri = (string) $request->server->get('REQUEST_URI', '');
233+
$uriPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?? '');
234+
$uriPath = rtrim($uriPath, '/');
235+
if ('' === $uriPath) {
236+
$uriPath = '/';
237+
}
238+
239+
// Use REQUEST_URI path when pathInfo is "/" but the actual URL path is not "/".
240+
$effectivePath = $pathInfo;
241+
if ('/' === $pathInfo && '/' !== $uriPath) {
242+
$effectivePath = $uriPath;
243+
}
244+
245+
$segments = array_values(array_filter(
246+
explode('/', trim($effectivePath, '/')),
247+
static fn ($v) => '' !== $v
248+
));
249+
$seg0 = $segments[0] ?? '';
250+
$seg1 = $segments[1] ?? '';
251+
$seg2 = $segments[2] ?? '';
252+
253+
// Home (only when the real URL path is "/")
254+
if ('/' === $effectivePath) {
255+
return ['page-home'];
256+
}
257+
258+
if ('home' === $seg0) {
259+
return ['page-home'];
260+
}
261+
262+
if ('courses' === $seg0) {
263+
return ['page-my-courses'];
264+
}
265+
266+
if ('catalogue' === $seg0) {
267+
return ['page-catalogue'];
268+
}
269+
270+
if ('agenda' === $seg0 || 'calendar' === $seg0) {
271+
return ['page-agenda'];
272+
}
273+
274+
if ('tracking' === $seg0) {
275+
return ['page-tracking'];
276+
}
277+
278+
if ('social' === $seg0) {
279+
return ['page-social'];
280+
}
281+
282+
if ('account' === $seg0) {
283+
return ['page-account-security'];
284+
}
285+
286+
if ('admin-dashboard' === $seg0) {
287+
return ['page-administration', 'page-administration-session'];
288+
}
289+
290+
// Administration + sub-blocks (Vue)
291+
if ('admin' === $seg0) {
292+
$classes = ['page-administration'];
293+
294+
if ('' !== $seg1) {
295+
// Example: /admin/settings -> page-administration page-administration-settings
296+
$classes[] = 'page-administration-'.$this->slugCss($seg1);
297+
}
298+
299+
// Most Vue admin pages are platform-level.
300+
if (!\in_array('page-administration-platform', $classes, true)) {
301+
$classes[] = 'page-administration-platform';
302+
}
303+
304+
return array_values(array_unique($classes));
305+
}
306+
307+
// Vue "resources" routes -> optional tool markers
308+
// Example: /resources/document/... -> page-tool page-tool-document
309+
if ('resources' === $seg0 && '' !== $seg1) {
310+
return ['page-tool', 'page-tool-'.$this->slugCss($seg1)];
311+
}
312+
313+
// Legacy PHP pages under /main/*
314+
if ('main' === $seg0 && '' !== $seg1) {
315+
// Tracking must share the same marker across all its pages.
316+
if ('tracking' === $seg1) {
317+
return ['page-tracking'];
318+
}
319+
320+
// Legacy administration pages are NOT tools.
321+
// Examples:
322+
// - /main/admin/user_list.php -> page-administration page-administration-user
323+
// - /main/admin/course_add.php -> page-administration page-administration-course
324+
// - /main/admin/session_list.php -> page-administration page-administration-session
325+
if ('admin' === $seg1) {
326+
$classes = ['page-administration'];
327+
328+
// Try to detect admin sub-block from the script filename (seg2), or fallback to SCRIPT_NAME.
329+
$scriptFile = $seg2;
330+
if ('' === $scriptFile) {
331+
$scriptName = (string) $request->server->get('SCRIPT_NAME', '');
332+
$scriptFile = basename($scriptName);
333+
}
334+
335+
$block = $this->detectLegacyAdminBlock($scriptFile);
336+
$classes[] = 'page-administration-'.$block;
337+
338+
// Ensure we always have a stable "platform" marker when no specific block applies.
339+
if (!\in_array('page-administration-platform', $classes, true)
340+
&& !\in_array('page-administration-user', $classes, true)
341+
&& !\in_array('page-administration-course', $classes, true)
342+
&& !\in_array('page-administration-session', $classes, true)
343+
) {
344+
$classes[] = 'page-administration-platform';
345+
}
346+
347+
return array_values(array_unique($classes));
348+
}
349+
350+
// Other legacy tools: /main/<tool>/* -> page-tool + page-tool-<tool>
351+
return ['page-tool', 'page-tool-'.$this->slugCss($seg1)];
352+
}
353+
354+
// Legacy tools fallback by script name (extra safety)
355+
$script = (string) $request->server->get('SCRIPT_NAME', '');
356+
if (preg_match('#/main/([a-z_]+)/#', $script, $m)) {
357+
$tool = (string) $m[1];
358+
359+
if ('tracking' === $tool) {
360+
return ['page-tracking'];
361+
}
362+
363+
// Legacy administration pages are NOT tools.
364+
if ('admin' === $tool) {
365+
$classes = ['page-administration'];
366+
$classes[] = 'page-administration-'.$this->detectLegacyAdminBlock(basename($script));
367+
368+
// Safe default when no specific block applies.
369+
if (!\in_array('page-administration-platform', $classes, true)
370+
&& !\in_array('page-administration-user', $classes, true)
371+
&& !\in_array('page-administration-course', $classes, true)
372+
&& !\in_array('page-administration-session', $classes, true)
373+
) {
374+
$classes[] = 'page-administration-platform';
375+
}
376+
377+
return array_values(array_unique($classes));
378+
}
379+
380+
return ['page-tool', 'page-tool-'.$this->slugCss($tool)];
381+
}
382+
383+
// Generic fallback: page-<first segment>
384+
if ('' !== $seg0) {
385+
return ['page-'.$this->slugCss($seg0)];
386+
}
387+
388+
return ['page-generic'];
389+
}
390+
391+
/**
392+
* Detect legacy admin block from filename.
393+
* Keeps theming markers stable without hardcoding every admin file.
394+
*/
395+
private function detectLegacyAdminBlock(string $scriptFile): string
396+
{
397+
$file = strtolower($scriptFile);
398+
399+
if (str_starts_with($file, 'user_') || str_starts_with($file, 'user-')) {
400+
return 'user';
401+
}
402+
403+
if (str_starts_with($file, 'course_') || str_starts_with($file, 'course-')) {
404+
return 'course';
405+
}
406+
407+
if (str_starts_with($file, 'session_') || str_starts_with($file, 'session-')) {
408+
return 'session';
409+
}
410+
411+
return 'platform';
412+
}
413+
414+
/**
415+
* Converts a string into a safe CSS class fragment.
416+
*/
417+
private function slugCss(string $value): string
418+
{
419+
$value = strtolower(trim($value));
420+
$value = preg_replace('/[^a-z0-9\-_]+/', '-', $value) ?? $value;
421+
$value = trim($value, '-');
422+
423+
return '' !== $value ? $value : 'generic';
424+
}
214425
}

src/CoreBundle/Resources/views/Layout/base-layout.html.twig

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@
1010
{% block chamilo_head %}
1111
{%- include "@ChamiloCore/Layout/head.html.twig" %}
1212
{% endblock %}
13-
<body class="font-sans antialiased {{ section_name }}"
13+
{%- set page_type_classes = page_helper.getPageTypeCssClasses(app.request) -%}
14+
<body class="font-sans antialiased {{ section_name }} {{ page_type_classes|join(' ') }}"
1415
data-in-course="{{ course ? 'true' : 'false' }}"
1516
data-course-id="{{ course ? course.id : '' }}"
1617
data-session-id="{{ session ? session.id : '' }}"
1718
>
1819
<noscript>{{ "Your browser does not support Javascript"|trans }}</noscript>
1920
{%- block chamilo_wrap -%}
21+
<div class="page-marker {{ page_type_classes|join(' ') }}" aria-hidden="true"></div>
2022
{%- block page_content %}
2123
{% endblock %}
2224
{% endblock -%}

0 commit comments

Comments
 (0)