main
js 427 lines 17.3 KB
Raw
1 /**
2 * MAPS Connect - Layout compartido (menú lateral plegable)
3 */
4
5 const NAV_ITEMS = [
6 { href: 'inicio.html', label: 'Inicio' },
7 { href: 'perfil.html', label: 'Mi Perfil' },
8 { href: 'certificados.html', label: 'Mi Ruta MAPS' },
9 { href: 'comunidad.html', label: 'Comunidad y Recursos' },
10 { href: 'mensajes.html', label: 'Mensajes' },
11 { href: 'empresarial.html', label: 'Semestre Empresarial' },
12 { href: 'circulos.html', label: 'Círculos de Estudio' },
13 { href: 'ajustes.html', label: 'Ajustes' }
14 ];
15
16 /**
17 * Nombres personalizados del menú según el skin activo (data-estilo).
18 * Los textos provienen de los prototipos entregados (mockups).
19 */
20 const ETIQUETAS_ESTILO = {
21 saiyan: {
22 'inicio.html': '01. Gran Dojo (Inicio)',
23 'perfil.html': '02. Mi Perfil Saiyan',
24 'certificados.html': '03. Ruta de Combate',
25 'comunidad.html': '04. Comunidad Z-Fighters',
26 'mensajes.html': '05. Telepatía / Chat',
27 'empresarial.html': '06. Torneo Empresarial',
28 'circulos.html': '07. Círculos de Ki',
29 'ajustes.html': '08. Ajustes de Ki',
30 'admin.html': '09. Mando Superior (Admin)',
31 'docente.html': '09. Dojo Docente'
32 },
33 medieval: {
34 'inicio.html': 'La Gran Aula (Inicio)',
35 'perfil.html': 'Mi Pergamino (Perfil)',
36 'certificados.html': 'Mapa del Hechizo (Ruta)',
37 'comunidad.html': 'La Cámara de Sabios',
38 'mensajes.html': 'Búhos y Pergaminos',
39 'empresarial.html': 'Cámara del Tesoro',
40 'circulos.html': 'Círculos de Magia',
41 'ajustes.html': 'Cámara de los Consejos',
42 'admin.html': 'Consejo de Administración',
43 'docente.html': 'Gran Maestro (Docente)'
44 },
45 noir: {
46 'inicio.html': '01. Comandancia (Inicio)',
47 'perfil.html': '02. Expediente Personal',
48 'certificados.html': '03. Pistas MAPS',
49 'comunidad.html': '04. Archivo Central',
50 'mensajes.html': '05. Notas Cifradas',
51 'empresarial.html': '06. Sindicatos y Alianzas',
52 'circulos.html': '07. Rondas de Estudio',
53 'ajustes.html': '08. Ajustes de Caso',
54 'admin.html': '09. Mando Superior (Admin)',
55 'docente.html': '09. Comisaría Docente'
56 },
57 alchemy: {
58 'inicio.html': '01. Círculo (Inicio)',
59 'perfil.html': '02. Sello Personal',
60 'certificados.html': '03. Leyes MAPS',
61 'comunidad.html': '04. Códice Global',
62 'mensajes.html': '05. Runas de Enlace',
63 'empresarial.html': '06. Gremios Transmutadores',
64 'circulos.html': '07. Círculos Esotéricos',
65 'ajustes.html': '08. Sello y Ajustes',
66 'admin.html': '09. Bóveda Alquímica (Admin)',
67 'docente.html': '09. Gremio Docente'
68 },
69 minimal: {
70 'inicio.html': '01. Panel Principal',
71 'perfil.html': '02. Ficha Personal',
72 'certificados.html': '03. Ruta Académica',
73 'comunidad.html': '04. Red Global',
74 'mensajes.html': '05. Mensajería',
75 'empresarial.html': '06. Alianzas',
76 'circulos.html': '07. Círculos de Estudio',
77 'ajustes.html': '08. Configuración',
78 'admin.html': '09. Control Institucional',
79 'docente.html': '09. Asesoría Docente'
80 },
81 invernadero: {
82 'inicio.html': 'Invernadero (Inicio)',
83 'perfil.html': 'Mi Semillero (Perfil)',
84 'certificados.html': 'Ruta de Cosecha (MAPS)',
85 'comunidad.html': 'Comunidad de Cultivo',
86 'mensajes.html': 'Búsquedas y Pollen',
87 'empresarial.html': 'Invernadero Empresarial',
88 'circulos.html': 'Círculos de Siembra',
89 'ajustes.html': 'Riego y Ajustes',
90 'admin.html': 'Rama de Administración',
91 'docente.html': 'Brote Docente'
92 }
93 };
94
95 const Layout = {
96 async init(currentPage) {
97 if (!Auth.requireAuth()) return;
98 if (await Auth.redirectIfPerfilIncompleto()) return;
99
100 this.paginaActual = currentPage;
101 this.renderSidebar(currentPage);
102 this.renderTopbar();
103 this.initNotificaciones();
104 this.bindToggle();
105 this.bindGlobalSearch();
106 // Re-renderiza el menú al cambiar de skin (ajustes) sin recargar.
107 document.addEventListener('estilos:cambio', () => this.renderSidebar(this.paginaActual));
108 },
109
110 /** Etiqueta del item según el skin activo; cae al nombre base si no aplica. */
111 _etiqueta(item) {
112 const estilo = document.documentElement.getAttribute('data-estilo') || 'clasico';
113 const tabla = ETIQUETAS_ESTILO[estilo] || {};
114 return tabla[item.href] || item.label;
115 },
116
117 renderSidebar(currentPage) {
118 const sidebar = document.getElementById('sidebar');
119 if (!sidebar) return;
120
121 const user = Auth.getUser();
122 const items = [...NAV_ITEMS];
123 const rol = String(user?.rol || '').toUpperCase();
124 if (rol === 'ADMINISTRADOR') {
125 items.push({ href: 'admin.html', label: 'Panel Administrativo' });
126 } else if (rol === 'PROFESOR') {
127 items.push({ href: 'docente.html', label: 'Gestión Docente' });
128 }
129 const navLinks = items.map(item => {
130 const active = item.href === currentPage ? 'active' : '';
131 return `<li><a href="${item.href}" class="${active}">${Utils.esc(this._etiqueta(item))}</a></li>`;
132 }).join('');
133
134 sidebar.innerHTML = `
135 <div class="sidebar-header">
136 <h1>MAPS Connect</h1>
137 <div class="sidebar-user">
138 ${Utils.avatarHtml(user?.nombre, user?.foto, 'avatar sidebar-avatar', user?.nombre || 'Usuario')}
139 <small>${user?.nombre || 'Usuario'}</small>
140 </div>
141 </div>
142 <ul class="sidebar-nav">${navLinks}</ul>
143 <div class="sidebar-footer">
144 <button class="btn btn-sm btn-outline" id="btn-logout">Cerrar sesión</button>
145 </div>
146 `;
147
148 document.getElementById('btn-logout')?.addEventListener('click', () => Auth.logout());
149 },
150
151 renderTopbar() {
152 const topbar = document.getElementById('topbar');
153 if (!topbar) return;
154
155 topbar.innerHTML = `
156 <button class="menu-backdrop" id="menu-backdrop" type="button" aria-label="Cerrar menú"></button>
157 <button class="menu-toggle" id="sidebar-toggle" type="button"
158 aria-label="Abrir menú" aria-controls="sidebar" aria-expanded="false">
159 <span aria-hidden="true">☰</span>
160 </button>
161 <div class="global-search" role="search">
162 <input class="global-search-input" id="global-search" type="search"
163 placeholder="Buscar personas..." aria-label="Buscar personas" autocomplete="off">
164 <div class="global-search-dropdown" id="global-search-dd" hidden></div>
165 </div>
166 <span id="topbar-title" class="visually-hidden"></span>
167 <div class="notif-bell" id="notif-bell">
168 <button type="button" class="notif-toggle" id="notif-toggle" aria-label="Notificaciones" aria-expanded="false">
169 <span aria-hidden="true">🔔</span>
170 <span class="notif-badge" id="notif-badge" hidden>0</span>
171 </button>
172 <div class="notif-panel" id="notif-panel" hidden>
173 <div class="notif-panel-head">
174 <strong>Notificaciones</strong>
175 <button type="button" class="notif-marcar-todas" id="notif-marcar-todas">Marcar todas leídas</button>
176 </div>
177 <div class="notif-list" id="notif-list"></div>
178 </div>
179 </div>
180 `;
181 },
182
183 /**
184 * Buscador global de personas: consulta GET /usuarios/buscar?q= y despliega
185 * coincidencias para ir al perfil o abrir un chat desde cualquier página.
186 */
187 bindGlobalSearch() {
188 const input = document.getElementById('global-search');
189 const dd = document.getElementById('global-search-dd');
190 if (!input || !dd) return;
191
192 let timer = null;
193 input.addEventListener('input', () => {
194 clearTimeout(timer);
195 const q = input.value.trim();
196 if (q.length < 2) {
197 dd.hidden = true;
198 dd.innerHTML = '';
199 return;
200 }
201 timer = setTimeout(() => this.buscarGlobal(q, dd), 250);
202 });
203
204 input.addEventListener('focus', () => {
205 if (input.value.trim().length >= 2 && dd.innerHTML) dd.hidden = false;
206 });
207
208 input.addEventListener('keydown', e => {
209 if (e.key === 'Escape') {
210 dd.hidden = true;
211 input.blur();
212 }
213 });
214
215 document.addEventListener('click', e => {
216 if (!e.target.closest('.global-search')) dd.hidden = true;
217 });
218 },
219
220 async buscarGlobal(q, dd) {
221 let res;
222 try {
223 res = await API.request(`/usuarios/buscar?q=${encodeURIComponent(q)}`);
224 } catch (e) {
225 dd.hidden = true;
226 return;
227 }
228
229 const personas = Array.isArray(res) ? res : [];
230 if (!personas.length) {
231 dd.innerHTML = '<div class="global-search-empty">Sin coincidencias</div>';
232 dd.hidden = false;
233 return;
234 }
235
236 dd.innerHTML = personas.map(p => {
237 const esDocente = p.rol === 'PROFESOR';
238 const sub = esDocente
239 ? 'Docente'
240 : [p.carrera, p.semestre ? `${p.semestre}° Semestre` : ''].filter(Boolean).join('');
241 return `
242 <div class="global-search-item" data-href="usuario.html?id=${encodeURIComponent(p.id)}" data-id="${Utils.esc(p.id)}">
243 ${Utils.avatarHtml(p.nombre, p.foto, 'avatar', p.nombre)}
244 <div class="global-search-info">
245 <strong>${Utils.esc(p.nombre)}</strong>
246 <span>${Utils.esc(sub)}</span>
247 </div>
248 <button class="global-search-chat" type="button" aria-label="Abrir chat">Chat</button>
249 </div>`;
250 }).join('');
251 dd.hidden = false;
252
253 dd.querySelectorAll('.global-search-item').forEach(item => {
254 item.addEventListener('click', e => {
255 if (e.target.closest('.global-search-chat')) {
256 const id = item.dataset.id;
257 API.request(`/mensajes/nuevo/${encodeURIComponent(id)}`, { method: 'POST' })
258 .then(() => { window.location.href = 'mensajes.html'; })
259 .catch(() => { window.location.href = 'mensajes.html'; });
260 return;
261 }
262 window.location.href = item.dataset.href;
263 });
264 });
265 },
266
267 bindToggle() {
268 const toggle = document.getElementById('sidebar-toggle');
269 const sidebar = document.getElementById('sidebar');
270 const layout = document.querySelector('.app-layout');
271 const backdrop = document.getElementById('menu-backdrop');
272
273 const setMenuState = isOpen => {
274 sidebar?.classList.toggle('open', isOpen);
275 layout?.classList.toggle('menu-open', isOpen);
276 toggle.setAttribute('aria-expanded', String(isOpen));
277 toggle.setAttribute('aria-label', isOpen ? 'Cerrar menú' : 'Abrir menú');
278 };
279
280 toggle?.addEventListener('click', () => setMenuState(!sidebar?.classList.contains('open')));
281 backdrop?.addEventListener('click', () => setMenuState(false));
282 document.addEventListener('keydown', event => {
283 if (event.key === 'Escape' && sidebar?.classList.contains('open')) setMenuState(false);
284 });
285 },
286
287 setPageTitle(title) {
288 const el = document.getElementById('topbar-title');
289 if (el) el.textContent = title;
290 document.title = `${title} | MAPS Connect`;
291 },
292
293 // ---------- Notificaciones (campana global) ----------
294
295 initNotificaciones() {
296 const bell = document.getElementById('notif-bell');
297 if (!bell) return;
298
299 this.notif = this.notif || { refreshedAt: 0, panelAbierto: false, eventSource: null };
300
301 document.getElementById('notif-toggle')?.addEventListener('click', e => {
302 e.stopPropagation();
303 this.notif.panelAbierto = !this.notif.panelAbierto;
304 document.getElementById('notif-panel').hidden = !this.notif.panelAbierto;
305 document.getElementById('notif-toggle').setAttribute('aria-expanded', String(this.notif.panelAbierto));
306 if (this.notif.panelAbierto) this.refreshNotificaciones();
307 });
308
309 document.addEventListener('click', e => {
310 if (this.notif.panelAbierto && !e.target.closest('#notif-bell')) {
311 this.notif.panelAbierto = false;
312 document.getElementById('notif-panel').hidden = true;
313 document.getElementById('notif-toggle').setAttribute('aria-expanded', 'false');
314 }
315 });
316
317 document.getElementById('notif-marcar-todas')?.addEventListener('click', async () => {
318 try {
319 await API.request('/notificaciones/marcar-todas', { method: 'POST' });
320 } catch (e) {
321 // Se ignora; el siguiente refresco regulara el badge.
322 }
323 this.refreshNotificaciones();
324 });
325
326 // Canal SSE en vivo: toast + refresco inmediato al llegar una notificación.
327 const token = Auth.getToken();
328 if (token) {
329 const source = new EventSource(`${API_BASE_URL}/notificaciones/stream?token=${encodeURIComponent(token)}`);
330 source.addEventListener('mensaje', e => {
331 try {
332 const dato = JSON.parse(e.data);
333 this.notifRecibida(dato);
334 } catch (err) {
335 // Evento malformado; se ignora.
336 }
337 });
338 ['foro', 'duda', 'sesion', 'asesoria', 'general'].forEach(tipo => {
339 source.addEventListener(tipo, e => {
340 try {
341 const dato = JSON.parse(e.data);
342 this.notifRecibida(dato);
343 } catch (err) {
344 // Evento malformado; se ignora.
345 }
346 });
347 });
348 this.notif.eventSource = source;
349 }
350
351 this.refreshNotificaciones();
352 setInterval(() => this.refreshNotificaciones(), 5000);
353 },
354
355 notifRecibida(notif) {
356 if (!notif || !notif.tipo) return;
357
358 // En la página de mensajes el chat abierto ya se actualiza por su propio stream;
359 // no mostramos toast para no duplicar.
360 const enMensajes = window.location.pathname.includes('mensajes.html');
361
362 if (notif.tipo === 'mensaje') {
363 if (!enMensajes) {
364 const nombre = notif.emisorNombre || 'Alguien';
365 const texto = (notif.mensaje && (notif.mensaje.texto || notif.mensaje.contenido)) || '';
366 const preview = texto ? `: ${texto}` : ' te envió un adjunto';
367 Utils.toast(`Nuevo mensaje de ${nombre}${preview}`, 'info');
368 }
369 } else {
370 // Otras áreas: foro, dudas, sesiones, asesorías, etc.
371 const etiquetas = {
372 foro: 'Foro',
373 duda: 'Duda',
374 sesion: 'Sesión',
375 asesoria: 'Asesoría',
376 general: 'Notificación'
377 };
378 const etiqueta = etiquetas[notif.tipo] || 'Notificación';
379 const preview = notif.preview ? `: ${notif.preview}` : '';
380 Utils.toast(`${etiqueta}${notif.titulo || 'Novedad'}${preview}`, 'info');
381 }
382 this.refreshNotificaciones();
383 },
384
385 /** Recarga badge + panel. Útil también para llamarlo desde otras páginas. */
386 async refreshNotificaciones() {
387 const badge = document.getElementById('notif-badge');
388 const list = document.getElementById('notif-list');
389 if (!badge || !list) return;
390
391 let resumen;
392 try {
393 resumen = await API.request('/notificaciones/resumen');
394 } catch (e) {
395 return;
396 }
397
398 const noLeidos = Number(resumen?.noLeidos) || 0;
399 if (noLeidos > 0) {
400 badge.hidden = false;
401 badge.textContent = noLeidos > 99 ? '99+' : String(noLeidos);
402 } else {
403 badge.hidden = true;
404 }
405
406 const items = Array.isArray(resumen?.items) ? resumen.items : [];
407 if (!items.length) {
408 list.innerHTML = '<div class="notif-vacio">Sin notificaciones.</div>';
409 return;
410 }
411
412 list.innerHTML = items.map(item => {
413 const unread = Number(item.noLeidos) || 0;
414 const sub = [unread > 0 ? `${unread} nuevo${unread === 1 ? '' : 's'}` : 'Sin novedades', item.tiempo].filter(Boolean).join('');
415 return `
416 <a class="notif-item" href="${Utils.esc(item.enlace || 'pages/mensajes.html')}">
417 ${Utils.avatarHtml(item.nombre, item.foto, 'avatar' + (item.esProf ? ' prof' : ''), item.nombre)}
418 <div class="notif-item-info">
419 <strong>${Utils.esc(item.nombre)}</strong>
420 <span class="notif-preview">${Utils.esc(item.preview || '')}</span>
421 <span>${Utils.esc(sub)}</span>
422 </div>
423 ${unread > 0 ? `<span class="notif-item-badge">${unread}</span>` : ''}
424 </a>`;
425 }).join('');
426 }
427 };