942 lines
31 KiB
JavaScript
942 lines
31 KiB
JavaScript
|
|
var CourseNav = (function (COURSE_CONFIG) {
|
|||
|
|
"use strict";
|
|||
|
|
|
|||
|
|
/* ==========================================================================
|
|||
|
|
* === 1. Inyección y ejecución de scripts de contenido dinámico ============
|
|||
|
|
* ========================================================================== */
|
|||
|
|
const loadedScriptSrcs = new Set();
|
|||
|
|
|
|||
|
|
function executeInjectedScripts(container) {
|
|||
|
|
// 1) Scripts externos (src)
|
|||
|
|
container.querySelectorAll("script[src]").forEach((old) => {
|
|||
|
|
const src = old.src;
|
|||
|
|
if (!loadedScriptSrcs.has(src)) {
|
|||
|
|
loadedScriptSrcs.add(src);
|
|||
|
|
const s = document.createElement("script");
|
|||
|
|
s.src = src;
|
|||
|
|
s.async = false;
|
|||
|
|
document.body.appendChild(s);
|
|||
|
|
s.addEventListener("load", () => s.remove());
|
|||
|
|
}
|
|||
|
|
old.remove();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 2) Scripts inline
|
|||
|
|
container.querySelectorAll("script:not([src])").forEach((old) => {
|
|||
|
|
try {
|
|||
|
|
(0, eval)(old.textContent);
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error("Error al ejecutar script inline:", e);
|
|||
|
|
}
|
|||
|
|
old.remove();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==========================================================================
|
|||
|
|
* === 2. Extensión de Howl para eventos de tiempo ===========================
|
|||
|
|
* ========================================================================== */
|
|||
|
|
class ExtendedHowl extends Howl {
|
|||
|
|
constructor(options) {
|
|||
|
|
super(options);
|
|||
|
|
this._timeupdateListeners = [];
|
|||
|
|
this._interval = null;
|
|||
|
|
this._startTimeUpdate();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_startTimeUpdate() {
|
|||
|
|
this._interval = setInterval(() => {
|
|||
|
|
if (this.playing()) this._emitTimeUpdate(this.seek());
|
|||
|
|
}, 250);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_emitTimeUpdate(currentTime) {
|
|||
|
|
this._timeupdateListeners.forEach((cb) => cb(currentTime));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onTimeUpdate(cb) {
|
|||
|
|
this._timeupdateListeners.push(cb);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
offTimeUpdate(cb) {
|
|||
|
|
this._timeupdateListeners = this._timeupdateListeners.filter((l) => l !== cb);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
off(event, fn, id) {
|
|||
|
|
// Cuando se llama a off() sin argumentos, limpia todos los listeners.
|
|||
|
|
// Lo extendemos para que también limpie nuestros listeners de timeupdate.
|
|||
|
|
if (arguments.length === 0) {
|
|||
|
|
this._timeupdateListeners = [];
|
|||
|
|
}
|
|||
|
|
// Llama al método off() del padre
|
|||
|
|
return super.off(event, fn, id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
play(id) {
|
|||
|
|
const result = super.play(id);
|
|||
|
|
if (!this._interval) this._startTimeUpdate();
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pause(id) {
|
|||
|
|
const result = super.pause(id);
|
|||
|
|
clearInterval(this._interval);
|
|||
|
|
this._interval = null;
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
stop(id) {
|
|||
|
|
super.stop(id);
|
|||
|
|
clearInterval(this._interval);
|
|||
|
|
this._interval = null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function createSound(audioUrl) {
|
|||
|
|
return audioUrl ? new ExtendedHowl({ src: [audioUrl] }) : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==========================================================================
|
|||
|
|
* === 3. Controlador de audio ===============================================
|
|||
|
|
* ========================================================================== */
|
|||
|
|
class AudioController {
|
|||
|
|
constructor() {
|
|||
|
|
this.audioElement = null;
|
|||
|
|
this.audioControlButton = document.getElementById("coursenav-audio-control");
|
|||
|
|
this.audioIcon = document.getElementById("coursenav-audio-icon");
|
|||
|
|
this.progressCircle = document.getElementById("coursenav-progress-circle");
|
|||
|
|
this.isMuted = false;
|
|||
|
|
if (this.progressCircle) this.progressCircle.style.display = "none";
|
|||
|
|
if (this.audioControlButton) {
|
|||
|
|
this.audioControlButton.addEventListener("click", this.toggleAudio.bind(this));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
stopAllSoundsAndPlay(howl) {
|
|||
|
|
Howler._howls?.forEach((sound) => {
|
|||
|
|
// Detener y remover eventos de todos los sonidos EXCEPTO el que se va a reproducir.
|
|||
|
|
if (sound !== howl) {
|
|||
|
|
sound.stop();
|
|||
|
|
//sound.off();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
this.updateIcon();
|
|||
|
|
this.setAudio(howl);
|
|||
|
|
this.playAudio();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
loadAudio(url) {
|
|||
|
|
if (this.audioElement) this.audioElement.stop();
|
|||
|
|
if (!url) return;
|
|||
|
|
this.audioElement = createSound(url);
|
|||
|
|
this._bindAudioEvents();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
playAudio() {
|
|||
|
|
this.audioElement?.play();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pauseAudio() {
|
|||
|
|
this.audioElement?.pause();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
stopAudio() {
|
|||
|
|
this.audioElement?.stop();
|
|||
|
|
if (this.progressCircle) this.progressCircle.style.display = "none";
|
|||
|
|
this.updateIconVolume();
|
|||
|
|
this.audioElement = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
toggleAudio() {
|
|||
|
|
if (!this.audioElement) return;
|
|||
|
|
this.audioElement.playing() ? this.toggleMute() : this.playAudio();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
toggleMute() {
|
|||
|
|
this.isMuted = !this.isMuted;
|
|||
|
|
Howler.mute(this.isMuted);
|
|||
|
|
this.updateIconVolume();
|
|||
|
|
document.querySelectorAll("video").forEach((v) => (v.muted = this.isMuted));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onPlay() {
|
|||
|
|
if (this.progressCircle) this.progressCircle.style.display = "block";
|
|||
|
|
const t = this.audioElement.seek();
|
|||
|
|
this.updateProgressCircle(t);
|
|||
|
|
this.updateIconVolume();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onEnd() {
|
|||
|
|
if (this.progressCircle) this.progressCircle.style.display = "none";
|
|||
|
|
this.updateIcon();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
updateIcon() {
|
|||
|
|
if (!this.audioIcon) return;
|
|||
|
|
const playing = this.audioElement?.playing();
|
|||
|
|
this.audioIcon.className = playing ? "fa-duotone fa-solid fa-pause" : "fa-duotone fa-solid fa-play";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
updateIconVolume() {
|
|||
|
|
if (!this.audioIcon) return;
|
|||
|
|
this.audioIcon.className = this.isMuted ? "fa-duotone fa-solid fa-volume-slash" : "fa-duotone fa-solid fa-volume";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
updateProgressCircle(currentTime) {
|
|||
|
|
if (!this.progressCircle || !this.audioElement) return;
|
|||
|
|
const r = parseFloat(this.progressCircle.getAttribute("r"));
|
|||
|
|
const circ = 2 * Math.PI * r;
|
|||
|
|
const offset = circ - (currentTime / this.audioElement.duration()) * circ;
|
|||
|
|
this.progressCircle.setAttribute("stroke-dashoffset", offset);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setAudioUrl(url) {
|
|||
|
|
this.loadAudio(url);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setAudio(howl) {
|
|||
|
|
if (!(howl instanceof ExtendedHowl)) return;
|
|||
|
|
this.audioElement?.stop();
|
|||
|
|
this.audioElement = howl;
|
|||
|
|
this._bindAudioEvents();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_bindAudioEvents() {
|
|||
|
|
this.audioElement.on("play", this.onPlay.bind(this));
|
|||
|
|
this.audioElement.on("pause", this.updateIcon.bind(this));
|
|||
|
|
this.audioElement.on("stop", this.updateIcon.bind(this));
|
|||
|
|
this.audioElement.on("end", this.onEnd.bind(this));
|
|||
|
|
this.audioElement.onTimeUpdate(this.updateProgressCircle.bind(this));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const audioController = new AudioController();
|
|||
|
|
|
|||
|
|
/* ==========================================================================
|
|||
|
|
* === 4. Configuración global y constantes =================================
|
|||
|
|
* ========================================================================== */
|
|||
|
|
const COURSE_CONFIG_URL = COURSE_CONFIG.COURSE_CONFIG_URL || "config.json";
|
|||
|
|
const DEBUG = COURSE_CONFIG.DEBUG || false;
|
|||
|
|
const KEY_APP = COURSE_CONFIG.KEY || Infinity;
|
|||
|
|
const MAIN_CONTENT = document.getElementById("coursenav-main-content");
|
|||
|
|
const LOADER_ELEMENT = document.getElementById("coursenav-loader-course");
|
|||
|
|
const CLICK_SOUND = new Audio("audio/click.mp3");
|
|||
|
|
const PREV_BTN = document.getElementById("coursenav-prev-btn");
|
|||
|
|
const NEXT_BTN = document.getElementById("coursenav-next-btn");
|
|||
|
|
const COURSE_PROGRESS_BAR = document.getElementById("coursenav-progress-bar");
|
|||
|
|
const COURSE_MENU = document.getElementById("coursenav-main-menu");
|
|||
|
|
const BODY_CLASSES = [...document.body.classList];
|
|||
|
|
pipwerks.SCORM.version = "1.2";
|
|||
|
|
pipwerks.debug.isActive = DEBUG;
|
|||
|
|
pipwerks.SCORM.handleExitMode = false;
|
|||
|
|
|
|||
|
|
let sessionStartTime;
|
|||
|
|
let scormAPIUnloaded = false;
|
|||
|
|
let courseStructure = null;
|
|||
|
|
let courseData = { contentArray: [], maximumAdvance: 0 };
|
|||
|
|
let currentIndex = 0;
|
|||
|
|
|
|||
|
|
/* ==========================================================================
|
|||
|
|
* === 5. SCORM: Inicialización y helpers de alto nivel =====================
|
|||
|
|
* ========================================================================== */
|
|||
|
|
function initializeScorm(callback) {
|
|||
|
|
const scorm = pipwerks.SCORM;
|
|||
|
|
const connected = scorm.init();
|
|||
|
|
if (connected) {
|
|||
|
|
const status = scorm.get("cmi.core.lesson_status");
|
|||
|
|
if (status === "not attempted") {
|
|||
|
|
scorm.set("cmi.core.lesson_status", "incomplete");
|
|||
|
|
scorm.save();
|
|||
|
|
}
|
|||
|
|
sessionStartTime = Date.now();
|
|||
|
|
} else {
|
|||
|
|
console.warn("SCORM API no encontrada. Usando sessionStorage.");
|
|||
|
|
}
|
|||
|
|
callback(connected);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildContentArray(mods, courseTitle = "", moduleTitle = "", parentTitle = null) {
|
|||
|
|
mods.forEach((m) => {
|
|||
|
|
const isModuleLevel = !parentTitle && !moduleTitle;
|
|||
|
|
const currentModuleTitle = isModuleLevel ? m.title : moduleTitle;
|
|||
|
|
|
|||
|
|
if (m.content) {
|
|||
|
|
courseData.contentArray.push({
|
|||
|
|
title: m.title,
|
|||
|
|
content: m.content,
|
|||
|
|
visited: false,
|
|||
|
|
showAnimation: true,
|
|||
|
|
courseTitle,
|
|||
|
|
moduleTitle: currentModuleTitle,
|
|||
|
|
parentTitle,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
if (m.topics) {
|
|||
|
|
buildContentArray(m.topics, courseTitle, currentModuleTitle, m.title);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function verifyContentArray(saved, curr) {
|
|||
|
|
if (!saved || !curr || saved.length !== curr.length) return false;
|
|||
|
|
return saved.every((s, i) => s.title === curr[i].title && s.content === curr[i].content);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function loadConfig() {
|
|||
|
|
const xhr = new XMLHttpRequest();
|
|||
|
|
xhr.open("GET", `${COURSE_CONFIG_URL}?_=${Date.now()}`, true);
|
|||
|
|
xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
|
|||
|
|
xhr.withCredentials = true;
|
|||
|
|
xhr.responseType = "json";
|
|||
|
|
|
|||
|
|
xhr.onload = function () {
|
|||
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|||
|
|
courseStructure = xhr.response;
|
|||
|
|
courseData = { contentArray: [], maximumAdvance: 0 };
|
|||
|
|
if (courseStructure.title) document.title = courseStructure.title;
|
|||
|
|
|
|||
|
|
buildContentArray(courseStructure.modules, courseStructure.title || "");
|
|||
|
|
|
|||
|
|
const saved = getProgress();
|
|||
|
|
if (saved.contentArray && verifyContentArray(saved.contentArray, courseData.contentArray)) {
|
|||
|
|
courseData = saved;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (courseData.maximumAdvance > 0) showStartOptions();
|
|||
|
|
else initializeCourse();
|
|||
|
|
|
|||
|
|
setProgress(courseData);
|
|||
|
|
} else {
|
|||
|
|
MAIN_CONTENT?.remove();
|
|||
|
|
console.error("Error cargando config:", xhr.status, xhr.statusText);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
xhr.onerror = function () {
|
|||
|
|
MAIN_CONTENT?.remove();
|
|||
|
|
console.error("Error de red al cargar config");
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
xhr.send();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function initializeCourse() {
|
|||
|
|
if (COURSE_MENU) {
|
|||
|
|
buildMenu();
|
|||
|
|
hideDuplicateLinks();
|
|||
|
|
}
|
|||
|
|
if (courseData.contentArray.length > 0) loadContent();
|
|||
|
|
else MAIN_CONTENT.innerHTML = `<div class='alert alert-warning'>No hay contenido.</div>`;
|
|||
|
|
setupNavigation();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showStartOptions() {
|
|||
|
|
if (typeof Swal === "undefined") {
|
|||
|
|
currentIndex = confirm("¿Retomar tu progreso?") ? courseData.maximumAdvance : 0;
|
|||
|
|
initializeCourse();
|
|||
|
|
} else {
|
|||
|
|
Swal.fire({
|
|||
|
|
title: "¿Dónde quieres empezar?",
|
|||
|
|
text: "Retomar o comenzar de nuevo",
|
|||
|
|
icon: "question",
|
|||
|
|
showCancelButton: true,
|
|||
|
|
confirmButtonText: "Retomar",
|
|||
|
|
cancelButtonText: "Comenzar",
|
|||
|
|
target: MAIN_CONTENT,
|
|||
|
|
customClass: {
|
|||
|
|
confirmButton: "btn btn-primary text-white",
|
|||
|
|
cancelButton: "btn btn-secondary text-white",
|
|||
|
|
},
|
|||
|
|
}).then((res) => {
|
|||
|
|
currentIndex = res.isConfirmed ? courseData.maximumAdvance : 0;
|
|||
|
|
initializeCourse();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==========================================================================
|
|||
|
|
* === 6. Construcción y manejo del menú ====================================
|
|||
|
|
* ========================================================================== */
|
|||
|
|
function buildMenu() {
|
|||
|
|
COURSE_MENU.innerHTML = "";
|
|||
|
|
(courseStructure.modules || []).forEach((module) => {
|
|||
|
|
const ul = document.createElement("ul");
|
|||
|
|
ul.classList.add("course-menu");
|
|||
|
|
ul.appendChild(createMenuItem(module));
|
|||
|
|
COURSE_MENU.appendChild(ul);
|
|||
|
|
});
|
|||
|
|
hideDuplicateLinks();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function createMenuItem(item) {
|
|||
|
|
const li = document.createElement("li");
|
|||
|
|
li.classList.add("menu-item");
|
|||
|
|
|
|||
|
|
const wdiv = document.createElement("div");
|
|||
|
|
wdiv.classList.add("witem");
|
|||
|
|
li.appendChild(wdiv);
|
|||
|
|
|
|||
|
|
const link = document.createElement("a");
|
|||
|
|
link.classList.add("coursenav-link");
|
|||
|
|
link.innerHTML = item.title;
|
|||
|
|
|
|||
|
|
const idx = courseData.contentArray.findIndex((c) => c.content === item.content && c.title === item.title);
|
|||
|
|
link.dataset.coursenavindex = idx;
|
|||
|
|
link.dataset.coursenavvisited = idx >= 0 && courseData.contentArray[idx].visited;
|
|||
|
|
wdiv.appendChild(link);
|
|||
|
|
|
|||
|
|
link.addEventListener("click", () => {
|
|||
|
|
CLICK_SOUND.play();
|
|||
|
|
const index = parseInt(link.dataset.coursenavindex, 10);
|
|||
|
|
if (index >= 0) {
|
|||
|
|
if (DEBUG || courseData.contentArray[index].visited) {
|
|||
|
|
currentIndex = index;
|
|||
|
|
closeSidebar();
|
|||
|
|
loadContent();
|
|||
|
|
} else {
|
|||
|
|
closeSidebar();
|
|||
|
|
showLockedContentWarning();
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
const toggle = wdiv.querySelector(".toggle-icon");
|
|||
|
|
toggle && toggle.click();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (item.topics?.length) {
|
|||
|
|
const toggle = document.createElement("span");
|
|||
|
|
toggle.classList.add("toggle-icon");
|
|||
|
|
toggle.innerHTML = '<i class="fa-duotone fa-solid fa-square-chevron-down"></i>';
|
|||
|
|
wdiv.appendChild(toggle);
|
|||
|
|
|
|||
|
|
const subUl = document.createElement("ul");
|
|||
|
|
subUl.classList.add("sub-ul", "open");
|
|||
|
|
item.topics.forEach((sub) => subUl.appendChild(createMenuItem(sub)));
|
|||
|
|
li.appendChild(subUl);
|
|||
|
|
|
|||
|
|
toggle.addEventListener("click", () => {
|
|||
|
|
CLICK_SOUND.play();
|
|||
|
|
const isOpen = subUl.classList.toggle("open");
|
|||
|
|
const icon = toggle.querySelector("i");
|
|||
|
|
icon.classList.toggle("fa-square-chevron-down", isOpen);
|
|||
|
|
icon.classList.toggle("fa-square-chevron-right", !isOpen);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return li;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hideDuplicateLinks() {
|
|||
|
|
function processUl(ul) {
|
|||
|
|
const seen = new Set();
|
|||
|
|
Array.from(ul.children)
|
|||
|
|
.filter((el) => el.tagName === "LI")
|
|||
|
|
.forEach((li) => {
|
|||
|
|
const link = li.querySelector(":scope > .witem > .coursenav-link");
|
|||
|
|
if (link) {
|
|||
|
|
const text = link.textContent.trim();
|
|||
|
|
if (seen.has(text)) li.style.display = "none";
|
|||
|
|
else seen.add(text);
|
|||
|
|
}
|
|||
|
|
li.querySelectorAll(":scope > ul").forEach(processUl);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
document.querySelectorAll("#coursenav-main-menu > ul.course-menu").forEach(processUl);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function closeSidebar() {
|
|||
|
|
const offEl = document.getElementById("coursenav-offcanvas");
|
|||
|
|
const bsOff = bootstrap.Offcanvas.getInstance(offEl) || new bootstrap.Offcanvas(offEl);
|
|||
|
|
bsOff.hide();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Abre el offcanvas de navegación (o cualquier sidebar) usando la API de Bootstrap.
|
|||
|
|
*/
|
|||
|
|
function openSidebar() {
|
|||
|
|
const offEl = document.getElementById("coursenav-offcanvas");
|
|||
|
|
if (offEl) {
|
|||
|
|
// Obtén la instancia de Bootstrap Offcanvas o créala si no existe
|
|||
|
|
const bsOff = bootstrap.Offcanvas.getInstance(offEl) || new bootstrap.Offcanvas(offEl);
|
|||
|
|
bsOff.show();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showLockedContentWarning() {
|
|||
|
|
if (typeof Swal === "undefined") {
|
|||
|
|
alert("Debes completar el contenido actual antes de avanzar.");
|
|||
|
|
} else {
|
|||
|
|
Swal.fire({
|
|||
|
|
text: "Debes completar el contenido actual antes de avanzar.",
|
|||
|
|
icon: "warning",
|
|||
|
|
target: MAIN_CONTENT,
|
|||
|
|
customClass: {
|
|||
|
|
confirmButton: "btn btn-primary text-white",
|
|||
|
|
cancelButton: "btn btn-warning text-white",
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==========================================================================
|
|||
|
|
* === 7. Carga de contenido y actualización de la interfaz ================
|
|||
|
|
* ========================================================================== */
|
|||
|
|
function loadContent() {
|
|||
|
|
document.body.className = ""; // Limpiar primero
|
|||
|
|
BODY_CLASSES.forEach((clase) => document.body.classList.add(clase));
|
|||
|
|
MAIN_CONTENT.innerHTML = "";
|
|||
|
|
window.scrollTo(0, 0);
|
|||
|
|
LOADER_ELEMENT.style.display = "block";
|
|||
|
|
|
|||
|
|
if (NEXT_BTN) NEXT_BTN.classList.remove('look_at_me');
|
|||
|
|
|
|||
|
|
const item = courseData.contentArray[currentIndex];
|
|||
|
|
if (!item?.content) return console.warn("Ítem inválido:", item);
|
|||
|
|
audioController.stopAudio();
|
|||
|
|
audioController.audioElement?.off();
|
|||
|
|
Howler._howls?.forEach((h) => h.stop());
|
|||
|
|
|
|||
|
|
// Detener todas las animaciones de GSAP para evitar que se ejecuten en segundo plano
|
|||
|
|
if (window.gsap) {
|
|||
|
|
// gsap.killAll() es para miembros del Club GreenSock.
|
|||
|
|
// Si no está disponible, usamos un método alternativo para la versión gratuita.
|
|||
|
|
if (typeof gsap.killAll === "function") {
|
|||
|
|
gsap.killAll();
|
|||
|
|
} else {
|
|||
|
|
gsap.killTweensOf(gsap.utils.toArray("*"));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (Swal.isVisible()) {
|
|||
|
|
Swal.close();
|
|||
|
|
}
|
|||
|
|
// Al cerrar, limpiamos clases y atributos de html/body
|
|||
|
|
document.documentElement.classList.remove("swal2-shown", "swal2-height-auto");
|
|||
|
|
document.body.classList.remove("swal2-shown", "swal2-height-auto");
|
|||
|
|
document.documentElement.removeAttribute("aria-hidden");
|
|||
|
|
document.body.removeAttribute("aria-hidden");
|
|||
|
|
// Y volvemos a quitar aria-hidden de los scripts
|
|||
|
|
document.querySelectorAll("script[aria-hidden]").forEach((el) => el.removeAttribute("aria-hidden"));
|
|||
|
|
document.querySelectorAll("[aria-hidden]").forEach((el) => el.removeAttribute("aria-hidden"));
|
|||
|
|
fetch(item.content, { cache: "no-store" })
|
|||
|
|
.then((r) => {
|
|||
|
|
if (!r.ok) throw new Error(r.statusText);
|
|||
|
|
return r.text();
|
|||
|
|
})
|
|||
|
|
.then((html) => {
|
|||
|
|
//MAIN_CONTENT.innerHTML = html;
|
|||
|
|
$(MAIN_CONTENT).html(html);
|
|||
|
|
//executeInjectedScripts(MAIN_CONTENT);
|
|||
|
|
})
|
|||
|
|
.catch((err) => {
|
|||
|
|
console.error("Error cargando contenido:", err);
|
|||
|
|
MAIN_CONTENT.innerHTML = `<pre>${err.message}</pre>`;
|
|||
|
|
})
|
|||
|
|
.finally(() => {
|
|||
|
|
courseData.maximumAdvance = Math.max(courseData.maximumAdvance, currentIndex);
|
|||
|
|
LOADER_ELEMENT.style.display = "none";
|
|||
|
|
updateUITemplate();
|
|||
|
|
triggerSlideChange(currentIndex, courseData.contentArray);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateUITemplate() {
|
|||
|
|
setProgress(courseData);
|
|||
|
|
updateNavigationButtons();
|
|||
|
|
updateProgressBar();
|
|||
|
|
updateCourseNavLinks();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateCourseNavLinks() {
|
|||
|
|
document.querySelectorAll(".coursenav-link").forEach((link) => {
|
|||
|
|
const idx = parseInt(link.dataset.coursenavindex, 10);
|
|||
|
|
const item = courseData.contentArray[idx];
|
|||
|
|
if (item) {
|
|||
|
|
link.dataset.coursenavvisited = item.visited;
|
|||
|
|
item.visited ? link.classList.add("visited") : link.classList.remove("visited");
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setupNavigation() {
|
|||
|
|
PREV_BTN?.addEventListener("click", () => {
|
|||
|
|
//CLICK_SOUND.play();
|
|||
|
|
navigate(-1);
|
|||
|
|
});
|
|||
|
|
NEXT_BTN?.addEventListener("click", () => {
|
|||
|
|
//CLICK_SOUND.play();
|
|||
|
|
navigate(1);
|
|||
|
|
});
|
|||
|
|
updateNavigationButtons();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function navigate(dir) {
|
|||
|
|
triggerBeforeSlideChange(currentIndex, courseData.contentArray);
|
|||
|
|
if (NEXT_BTN) NEXT_BTN.classList.remove('look_at_me');
|
|||
|
|
const newIndex = currentIndex + dir;
|
|||
|
|
if (newIndex < 0 || newIndex >= courseData.contentArray.length) return;
|
|||
|
|
|
|||
|
|
if (dir === -1 || courseData.contentArray[currentIndex].visited || DEBUG) {
|
|||
|
|
currentIndex = newIndex;
|
|||
|
|
loadContent();
|
|||
|
|
} else {
|
|||
|
|
showLockedContentWarning();
|
|||
|
|
}
|
|||
|
|
updateNavigationButtons();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateNavigationButtons() {
|
|||
|
|
if (!courseData.contentArray.length || !courseData.contentArray[currentIndex]) {
|
|||
|
|
PREV_BTN.disabled = NEXT_BTN.disabled = true;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
PREV_BTN.disabled = currentIndex === 0;
|
|||
|
|
NEXT_BTN.disabled = currentIndex >= courseData.contentArray.length - 1 || (!courseData.contentArray[currentIndex].visited && !DEBUG);
|
|||
|
|
|
|||
|
|
if (NEXT_BTN) {
|
|||
|
|
const isLastSlide = currentIndex >= courseData.contentArray.length - 1;
|
|||
|
|
const currentSlide = courseData.contentArray[currentIndex];
|
|||
|
|
|
|||
|
|
// Mostrar animación solo si showAnimation es true y no es el último slide
|
|||
|
|
if (!isLastSlide && currentSlide.showAnimation && currentSlide.visited) {
|
|||
|
|
NEXT_BTN.classList.add('look_at_me');
|
|||
|
|
currentSlide.showAnimation = false; // Marcar para no mostrar nuevamente
|
|||
|
|
setProgress(courseData); // Guardar el estado
|
|||
|
|
} else {
|
|||
|
|
NEXT_BTN.classList.remove('look_at_me');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateProgressBar() {
|
|||
|
|
const visited = courseData.contentArray.filter((i) => i.visited).length;
|
|||
|
|
const pct = (visited / courseData.contentArray.length) * 100;
|
|||
|
|
COURSE_PROGRESS_BAR.style.width = pct + "%";
|
|||
|
|
COURSE_PROGRESS_BAR.setAttribute("aria-valuenow", pct.toFixed(2));
|
|||
|
|
COURSE_PROGRESS_BAR.textContent = `${pct.toFixed(0)}%`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==========================================================================
|
|||
|
|
* === 8. Eventos custom =====================================================
|
|||
|
|
* ========================================================================== */
|
|||
|
|
function triggerSlideChange(currentIndex, contentArray) {
|
|||
|
|
document.body.dispatchEvent(
|
|||
|
|
new CustomEvent("slideChange", {
|
|||
|
|
detail: { message: "Slide changed!", slideIndex: currentIndex, contentArray },
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function triggerSlideCompleted(index, total, slideObj) {
|
|||
|
|
document.body.dispatchEvent(
|
|||
|
|
new CustomEvent("slideCompleted", {
|
|||
|
|
detail: { message: "Slide completed!", slideIndex: index, totalSlides: total, slide: slideObj },
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function triggerBeforeSlideChange(currentIndex, contentArray) {
|
|||
|
|
document.body.dispatchEvent(
|
|||
|
|
new CustomEvent("beforeSlideChange", {
|
|||
|
|
detail: { message: "Before slide change!", currentIndex, contentArray },
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==========================================================================
|
|||
|
|
* === 9. API públicas y utilitarios =========================================
|
|||
|
|
* ========================================================================== */
|
|||
|
|
function setSlideVisited(state = true) {
|
|||
|
|
courseData.contentArray[currentIndex].visited = state;
|
|||
|
|
updateUITemplate();
|
|||
|
|
triggerSlideCompleted(currentIndex, courseData.contentArray.length, courseData.contentArray[currentIndex]);
|
|||
|
|
// Agregar o remover la clase look_at_me al botón next
|
|||
|
|
const nextBtn = document.getElementById('coursenav-next-btn');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function markSlidesAsVisited(indices) {
|
|||
|
|
indices
|
|||
|
|
.sort((a, b) => b - a)
|
|||
|
|
.forEach((i) => {
|
|||
|
|
currentIndex = i;
|
|||
|
|
setSlideVisited(true);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resetCourse() {
|
|||
|
|
// 1. Resetear el estado local
|
|||
|
|
courseData.contentArray.forEach(i => {
|
|||
|
|
i.visited = false;
|
|||
|
|
i.showAnimation = true;
|
|||
|
|
});
|
|||
|
|
courseData.maximumAdvance = 0;
|
|||
|
|
currentIndex = 0;
|
|||
|
|
|
|||
|
|
// 2. Borrar todo el estado persistente
|
|||
|
|
if (pipwerks.SCORM.connection.isActive) {
|
|||
|
|
pipwerks.SCORM.set("cmi.core.lesson_status", "incomplete");
|
|||
|
|
pipwerks.SCORM.set("cmi.core.lesson_location", "0"); // <- Esto es lo clave
|
|||
|
|
pipwerks.SCORM.set("cmi.core.score.raw", "");
|
|||
|
|
pipwerks.SCORM.set("cmi.suspend_data", "");
|
|||
|
|
pipwerks.SCORM.save();
|
|||
|
|
} else {
|
|||
|
|
sessionStorage.removeItem("cmi.core.lesson_status");
|
|||
|
|
sessionStorage.setItem("cmi.core.lesson_location", "0"); // <- Esto es lo clave
|
|||
|
|
sessionStorage.removeItem("cmi.core.score.raw");
|
|||
|
|
sessionStorage.removeItem("cmi.suspend_data");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. Forzar recarga limpia
|
|||
|
|
setProgress(courseData);
|
|||
|
|
loadContent();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function soundClick() {
|
|||
|
|
CLICK_SOUND.play();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isDebug() {
|
|||
|
|
return DEBUG;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function gotoSlide(index) {
|
|||
|
|
const i = Math.floor(index);
|
|||
|
|
if (!isNaN(i) && i >= 0 && i < courseData.contentArray.length) {
|
|||
|
|
currentIndex = i;
|
|||
|
|
loadContent();
|
|||
|
|
} else {
|
|||
|
|
console.error("gotoSlide: índice inválido", index);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==========================================================================
|
|||
|
|
* === 10. SCORM: helpers de bajo nivel y progreso ============================
|
|||
|
|
* ========================================================================== */
|
|||
|
|
function getLessonLocation() {
|
|||
|
|
if (pipwerks.SCORM.connection.isActive) {
|
|||
|
|
const val = pipwerks.SCORM.get("cmi.core.lesson_location");
|
|||
|
|
return val ?? "";
|
|||
|
|
}
|
|||
|
|
return sessionStorage.getItem("cmi.core.lesson_location") ?? "";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setLessonLocation(loc) {
|
|||
|
|
if (pipwerks.SCORM.connection.isActive) {
|
|||
|
|
const ok = pipwerks.SCORM.set("cmi.core.lesson_location", loc);
|
|||
|
|
if (ok) pipwerks.SCORM.save();
|
|||
|
|
return ok;
|
|||
|
|
}
|
|||
|
|
sessionStorage.setItem("cmi.core.lesson_location", loc);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getLessonStatus() {
|
|||
|
|
if (pipwerks.SCORM.connection.isActive) {
|
|||
|
|
return pipwerks.SCORM.get("cmi.core.lesson_status") ?? "";
|
|||
|
|
}
|
|||
|
|
return sessionStorage.getItem("cmi.core.lesson_status") ?? "";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setLessonStatus(st) {
|
|||
|
|
if (pipwerks.SCORM.connection.isActive) {
|
|||
|
|
const ok = pipwerks.SCORM.set("cmi.core.lesson_status", st);
|
|||
|
|
if (ok) pipwerks.SCORM.save();
|
|||
|
|
return ok;
|
|||
|
|
}
|
|||
|
|
sessionStorage.setItem("cmi.core.lesson_status", st);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getScore() {
|
|||
|
|
let val = pipwerks.SCORM.connection.isActive ? pipwerks.SCORM.get("cmi.core.score.raw") : sessionStorage.getItem("cmi.core.score.raw");
|
|||
|
|
return val != null && val !== "" ? Number(val) : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setScore(sc) {
|
|||
|
|
if (pipwerks.SCORM.connection.isActive) {
|
|||
|
|
const ok = pipwerks.SCORM.set("cmi.core.score.raw", sc);
|
|||
|
|
if (ok) pipwerks.SCORM.save();
|
|||
|
|
return ok;
|
|||
|
|
}
|
|||
|
|
sessionStorage.setItem("cmi.core.score.raw", sc);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getSuspendData() {
|
|||
|
|
let val = pipwerks.SCORM.connection.isActive ? pipwerks.SCORM.get("cmi.suspend_data") : sessionStorage.getItem("cmi.suspend_data");
|
|||
|
|
if (val) {
|
|||
|
|
try {
|
|||
|
|
return JSON.parse(val);
|
|||
|
|
} catch {
|
|||
|
|
return val;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return "";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setSuspendData(data) {
|
|||
|
|
const json = JSON.stringify(data);
|
|||
|
|
if (pipwerks.SCORM.connection.isActive) {
|
|||
|
|
const ok = pipwerks.SCORM.set("cmi.suspend_data", json);
|
|||
|
|
if (ok) pipwerks.SCORM.save();
|
|||
|
|
return ok;
|
|||
|
|
}
|
|||
|
|
sessionStorage.setItem("cmi.suspend_data", json);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getProgressPercent(byModule = false) {
|
|||
|
|
if (!byModule) {
|
|||
|
|
const visited = courseData.contentArray.filter((i) => i.visited).length;
|
|||
|
|
return parseFloat(((visited / courseData.contentArray.length) * 100).toFixed(2));
|
|||
|
|
}
|
|||
|
|
const currentSlide = courseData.contentArray[currentIndex];
|
|||
|
|
const moduleSlides = courseData.contentArray.filter((s) => s.moduleTitle === currentSlide.moduleTitle);
|
|||
|
|
const visited = moduleSlides.filter((i) => i.visited).length;
|
|||
|
|
return moduleSlides.length ? parseFloat(((visited / moduleSlides.length) * 100).toFixed(2)) : 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Obtiene el porcentaje de avance de cada módulo.
|
|||
|
|
* @returns {Object<string, number>} Un objeto con
|
|||
|
|
* { "Título de módulo": porcentaje (0–100), … }
|
|||
|
|
*/
|
|||
|
|
function getProgressByModule() {
|
|||
|
|
// Recolectamos totales y visitados por módulo
|
|||
|
|
const stats = {};
|
|||
|
|
courseData.contentArray.forEach((slide) => {
|
|||
|
|
const mod = slide.moduleTitle || "Sin módulo";
|
|||
|
|
if (!stats[mod]) stats[mod] = { total: 0, visited: 0 };
|
|||
|
|
stats[mod].total++;
|
|||
|
|
if (slide.visited) stats[mod].visited++;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Calculamos porcentajes
|
|||
|
|
const result = {};
|
|||
|
|
Object.entries(stats).forEach(([mod, { total, visited }]) => {
|
|||
|
|
result[mod] = parseFloat(((visited / total) * 100).toFixed(2));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setProgress(p) {
|
|||
|
|
setSuspendData(p);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getProgress() {
|
|||
|
|
return getSuspendData() || { contentArray: [], maximumAdvance: 0 };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function finishScorm() {
|
|||
|
|
if (pipwerks.SCORM.connection.isActive && !scormAPIUnloaded) {
|
|||
|
|
const elapsed = (Date.now() - sessionStartTime) / 1000;
|
|||
|
|
const hh = String(Math.floor(elapsed / 3600)).padStart(2, "0");
|
|||
|
|
const mm = String(Math.floor((elapsed % 3600) / 60)).padStart(2, "0");
|
|||
|
|
const ss = String(Math.floor(elapsed % 60)).padStart(2, "0");
|
|||
|
|
pipwerks.SCORM.set("cmi.core.session_time", `${hh}:${mm}:${ss}`);
|
|||
|
|
pipwerks.SCORM.save();
|
|||
|
|
pipwerks.SCORM.quit();
|
|||
|
|
scormAPIUnloaded = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getScormData(key) {
|
|||
|
|
return pipwerks.SCORM.connection.isActive ? pipwerks.SCORM.get(key) ?? "" : sessionStorage.getItem(key) ?? "";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setScormData(key, value) {
|
|||
|
|
if (pipwerks.SCORM.connection.isActive) {
|
|||
|
|
const ok = pipwerks.SCORM.set(key, value);
|
|||
|
|
if (ok) pipwerks.SCORM.save();
|
|||
|
|
return ok;
|
|||
|
|
}
|
|||
|
|
sessionStorage.setItem(key, value);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==========================================================================
|
|||
|
|
* === 11. Arranque DOM y offcanvas =========================================
|
|||
|
|
* ========================================================================== */
|
|||
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|||
|
|
initializeScorm(() => loadConfig());
|
|||
|
|
window.addEventListener("beforeunload", finishScorm);
|
|||
|
|
// Tooltips
|
|||
|
|
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
|||
|
|
tooltipTriggerList.map((el) => new bootstrap.Tooltip(el));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/* ==========================================================================
|
|||
|
|
* === 12. API pública =======================================================
|
|||
|
|
* ========================================================================== */
|
|||
|
|
return {
|
|||
|
|
/* Audio */
|
|||
|
|
audioController,
|
|||
|
|
createSound,
|
|||
|
|
soundClick,
|
|||
|
|
|
|||
|
|
/* Debug */
|
|||
|
|
isDebug,
|
|||
|
|
|
|||
|
|
/* SCORM Básico */
|
|||
|
|
getStudentName: () => getScormData("cmi.core.student_name"),
|
|||
|
|
getLessonLocation,
|
|||
|
|
setLessonLocation,
|
|||
|
|
getLessonStatus,
|
|||
|
|
setLessonStatus,
|
|||
|
|
getScore,
|
|||
|
|
setScore,
|
|||
|
|
getSuspendData,
|
|||
|
|
setSuspendData,
|
|||
|
|
getScormData,
|
|||
|
|
setScormData,
|
|||
|
|
|
|||
|
|
/* Navegación */
|
|||
|
|
nextSlide: () => navigate(1),
|
|||
|
|
prevSlide: () => navigate(-1),
|
|||
|
|
gotoSlide,
|
|||
|
|
openSidebar,
|
|||
|
|
closeSidebar,
|
|||
|
|
isVisited: () => courseData.contentArray[currentIndex]?.visited || false,
|
|||
|
|
isCompletedSlideIndex: (idx) => (idx >= 0 && idx < courseData.contentArray.length ? courseData.contentArray[idx].visited : undefined),
|
|||
|
|
|
|||
|
|
/* Estado del curso */
|
|||
|
|
getCurrentSlide: () => courseData.contentArray[currentIndex],
|
|||
|
|
getCurrentIndex: () => currentIndex,
|
|||
|
|
getCourseData: () => courseData,
|
|||
|
|
getCourseStructure: () => courseStructure,
|
|||
|
|
getCourseConfig: () => COURSE_CONFIG,
|
|||
|
|
getCourseTitle: () => courseStructure?.title || "",
|
|||
|
|
getCourseModules: () => courseStructure?.modules || [],
|
|||
|
|
getCourseContentArray: () => courseData.contentArray,
|
|||
|
|
|
|||
|
|
/* Curso actual */
|
|||
|
|
resetCourse,
|
|||
|
|
markSlidesAsVisited,
|
|||
|
|
setSlideVisited,
|
|||
|
|
completeLesson: () => setLessonStatus("completed"),
|
|||
|
|
updateProgressBar,
|
|||
|
|
getProgressPercent,
|
|||
|
|
getProgressByModule,
|
|||
|
|
getCurrentModuleSlides: () => {
|
|||
|
|
const module = courseData.contentArray[currentIndex]?.moduleTitle;
|
|||
|
|
return courseData.contentArray.filter((s) => s.moduleTitle === module);
|
|||
|
|
},
|
|||
|
|
getCurrentModuleTitle: () => courseData.contentArray[currentIndex]?.moduleTitle || "",
|
|||
|
|
getCurrentCourseTitle: () => courseData.contentArray[currentIndex]?.courseTitle || "",
|
|||
|
|
|
|||
|
|
/* SCORM avanzado */
|
|||
|
|
save: () => (pipwerks.SCORM.connection.isActive ? pipwerks.SCORM.save() : setProgress(courseData)),
|
|||
|
|
reload: loadContent,
|
|||
|
|
loadModule: (moduleTitle) => {
|
|||
|
|
const idx = courseData.contentArray.findIndex((s) => s.moduleTitle === moduleTitle);
|
|||
|
|
if (idx >= 0) {
|
|||
|
|
currentIndex = idx;
|
|||
|
|
loadContent();
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
})(COURSE_CONFIG);
|
|||
|
|
|
|||
|
|
window.CourseNav = CourseNav;
|