PageSourceSearch

https://elearning.ucse.edu.ar//lib/javascript/Educativa/Modules/Aula/Navigation.js?v=1787049104

js ucse.edu.ar collected 2026-10-02 11:55:03 UTC 39,543 bytes, 1,233 lines download raw bytes

1/***********************************************************/
2/*                                                         */
3/* Plataforma e-ducativa - Argentina                       */
4/*                                                         */
5/* Copyright (c) 2026 de e-ducativa Educación Virtual S.A. */
6/*                                                         */
7/***********************************************************/
8/**
9 * @file Navigation.js
10 * @brief Modulo unificado de navegacion para Frontend y Backend
11 *
12 * Maneja topbar, icon sidebar, menu de usuario y QR para acceso movil.
13 */
14
15import { Modal as ModalBase, isBackend } from 'Educativa/Modules/Aula/Base';
16import { popupAyuda, Utils } from 'Educativa/Modules/Base';
17import { Paginador, PaginadorVista } from 'Educativa/Modules/PaginadorSimple';
18import { LangSwitcher } from 'Educativa/Modules/Aula/Components/LangSwitcher';
19import { resetSpotlights } from 'Educativa/EducativaSpotlightTour';
20
21// Dict, EduOpen, url_dir y QRCode son globales disponibles en toda la aplicacion
22
23// Indicador de novedad (punto verde) del menu de usuario. Onboarding:
24// invita a abrir "Perfil y preferencias". El contador de aperturas se
25// persiste por navegador en localStorage; al llegar al limite el punto
26// desaparece.
27const PERFIL_INDICATOR_KEY = 'perfil_preferencias_visitas';
28const PERFIL_INDICATOR_LIMIT = 3;
29
30/**
31 * Clase Navigation - Gestiona toda la navegacion del sistema
32 */
33export class Navigation {
34    constructor(config) {
35        // Configuracion
36        this.username = config.username;
37        this.user_nombre_completo = config.user_nombre_completo;
38        this.url_thumb = config.url_thumb;
39        this.url_dir = config.url_dir;
40        this.id_curso = config.id_curso || 0;
41        this.sesiones_activas = config.sesiones_activas || 0;
42        this.url_ayuda = config.url_ayuda;
43        this.gestor_academico = config.gestor_academico === 'true' || config.gestor_academico === true;
44        this.is_backend = config.is_backend === 'true' || config.is_backend === true;
45        this.in_proctorizer = config.in_proctorizer === 'true' || config.in_proctorizer === true;
46
47        // En backend, el MenuAdmin renderiza un <a id="ayuda-link"> con la URL
48        // contextual correcta (incluye help_secc). Usarlo si esta disponible.
49        if ( this.is_backend ) {
50            var ayudaLink = document.getElementById( 'ayuda-link' );
51            if ( ayudaLink && ayudaLink.href ) {
52                this.url_ayuda = ayudaLink.href;
53            }
54        }
55
56        // Elementos del DOM
57        this.trigger = document.querySelector('[data-user-menu-trigger]');
58        this.dropdown = document.querySelector('.topbar-user-dropdown');
59
60        // Estado
61        this.isOpen = false;
62        this.perfilIndicator = null;
63        this.perfilMenuIndicator = null;
64
65        // Estado QR
66        this.qrModal = null;
67        this.qr_container = null;
68        this.qr_data = null;
69        this.qr_update = null;
70        this.qrcode = null;
71        this.showingQR = false;
72        this.temporal_access = null;
73
74        // Inicializar
75        this.init();
76    }
77
78    init() {
79        if (!this.trigger || !this.dropdown) {
80            // Silenciosamente no inicializar si no hay elementos
81            // (caso de iframe como personal.cgi)
82            return;
83        }
84
85        // Instanciar el lang switcher si esta presente en el DOM (bug #18341).
86        if ( document.querySelector( '[data-lang-switcher]' ) ) {
87            const langSwitcher = new LangSwitcher();
88            langSwitcher.render();
89        }
90
91        // Generar contenido del dropdown
92        this.renderDropdownContent();
93
94        // Punto verde de novedad en el menu de usuario (onboarding perfil)
95        this.initPerfilNoveltyIndicator();
96
97        // Event listener para abrir/cerrar
98        this.trigger.addEventListener('click', () => {
99            this.toggle();
100        });
101
102        // Cerrar al hacer click fuera
103        document.addEventListener('click', (e) => {
104            if (this.isOpen && !this.dropdown.contains(e.target) && !this.trigger.contains(e.target)) {
105                this.close();
106            }
107        });
108
109        // Cerrar con Escape
110        document.addEventListener('keydown', (e) => {
111            if (e.key === 'Escape' && this.isOpen) this.close();
112        });
113
114        // Inicializar toggle movil de sidebar
115        this.initMobileToggle();
116
117        // Inicializar pin de sidebar (solo desktop)
118        this.initPinToggle();
119
120        // Deshabilitar transiciones del sidebar durante resize
121        this.initResizeHandler();
122
123        // Boton scroll to top
124        this.initScrollToTop();
125    }
126
127    /**
128     * Inicializa el toggle de la sidebar para movil
129     */
130    initMobileToggle() {
131        const toggleBtn = document.querySelector('.sidebar-toggle');
132        const sidebar = document.querySelector('.icon-sidebar');
133        const overlay = document.querySelector('.sidebar-overlay');
134
135        if (!toggleBtn || !sidebar || !overlay) {
136            return;
137        }
138
139        // Toggle al hacer click en el boton
140        toggleBtn.addEventListener('click', () => {
141            sidebar.classList.toggle('show');
142            overlay.classList.toggle('show');
143        });
144
145        // Cerrar al hacer click en el overlay
146        overlay.addEventListener('click', () => {
147            sidebar.classList.remove('show');
148            overlay.classList.remove('show');
149        });
150
151        // Cerrar al hacer click en el boton de cerrar del sidebar
152        const closeBtn = sidebar.querySelector('.icon-sidebar-close');
153        if (closeBtn) {
154            closeBtn.addEventListener('click', () => {
155                sidebar.classList.remove('show');
156                overlay.classList.remove('show');
157            });
158        }
159    }
160
161    /**
162     * Crea el boton flotante de scroll-to-top que aparece al hacer scroll
163     */
164    initScrollToTop() {
165        const btn = document.createElement( 'div' );
166        btn.id = 'scroll-to-top';
167        btn.classList.add( 'scroll_top' );
168
169        if ( this.in_proctorizer ) {
170            btn.classList.add( 'in_proctorizer' );
171
172            const wrapper = document.querySelector( '.wrapper_floating_components' );
173            if ( wrapper ) {
174                wrapper.classList.add( 'in_proctorizer' );
175            }
176        }
177
178        const icon = document.createElement( 'i' );
179        icon.classList.add( 'icono', 'fas', 'fa-chevron-up' );
180        btn.appendChild( icon );
181        document.body.appendChild( btn );
182
183        btn.addEventListener( 'click', () => {
184            document.querySelector( 'body' ).scrollIntoView( { behavior: 'smooth' } );
185        } );
186
187        window.addEventListener( 'scroll', () => {
188            btn.classList.toggle( 'show', window.scrollY >= 150 );
189        } );
190    }
191
192    /**
193     * Deshabilita transiciones del sidebar durante resize para evitar
194     * el flash visual al cruzar breakpoints
195     */
196    initResizeHandler() {
197        let resizeTimer;
198        window.addEventListener('resize', () => {
199            document.body.classList.add('resize-in-progress');
200            clearTimeout(resizeTimer);
201            resizeTimer = setTimeout(() => {
202                document.body.classList.remove('resize-in-progress');
203            }, 100);
204        });
205    }
206
207    /**
208     * Inicializa el pin de la sidebar para desktop
209     * Persiste el estado en localStorage
210     */
211    initPinToggle() {
212        if (document.body.classList.contains('backend')) return;
213
214        const STORAGE_KEY = 'icon_sidebar_pinned';
215        const sidebar = document.querySelector('.icon-sidebar');
216        const pinBtn = sidebar?.querySelector('.icon-sidebar-pin');
217
218        if (!pinBtn || !sidebar) return;
219
220        // Ancho minimo de viewport para aplicar el pin expandido
221        // (evita solape con .menu_secciones del aula en anchos medios)
222        const wideQuery = window.matchMedia('(min-width: 1660px)');
223
224        // Aplica el pin al DOM solo si la preferencia esta activa Y el
225        // viewport es suficientemente ancho. La preferencia se conserva.
226        const applyPinState = () => {
227            const prefers = localStorage.getItem(STORAGE_KEY) === 'true';
228            const active = prefers && wideQuery.matches;
229            sidebar.classList.toggle('pinned', active);
230            document.body.classList.toggle('sidebar-pinned', active);
231        };
232
233        applyPinState();
234        wideQuery.addEventListener('change', applyPinState);
235
236        pinBtn.addEventListener('click', () => {
237            // En anchos donde el pin no se puede aplicar, el boton no hace nada
238            if (!wideQuery.matches) return;
239            const prefers = localStorage.getItem(STORAGE_KEY) !== 'true';
240            localStorage.setItem(STORAGE_KEY, String(prefers));
241            applyPinState();
242        });
243    }
244
245    renderDropdownContent() {
246        // Limpiar contenido previo
247        this.dropdown.innerHTML = '';
248
249        // Crear lista
250        const ul = document.createElement('ul');
251        ul.className = 'topbar-user-menu-list';
252
253        // Perfil y preferencias
254        this.addPerfilOption(ul);
255
256        // QR (login movil) - solo si no es gestor academico
257        if (!this.gestor_academico) {
258            this.addQROption(ul);
259        }
260
261        // Ayuda
262        this.addAyudaOption(ul);
263
264        // Tour Campus
265        this.addTourCampusOption(ul);
266
267        // Contactar al administrador
268        this.addSoporteOption(ul);
269
270        // Cerrar sesiones remotas (si hay mas de 1 sesion activa)
271        if (this.sesiones_activas > 1) {
272            this.addRemoteSessionsOption(ul);
273        }
274
275        // Cerrar sesion
276        this.addLogoutOption(ul);
277
278        this.dropdown.appendChild(ul);
279    }
280
281    addPerfilOption(ul) {
282        const li = document.createElement('li');
283        li.className = 'topbar-user-menu-item';
284
285        const link = document.createElement('a');
286        link.href = '#';
287        link.className = 'topbar-user-menu-link';
288        link.textContent = Dict.termcap('PERFIL_Y_PREFERENCIAS');
289
290        link.addEventListener('click', (e) => {
291            e.preventDefault();
292            if (this.esUsuarioAnonimo()) {
293                alert(Dict.termcap('ESTA_FUNCION_NO_ESTA_DISPONIBLE_PARA_USUARIOS_ANONIMOS'));
294                return;
295            }
296            this.registerPerfilVisit();
297            this.openPerfilModal();
298            this.close();
299        });
300
301        li.appendChild(link);
302
303        // Punto verde de novedad: refuerza el onboarding dentro del menu.
304        // Lo agrega addPerfilOption (y no initPerfilNoveltyIndicator) para
305        // que sobreviva al re-render del dropdown en finishRemoteSessions.
306        // Se inserta inline dentro del link, pegado al texto.
307        if (!this.esUsuarioAnonimo() &&
308            this.getPerfilVisitCount() < PERFIL_INDICATOR_LIMIT) {
309            const indicator = document.createElement('span');
310            indicator.className = 'topbar-user-menu-indicator';
311            link.appendChild(indicator);
312            this.perfilMenuIndicator = indicator;
313        }
314
315        ul.appendChild(li);
316    }
317
318    addQROption(ul) {
319        const li = document.createElement('li');
320        li.className = 'topbar-user-menu-item';
321
322        const link = document.createElement('a');
323        link.href = '#';
324        link.className = 'topbar-user-menu-link';
325        link.textContent = Dict.term('QR_OPCION_MENU_DESPLEGABLE');
326
327        link.addEventListener('click', (e) => {
328            e.preventDefault();
329            this.openQRModal();
330            this.close();
331        });
332
333        li.appendChild(link);
334        ul.appendChild(li);
335    }
336
337    addAyudaOption(ul) {
338        const li = document.createElement('li');
339        li.className = 'topbar-user-menu-item';
340
341        const link = document.createElement('a');
342        link.href = '#';
343        link.className = 'topbar-user-menu-link';
344        link.textContent = Dict.termcap('AYUDA');
345
346        link.addEventListener('click', (e) => {
347            e.preventDefault();
348            popupAyuda({ url: this.url_ayuda });
349            this.close();
350        });
351
352        li.appendChild(link);
353        ul.appendChild(li);
354    }
355
356    addTourCampusOption(ul) {
357        if ( this.esUsuarioAnonimo() ) { return; }
358
359        const li = document.createElement('li');
360        li.className = 'topbar-user-menu-item';
361
362        const link = document.createElement('a');
363        link.href = '#';
364        link.className = 'topbar-user-menu-link';
365        link.textContent = Dict.termcap('TOUR_CAMPUS');
366
367        link.addEventListener('click', (e) => {
368            e.preventDefault();
369            resetSpotlights();
370        });
371
372        li.appendChild(link);
373        ul.appendChild(li);
374    }
375
376    addSoporteOption(ul) {
377        const li = document.createElement('li');
378        li.className = 'topbar-user-menu-item';
379
380        const link = document.createElement('a');
381        link.href = '#';
382        link.className = 'topbar-user-menu-link';
383        link.textContent = Dict.termcap('CONTACTAR_AL_ADMINISTRADOR');
384
385        link.addEventListener('click', (e) => {
386            e.preventDefault();
387            this.openSoporteModal();
388            this.close();
389        });
390
391        li.appendChild(link);
392        ul.appendChild(li);
393    }
394
395    addRemoteSessionsOption(ul) {
396        const li = document.createElement('li');
397        li.className = 'topbar-user-menu-item';
398
399        const link = document.createElement('a');
400        link.href = '#';
401        link.className = 'topbar-user-menu-link';
402        link.textContent = Dict.termcap('CERRAR_SESIONES_REMOTAS');
403
404        link.addEventListener('click', (e) => {
405            e.preventDefault();
406            this.finishRemoteSessions();
407            this.close();
408        });
409
410        li.appendChild(link);
411        ul.appendChild(li);
412    }
413
414    addLogoutOption(ul) {
415        const li = document.createElement('li');
416        li.className = 'topbar-user-menu-item topbar-user-menu-item-logout';
417
418        const link = document.createElement('a');
419        link.href = '#';
420        link.className = 'topbar-user-menu-link';
421        link.textContent = Dict.termcap('SALIR');
422
423        link.addEventListener('click', (e) => {
424            e.preventDefault();
425            this.cerrarSesion();
426        });
427
428        li.appendChild(link);
429        ul.appendChild(li);
430    }
431
432    /**
433     * Inicializa el modal de Perfil si no existe
434     */
435    initPerfilModal() {
436        if (this.modal_window) {
437            return;
438        }
439
440        // Crear iframe para personal.cgi
441        const iframe = document.createElement('iframe');
442        iframe.style.width = '100%';
443        iframe.style.height = '100%';
444        iframe.style.border = '0';
445        iframe.setAttribute('src', this.url_dir + 'personal.cgi');
446
447        // Crear modal usando Modal global de Comunes.js
448        this.modal_window = new Modal({
449            content: iframe,
450            header: { show: false },
451            id: 'perfil_modal_nav',
452            classes: ['wrapper_size_large', 'perfil_nav']
453        });
454
455        // Agregar clase 'iframe' al contenido del modal para remover padding
456        this.modal_window.modalContent.classList.add('iframe');
457    }
458
459    openPerfilModal() {
460        this.initPerfilModal();
461        this.modal_window.show();
462    }
463
464    openQRModal() {
465        this.initQRModal();
466        this.getQRAccesoTemporal();
467    }
468
469    openSoporteModal() {
470        // Abrir modal de contacto con administrador
471        if (typeof EduOpen !== 'undefined') {
472            const idCursoParam = this.id_curso ? '&id_curso=' + this.id_curso : '';
473            EduOpen({
474                url: this.url_dir + 'mensajeria.cgi?mail_to_admin=1' + idCursoParam,
475                id: 'comunes_mensajeria',
476                exitButtonOnly: true,
477                classname: 'comunes_mensajeria'
478            });
479        }
480    }
481
482    finishRemoteSessions() {
483        // Cierra sesiones remotas via acceso.cgi finish_remote_sessions
484        const formData = new FormData();
485        formData.append('wAccion', 'finish_remote_sessions');
486
487        fetch(this.url_dir + 'qr.cgi', {
488            method: 'POST',
489            body: formData
490        }).then((response) => {
491            if (response.ok) {
492                alert(Dict.termcap('CERRAR_SESIONES_REMOTAS_SUCCESS'));
493                // Recargar el dropdown para remover la opcion
494                this.sesiones_activas = 1;
495                this.renderDropdownContent();
496            }
497        }).catch((error) => {
498            console.error('Error cerrando sesiones remotas:', error);
499        });
500    }
501
502    cerrarSesion() {
503        EducativaDraftManager.clearAll();
504        const id_curso = this.id_curso || 0;
505        window.location.href = this.url_dir + 'acceso.cgi?wAccion=logoff&id_curso=' + id_curso;
506    }
507
508    toggle() {
509        if ( this.in_proctorizer ) return;
510
511        if (this.isOpen) {
512            this.close();
513        } else {
514            this.open();
515        }
516    }
517
518    open() {
519        this.dropdown.style.display = 'block';
520        this.trigger.classList.add('active');
521        this.isOpen = true;
522    }
523
524    close() {
525        this.dropdown.style.display = 'none';
526        this.trigger.classList.remove('active');
527        this.isOpen = false;
528    }
529
530    esUsuarioAnonimo() {
531        return this.username === '_anonimo';
532    }
533
534    /**
535     * Lee del localStorage la cantidad de aperturas de "Perfil y
536     * preferencias" registradas en este navegador.
537     * @returns {number}
538     */
539    getPerfilVisitCount() {
540        const stored = parseInt(localStorage.getItem(PERFIL_INDICATOR_KEY), 10);
541        return isNaN(stored) ? 0 : stored;
542    }
543
544    /**
545     * Inyecta el punto verde de novedad sobre el menu de usuario mientras
546     * el usuario no haya abierto "Perfil y preferencias" el numero de veces
547     * definido por PERFIL_INDICATOR_LIMIT (onboarding).
548     */
549    initPerfilNoveltyIndicator() {
550        if (this.esUsuarioAnonimo()) {
551            return;
552        }
553
554        if (this.getPerfilVisitCount() >= PERFIL_INDICATOR_LIMIT) {
555            return;
556        }
557
558        const indicator = document.createElement('span');
559        indicator.className = 'topbar-user-indicator';
560        this.trigger.appendChild(indicator);
561        this.perfilIndicator = indicator;
562    }
563
564    /**
565     * Registra una apertura de "Perfil y preferencias" e incrementa el
566     * contador del localStorage. Al alcanzar el limite remueve los puntos
567     * verdes de la topbar y del item del menu de usuario.
568     */
569    registerPerfilVisit() {
570        const count = this.getPerfilVisitCount() + 1;
571        localStorage.setItem(PERFIL_INDICATOR_KEY, String(count));
572
573        if (count < PERFIL_INDICATOR_LIMIT) {
574            return;
575        }
576
577        if (this.perfilIndicator) {
578            this.perfilIndicator.remove();
579            this.perfilIndicator = null;
580        }
581
582        if (this.perfilMenuIndicator) {
583            this.perfilMenuIndicator.remove();
584            this.perfilMenuIndicator = null;
585        }
586    }
587
588    /**
589     * Actualiza los datos del usuario (nombre completo y foto de perfil)
590     * Llamado desde personal.cgi cuando el usuario modifica sus datos
591     * @param {Object} data
592     * @param {string} data.nombreCompleto - Nombre completo del usuario
593     * @param {string} data.urlThumb - URL de la foto de perfil
594     */
595    actualizarDatosUsuario({ nombreCompleto, urlThumb }) {
596        if (nombreCompleto) {
597            this.user_nombre_completo = nombreCompleto;
598            // Actualizar el nombre en el DOM si es necesario
599            const userNameElement = document.querySelector('.topbar-user-name');
600            if (userNameElement) {
601                userNameElement.textContent = nombreCompleto;
602            }
603        }
604
605        if (urlThumb) {
606            this.url_thumb = urlThumb;
607            // Actualizar la imagen en el DOM si existe
608            const avatarElement = document.querySelector('.topbar-user-avatar');
609            if (avatarElement) {
610                avatarElement.src = urlThumb;
611            }
612        }
613    }
614
615    // =========================================================================
616    // Funcionalidad QR para acceso movil
617    // =========================================================================
618
619    /**
620     * Inicializa el modal de QR si no existe
621     */
622    initQRModal() {
623        if (this.qrModal) {
624            return;
625        }
626
627        this.qrModal = new ModalBase({
628            classname: 'qr',
629            close_hdlr: this.closeQRModal.bind(this)
630        });
631
632        const aclaracion = document.createElement('div');
633        aclaracion.classList.add('desc');
634        aclaracion.textContent = Dict.term('QR_SCAN_DESCRIPCION');
635        this.qrModal.container.appendChild(aclaracion);
636
637        const qrContainer = document.createElement('div');
638        qrContainer.classList.add('qr_container');
639        this.qr_container = qrContainer;
640        this.qrModal.container.appendChild(qrContainer);
641    }
642
643    /**
644     * Handler para cerrado de ventana modal de QR
645     */
646    closeQRModal() {
647        this.showingQR = false;
648    }
649
650    /**
651     * Realiza llamada ajax a qr.cgi para generar un acceso temporal
652     * El link generado sera usado por la app al leer el codigo QR
653     */
654    getQRAccesoTemporal() {
655        const id_curso_actual = this.id_curso || 0;
656
657        const qr_params = {
658            'id_curso': id_curso_actual,
659            'wAccion': 'get_qr_link',
660            'cgi': this.getQRFwdLink()
661        };
662        const postParams = Utils.prepareFormData(qr_params);
663
664        fetch(url_dir + 'qr.cgi', {
665            method: "POST",
666            body: postParams
667        }).then(response => {
668            if (!response.ok) {
669                throw new Error(`Request failed: ${response.status}`);
670            }
671            return response.text();
672        }).then((responseText) => {
673            const json = JSON.parse(responseText);
674            let link;
675            if (json['data'] && json['data']['link']) {
676                link = json['data']['link'];
677                this.temporal_access = json['data']['temporal_access'];
678            }
679            this.showQRCode(link);
680        }).catch((error) => {
681            // Error silently handled
682        });
683    }
684
685    /**
686     * Link al que se redirigira la app al loguearse mediante QR
687     * @returns {string} URL relativa al CGI actual
688     */
689    getQRFwdLink() {
690        if (isBackend()) {
691            return 'escritorio.cgi';
692        } else {
693            let link;
694            const path_array = window.location.pathname.split('/');
695            const cgi = path_array[path_array.length - 1];
696
697            if (cgi) {
698                link = cgi + window.location.search;
699                return link;
700            }
701        }
702    }
703
704    /**
705     * Muestra el codigo QR en el modal
706     * @param {string} link - URL a codificar en el QR
707     */
708    showQRCode(link) {
709        const qrData = document.createElement('div');
710        qrData.classList.add('qr_data');
711        const qrOverlay = document.createElement('img');
712        qrOverlay.classList.add('qr_overlay');
713        qrData.appendChild(qrOverlay);
714        this.qr_data = qrData;
715
716        this.qr_container.replaceChildren(qrData);
717
718        const qrOk = this.loadQRCode(link);
719
720        if (!qrOk) {
721            return;
722        }
723
724        this.showQRModal();
725    }
726
727    /**
728     * Genera el codigo QR usando la libreria QRCode.js
729     * @param {string} link - URL a codificar
730     * @returns {boolean} true si se genero correctamente
731     */
732    loadQRCode(link) {
733        try {
734            this.qrcode = new QRCode(this.qr_data, {
735                text: link,
736                width: 300,
737                height: 300,
738                colorDark: "#000000",
739                colorLight: "#ffffff",
740                correctLevel: QRCode.CorrectLevel.H
741            });
742            return true;
743        } catch (e) {
744            // Error generating QR code
745            return false;
746        }
747    }
748
749    /**
750     * Muestra el modal con el QR
751     */
752    showQRModal() {
753        this.showingQR = true;
754        this.qrModal.show();
755        setTimeout(this.showUpdateQR.bind(this), 60000);
756        setTimeout(this.checkAppLogin.bind(this), 5000);
757    }
758
759    /**
760     * Oculta el modal de QR
761     */
762    hideQRModal() {
763        this.showingQR = false;
764        this.qrModal.hide();
765    }
766
767    /**
768     * Handler del timeout de la ventana modal de QR
769     * Muestra boton para refrescar el codigo despues de 60 segundos
770     */
771    showUpdateQR() {
772        this.showingQR = false;
773
774        const qrUpdate = document.createElement('div');
775        qrUpdate.classList.add('qr_update');
776        qrUpdate.title = Dict.termcap('QR_UPDATE_DESCRIPCION');
777
778        const syncIcon = document.createElement('i');
779        syncIcon.classList.add('far', 'fa-sync-alt', 'icon');
780        qrUpdate.appendChild(syncIcon);
781
782        this.qr_update = qrUpdate;
783
784        this.qr_data.appendChild(qrUpdate);
785        this.qr_update.addEventListener('click', this.getQRAccesoTemporal.bind(this));
786    }
787
788    /**
789     * Verifica periodicamente si el usuario se logueo desde la app
790     * Cierra el modal automaticamente cuando detecta el login
791     */
792    checkAppLogin() {
793        fetch(url_dir + 'qr.cgi', {
794            method: 'POST',
795            body: Utils.prepareFormData({
796                'wAccion': 'check_app_login',
797                'temporal_access': this.temporal_access
798            })
799        }).then(response => {
800            if (!response.ok) {
801                throw new Error("request Failed");
802            }
803            return response.text();
804        }).then((responseText) => {
805            const json = JSON.parse(responseText);
806            if (this.showingQR) {
807                if (json['data'] && json['data']['logged']) {
808                    this.hideQRModal();
809                } else {
810                    setTimeout(this.checkAppLogin.bind(this), 5000);
811                }
812            }
813        }).catch((e) => {
814            // Error checking app login silently handled
815        });
816    }
817
818    /**
819     * Establece el breadcrumb extendido en la topbar.
820     * Generico: reutilizable en cualquier contexto.
821     * Los elementos se agregan directamente al nav (sin container),
822     * marcados con data-breadcrumb-ext para poder limpiarlos.
823     *
824     * NOTA: los href de links se asignan directamente al atributo href del <a>.
825     * Deben ser rutas relativas al aula (ej. "?id_curso=123") o absolutas con
826     * protocolo (ej. "https://..."). Sin protocolo, el browser los resuelve
827     * como relativos al path actual del CGI.
828     *
829     * @param {Array<{href: string, title: string, text: string}>} links
830     * @param {string} currentText - Texto nivel actual
831     * @param {string} currentTitle - Title nivel actual
832     */
833    setTopbarBreadcrumb({ links = [], currentText = '', currentTitle = '' }) {
834        const nav = document.querySelector('.topbar-breadcrumb');
835        if (!nav) return;
836
837        this.clearTopbarBreadcrumb();
838
839        links.forEach((link) => {
840            nav.appendChild(this.#createBcSeparator());
841
842            const anchor = document.createElement('a');
843            anchor.href = link.href;
844            anchor.title = link.title || '';
845            anchor.className = 'breadcrumb-item';
846            anchor.dataset.isBreadcrumbExtend = 'true';
847            anchor.textContent = link.text;
848            nav.appendChild(anchor);
849        });
850
851        if (currentText) {
852            var sep = document.createElement('i');
853            sep.className = 'breadcrumb-separator fas fa-chevron-right';
854            nav.appendChild(sep);
855
856            const span = document.createElement('span');
857            span.className = 'breadcrumb-item breadcrumb-current';
858            span.title = currentTitle || '';
859            span.textContent = currentText;
860            nav.appendChild(span);
861        }
862
863        // Refresh breadcrumb collapse
864        const bcNav = document.querySelector('.topbar-breadcrumb');
865        if (bcNav && bcNav._eduBreadcrumb) {
866            bcNav._eduBreadcrumb.refresh();
867        }
868    }
869
870    /**
871     * Actualiza solo el nivel hoja del breadcrumb extendido existente.
872     *
873     * @param {string} currentText - Nuevo texto
874     * @param {string} currentTitle - Nuevo title
875     */
876    updateTopbarBreadcrumbLeaf({ currentText = '', currentTitle = '' }) {
877        const current = document.querySelector('.topbar-breadcrumb .breadcrumb-current');
878        if (!current) return;
879        current.textContent = currentText;
880        current.title = currentTitle;
881
882        // Refresh breadcrumb collapse
883        const bcNav = document.querySelector('.topbar-breadcrumb');
884        if (bcNav && bcNav._eduBreadcrumb) {
885            bcNav._eduBreadcrumb.refresh();
886        }
887    }
888
889    /**
890     * Remueve el breadcrumb extendido de la topbar.
891     */
892    clearTopbarBreadcrumb() {
893        document.querySelectorAll('.topbar-breadcrumb [data-is-breadcrumb-extend="true"]')
894            .forEach((el) => el.remove());
895
896        // Refresh breadcrumb collapse
897        const bcNav = document.querySelector('.topbar-breadcrumb');
898        if (bcNav && bcNav._eduBreadcrumb) {
899            bcNav._eduBreadcrumb.refresh();
900        }
901    }
902
903    /**
904     * Crea un separador chevron para el breadcrumb.
905     * @returns {HTMLElement}
906     */
907    #createBcSeparator() {
908        const sep = document.createElement('i');
909        sep.className = 'breadcrumb-separator fas fa-chevron-right';
910        sep.dataset.isBreadcrumbExtend = 'true';
911        return sep;
912    }
913}
914
915/**
916 * Clase MensajesNoLeidos - Popup de mensajes no leidos con paginador
917 */
918export class MensajesNoLeidos {
919    constructor(args) {
920        // Propiedades de instancia
921        this.id_abr = 'MC';
922        this.id = 'Correo';
923        this.className = 'mensajes_function';
924        this.lock = false;
925
926        // Merge args
927        Object.assign(this, args);
928
929        // Calcular milisegundos de hoy para comparaciones de fecha
930        const hoy = new Date();
931        this.mil_hoy = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate()).getTime();
932
933        this.order_option = {};
934        this.cursos_option = {};
935        this.title = Dict.termcap('CORREO_NO_LEIDO');
936
937        // Headers para el paginador
938        this.headers = [
939            {
940                id: 'fecha',
941                tipo: 'date',
942                sort: 1,
943                nombre: Dict.termcap('FECHA')
944            },
945            {
946                id: 'asunto',
947                tipo: 'string',
948                sort: 1,
949                nombre: Dict.termcap('ASUNTO')
950            },
951            {
952                id: 'remitente',
953                tipo: 'string',
954                sort: 1,
955                nombre: Dict.termcap('REMITENTE')
956            },
957            {
958                id: 'grupo',
959                tipo: 'string',
960                sort: 1,
961                nombre: Dict.termcap('GRUPO')
962            }
963        ];
964
965        // Inicializar paginador
966        this.pg = new Paginador({
967            id: 'pg_' + this.id_abr,
968            source_url: url_dir + 'mensajes_no_leidos.cgi',
969            source_params: {
970                wAccion: 'modulo_correo'
971            },
972            page_size: this.page_size,
973            total_registros: this.total_registros,
974            headers: this.headers,
975            order: this.order,
976            order_asc: this.order_asc,
977            filter: this.filter,
978            keywords: this.keywords
979        });
980
981        // Observers del paginador
982        this.pg.observe('beforeUpdate', this.onBeforeUpdatePaginado.bind(this));
983        this.pg.observe('update', this.onUpdatePaginado.bind(this));
984
985        // Crear estructura HTML
986        this.createMenuBox();
987
988        // Limpiar el container (remueve el spinner)
989        this.container.innerHTML = '';
990        this.container.appendChild(this.box);
991
992        // Layout basico
993        this.correos_container = document.createElement('div');
994        this.box_body.appendChild(this.correos_container);
995
996        this.paginador_container = document.createElement('div');
997        this.box_footer.appendChild(this.paginador_container);
998
999        // Renderizar paginador
1000        this.renderPaginador();
1001
1002        // Cargar primera pagina
1003        this.pg.setPagina({
1004            total_registros: this.total_registros,
1005            page: 1,
1006            order: this.order,
1007            order_asc: this.order_asc,
1008            filter: this.filter,
1009            keywords: this.keywords
1010        }, this.items);
1011    }
1012
1013    /**
1014     * Helper para capitalizar primera letra
1015     */
1016    capitalize(str) {
1017        if (!str) return '';
1018        return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
1019    }
1020
1021    /**
1022     * Helper para escapar HTML
1023     */
1024    escapeHTML(str) {
1025        const div = document.createElement('div');
1026        div.textContent = str;
1027        return div.innerHTML;
1028    }
1029
1030    /**
1031     * Callback antes de actualizar paginado (muestra spinner)
1032     */
1033    onBeforeUpdatePaginado() {
1034        this.lock = true;
1035        if (!this.box_body.classList.contains('el_loading')) {
1036            this.box_body.classList.add('el_loading');
1037        }
1038    }
1039
1040    /**
1041     * Callback despues de actualizar paginado
1042     */
1043    onUpdatePaginado() {
1044        if (this.box_body.classList.contains('el_loading')) {
1045            this.box_body.classList.remove('el_loading');
1046        }
1047        this.items = this.pg.getData();
1048
1049        if (this.items.length === 0) {
1050            this.sinCorreos();
1051            this.lock = false;
1052            return;
1053        }
1054
1055        this.updateMainBody();
1056        this.lock = false;
1057    }
1058
1059    /**
1060     * Muestra mensaje cuando no hay correos
1061     */
1062    sinCorreos() {
1063        const mensajeVacio = document.createElement('div');
1064        mensajeVacio.className = 'seccion_vacia';
1065        mensajeVacio.textContent = Dict.termcap('NO_EXISTEN_MENSAJES_NO_LEIDOS');
1066        this.correos_container.innerHTML = '';
1067        this.correos_container.appendChild(mensajeVacio);
1068    }
1069
1070    /**
1071     * Actualiza el body principal con la lista de mensajes
1072     */
1073    updateMainBody() {
1074        const ul = document.createElement('ul');
1075        ul.className = 'lista_mensajes';
1076
1077        for (let i = 0; i < this.items.length; i++) {
1078            const item = this.items[i];
1079            const fecha = Utils.parseDate(item.fecha);
1080            const textoFecha = this.capitalize(this.formatDate(fecha));
1081
1082            const li = document.createElement('li');
1083
1084            const link = document.createElement('a');
1085            link.href = url_dir + 'webmail.cgi?wAccion=VerEmail&wIdBandeja=Inbox&wIdEmail=' +
1086                item.id + '&id_curso=' + item.id_curso;
1087
1088            // Subject con adjunto opcional
1089            const subjectDiv = document.createElement('div');
1090            subjectDiv.className = 'subject linea_unica';
1091
1092            if (item.adjunto) {
1093                const adjuntoIcon = document.createElement('i');
1094                adjuntoIcon.className = 'fal fa-paperclip adjunto';
1095                subjectDiv.appendChild(adjuntoIcon);
1096            }
1097
1098            const subjectText = document.createTextNode(item.asunto);
1099            subjectDiv.appendChild(subjectText);
1100            link.appendChild(subjectDiv);
1101
1102            // From + Aula
1103            const fromDiv = document.createElement('div');
1104            fromDiv.className = 'linea_unica';
1105
1106            const fromSpan = document.createElement('span');
1107            fromSpan.className = 'from';
1108            fromSpan.textContent = item.nombre;
1109            fromDiv.appendChild(fromSpan);
1110
1111            const aulaSpan = document.createElement('span');
1112            aulaSpan.className = 'aula';
1113            aulaSpan.innerHTML = ' - ' + this.escapeHTML(item.nombre_curso);
1114            fromDiv.appendChild(aulaSpan);
1115            link.appendChild(fromDiv);
1116
1117            // Fecha
1118            const dateDiv = document.createElement('div');
1119            dateDiv.className = 'date linea_unica';
1120            dateDiv.textContent = textoFecha;
1121            link.appendChild(dateDiv);
1122
1123            li.appendChild(link);
1124            ul.appendChild(li);
1125        }
1126
1127        this.correos_container.innerHTML = '';
1128        this.correos_container.appendChild(ul);
1129    }
1130
1131    /**
1132     * Renderiza los controles del paginador
1133     */
1134    renderPaginador() {
1135        this.pg_view = new PaginadorVista({
1136            paginador: this.pg,
1137            target: this.id + '-pg',
1138            resumen_pie_txt: Dict.term('CANT_MENSAJES'),
1139            resumen_pie_txt_singular: Dict.term('CANT_MENSAJE')
1140        });
1141
1142        // Limpiar contenedor
1143        this.paginador_container.innerHTML = '';
1144
1145        const control = this.pg_view.renderPageControl('reducido');
1146        if (control) {
1147            this.paginador_container.appendChild(control);
1148        }
1149    }
1150
1151    /**
1152     * Crea la estructura HTML del menu box
1153     */
1154    createMenuBox() {
1155        // Header
1156        this.box_header = document.createElement('header');
1157        this.box_header.className = 'mensajes_header';
1158
1159        const h2 = document.createElement('h2');
1160        h2.className = 'mensajes_titulo';
1161        h2.textContent = this.title;
1162        this.box_header.appendChild(h2);
1163
1164        // Boton cerrar
1165        this.cerrar = document.createElement('button');
1166        this.cerrar.type = 'button';
1167        this.cerrar.className = 'cerrar_bt';
1168
1169        const cerrarIcon = document.createElement('i');
1170        cerrarIcon.className = 'cerrar fas fa-times';
1171        this.cerrar.appendChild(cerrarIcon);
1172
1173        this.cerrar.addEventListener('click', (e) => {
1174            if (this.fbar && this.fbar.hideMensajeNoLeidos) {
1175                this.fbar.hideMensajeNoLeidos(e);
1176            }
1177        });
1178
1179        this.box_header.appendChild(this.cerrar);
1180
1181        // Body
1182        this.box_body = document.createElement('div');
1183        this.box_body.className = 'body';
1184
1185        // Footer
1186        this.box_footer = document.createElement('footer');
1187
1188        // Box principal
1189        this.box = document.createElement('section');
1190        this.box.className = this.className;
1191        this.box.id = this.id;
1192        this.box.appendChild(this.box_header);
1193        this.box.appendChild(this.box_body);
1194        this.box.appendChild(this.box_footer);
1195    }
1196
1197    /**
1198     * Formatea una fecha de forma amigable
1199     */
1200    formatDate(fecha) {
1201        const milFecha = new Date(fecha.getFullYear(), fecha.getMonth(), fecha.getDate()).getTime();
1202        const dif = (this.mil_hoy - milFecha) / (1000 * 60 * 60 * 24);
1203        const showAnio = new Date().getFullYear() !== fecha.getFullYear();
1204
1205        if (dif < 1) {
1206            return Dict.termlc('HOY') + ', ' + Utils.dateFormatTime(fecha) + ' hs';
1207        } else if (dif >= 1 && dif < 2) {
1208            return Dict.term('AYER') + ', ' + Utils.dateFormatTime(fecha) + ' hs';
1209        } else {
1210            let termino = '';
1211            const values = {
1212                dia: fecha.getDate(),
1213                mes: Utils.dateFormatMonthName(fecha),
1214                hora: Utils.dateFormatTime(fecha)
1215            };
1216
1217            if (showAnio) {
1218                termino = 'DIA_DE_MES_DE_ANIO_HORA';
1219                values.anio = fecha.getFullYear();
1220            } else {
1221                termino = 'DIA_DE_MES_HORA';
1222            }
1223
1224            return Dict.interpolate(Dict.term(termino), values);
1225        }
1226    }
1227}
1228
1229if (typeof window !== 'undefined') {
1230    if (!window.Educativa) window.Educativa = {};
1231    if (!window.Educativa.Aula) window.Educativa.Aula = {};
1232    window.Educativa.Aula.MensajesNoLeidos = MensajesNoLeidos;
1233}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.