main
js 893 lines 36.7 KB
Raw
1 /**
2 * MAPS Connect - Comunidad y Recursos (Foro + Apuntes + Tips)
3 * Las tarjetas solo se renderizan cuando el backend devuelve datos.
4 */
5
6 const Comunidad = {
7 state: {
8 foro: [],
9 apuntes: [],
10 tips: [],
11 materiasModal: [],
12 loaded: new Set(),
13 activeTab: 'foro-dudas'
14 },
15
16 async init() {
17 Layout.init('comunidad.html');
18 Layout.setPageTitle('Comunidad y Recursos');
19
20 this.bindTabs();
21 this.bindFilters();
22 this.bindActionButton();
23 this.bindDocument();
24
25 // Permite enlazar directo a una pestaña: comunidad.html#foro-dudas
26 const hash = window.location.hash.replace('#', '');
27 const startTab = ['foro-dudas', 'apuntes', 'tips'].includes(hash) ? hash : 'foro-dudas';
28 const btn = document.querySelector(`.tab-button[data-tab="${startTab}"]`);
29 this.switchTab(startTab, btn ? btn.dataset.action : '+ Preguntar Duda');
30
31 await this.cargarMateriasSelect();
32 },
33
34 async cargarMateriasSelect() {
35 const select = document.getElementById('hub-materia');
36 let materias = [];
37 let esSemestre = false;
38 let carrera = '';
39 try {
40 const res = await API.request('/inicio');
41 const data = res && typeof res === 'object' ? res : {};
42 materias = Array.isArray(data.materias) ? data.materias : [];
43 esSemestre = materias.length > 0;
44 carrera = (data.perfil && data.perfil.carrera) ? data.perfil.carrera : '';
45 } catch (e) {
46 materias = [];
47 }
48 if (!materias.length) {
49 // Maestro o estudiante sin onboarding: usa todo el catálogo.
50 try {
51 const res = await API.request('/materias');
52 materias = Array.isArray(res) ? res : (res && Array.isArray(res.data) ? res.data : []);
53 } catch (e) {
54 materias = [];
55 }
56 }
57 this.state.materiasModal = materias;
58
59 const note = document.getElementById('career-note');
60 if (note) {
61 if (carrera) {
62 note.hidden = false;
63 note.innerHTML = `Mostrando solo contenido de tu carrera: <strong>${this.esc(carrera)}</strong>`;
64 } else {
65 note.hidden = true;
66 }
67 }
68
69 if (select) {
70 const actual = select.value;
71 const etiqueta = esSemestre ? 'Todas las materias del semestre' : 'Todas las materias';
72 select.innerHTML = `<option value="">${this.esc(etiqueta)}</option>` + materias.map(m => {
73 const nombre = this.materiaNombre(m);
74 return `<option value="${this.esc(nombre)}">${this.esc(nombre)}</option>`;
75 }).join('');
76 select.value = actual;
77 }
78
79 this.refrescarSelectsMateria();
80 },
81
82 bindTabs() {
83 const tabs = document.querySelectorAll('.tab-button');
84 tabs.forEach(btn => {
85 btn.addEventListener('click', () => {
86 this.switchTab(btn.dataset.tab, btn.dataset.action);
87 });
88 });
89 },
90
91 bindFilters() {
92 const search = document.getElementById('hub-search');
93 const materia = document.getElementById('hub-materia');
94 search?.addEventListener('input', () => this.renderActive());
95 materia?.addEventListener('change', () => this.renderActive());
96 },
97
98 bindActionButton() {
99 const btn = document.getElementById('context-action-btn');
100 btn?.addEventListener('click', () => {
101 const tabId = this.state.activeTab || 'foro-dudas';
102 this.abrirModal(tabId);
103 });
104 },
105
106 switchTab(tabId, actionText) {
107 this.state.activeTab = tabId;
108
109 document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
110 document.querySelectorAll('.tab-button').forEach(b => b.classList.remove('active'));
111
112 document.getElementById(tabId)?.classList.add('active');
113 document.querySelector(`.tab-button[data-tab="${tabId}"]`)?.classList.add('active');
114
115 const actionBtn = document.getElementById('context-action-btn');
116 if (actionBtn) actionBtn.textContent = actionText;
117
118 this.loadTab(tabId);
119 },
120
121 async loadTab(tabId) {
122 const map = {
123 'foro-dudas': {key: 'foro', loader: () => this.loadForo()},
124 'apuntes': {key: 'apuntes', loader: () => this.loadApuntes()},
125 'tips': {key: 'tips', loader: () => this.loadTips()}
126 };
127 const conf = map[tabId];
128 if (!conf) return;
129 if (this.state.loaded.has(conf.key)) {
130 this.renderActive();
131 return;
132 }
133 await conf.loader();
134 },
135
136 async loadForo() {
137 try {
138 const res = await API.request('/foro');
139 this.state.foro = this.toArray(res);
140 this.state.loaded.add('foro');
141 } catch (e) {
142 this.state.foro = [];
143 }
144 this.renderActive();
145 },
146
147 async loadApuntes() {
148 try {
149 const res = await API.request('/recursos');
150 this.state.apuntes = this.toArray(res);
151 this.state.loaded.add('apuntes');
152 } catch (e) {
153 this.state.apuntes = [];
154 }
155 this.renderActive();
156 },
157
158 async loadTips() {
159 try {
160 const res = await API.request('/tips');
161 this.state.tips = this.toArray(res);
162 this.state.loaded.add('tips');
163 } catch (e) {
164 this.state.tips = [];
165 }
166 this.renderActive();
167 },
168
169 renderActive() {
170 const q = (document.getElementById('hub-search')?.value || '').trim().toLowerCase();
171 const m = document.getElementById('hub-materia')?.value || '';
172
173 const match = item => {
174 const text = `${this.field(item, 'titulo')} ${this.field(item, 'descripcion')} ${this.field(item, 'texto')}`.toLowerCase();
175 const materia = this.materiaNombre(item).toLowerCase();
176 return (!q || text.includes(q)) && (!m || materia === m);
177 };
178
179 switch (this.state.activeTab) {
180 case 'foro-dudas':
181 this.renderForo(this.state.foro.filter(match), 'foro-dudas');
182 break;
183 case 'apuntes':
184 this.renderApuntes(this.state.apuntes.filter(match), 'apuntes');
185 break;
186 case 'tips':
187 this.renderTips(this.state.tips.filter(match), 'tips');
188 break;
189 }
190 },
191
192 bindVotes() {
193 const panel = document.getElementById('tips');
194 if (!panel) return;
195 panel.querySelectorAll('.vote-btn').forEach(btn => {
196 btn.addEventListener('click', () => this.votarTip(btn.getAttribute('data-id'), btn.getAttribute('data-votado') === 'true'));
197 });
198 },
199
200 bindDocument() {
201 document.addEventListener('click', e => {
202 const followBtn = e.target.closest('.follow-btn');
203 if (followBtn) {
204 e.preventDefault();
205 const id = followBtn.getAttribute('data-id');
206 if (id) Utils.alternarSeguir(followBtn, id);
207 return;
208 }
209
210 const downBtn = e.target.closest('.btn-solid[data-href]');
211 if (downBtn) {
212 e.preventDefault();
213 window.open(downBtn.getAttribute('data-href'), '_blank', 'noopener');
214 return;
215 }
216
217 const recursoBtn = e.target.closest('.apunte-descargar');
218 if (recursoBtn) {
219 e.preventDefault();
220 this.descargarRecurso(recursoBtn.getAttribute('data-id'), recursoBtn.getAttribute('data-nombre'));
221 return;
222 }
223
224 const foroBtn = e.target.closest('.foro-action');
225 if (foroBtn) {
226 e.preventDefault();
227 const accion = foroBtn.getAttribute('data-action');
228 if (accion === 'voto') {
229 Utils.toast('Los votos del foro se habilitan cuando el backend de publicaciones esté integrado.', 'info');
230 } else if (accion === 'guardar') {
231 Utils.toast('El guardado se habilita cuando el backend de comunidad esté integrado.', 'info');
232 } else {
233 Utils.toast('Para aportar una respuesta, abre la publicación cuando el detalle esté disponible.', 'info');
234 }
235 }
236 });
237 },
238
239 renderForo(items, panelId) {
240 const panel = document.getElementById(panelId);
241 if (!panel) return;
242 panel.innerHTML = '';
243 if (!items.length) return;
244 panel.innerHTML = items.map(item => this.foroCard(item)).join('');
245 },
246
247 renderApuntes(items, panelId) {
248 const panel = document.getElementById(panelId);
249 if (!panel) return;
250 panel.innerHTML = '';
251 if (!items.length) return;
252 panel.innerHTML = items.map(item => this.apunteCard(item)).join('');
253 },
254
255 renderTips(items, panelId) {
256 const panel = document.getElementById(panelId);
257 if (!panel) return;
258 panel.innerHTML = '';
259 if (!items.length) return;
260 panel.innerHTML = items.map(item => this.tipCard(item)).join('');
261 this.bindVotes();
262 },
263
264 foroCard(item) {
265 const titulo = this.field(item, 'titulo');
266 const desc = this.field(item, 'descripcion');
267 const materia = this.materiaNombre(item);
268 const autor = this.autorNombre(item);
269 const tiempo = this.field(item, 'tiempo') || this.field(item, 'haceTiempo') || '';
270 const votos = this.num(item, 'votos');
271 const respuestas = this.num(item, 'respuestas') || this.num(item, 'numRespuestas');
272 const resuelto = item.resuelto === true || item.resuelto === 'true';
273 const solucion = this.field(item, 'solucion');
274
275 const autorObj = item.autor && typeof item.autor === 'object' ? item.autor : null;
276 const autorId = item.autorId || (autorObj ? autorObj.id : null);
277 const autorFoto = item.autorFoto || (autorObj ? autorObj.foto : null);
278 const loSigo = item.siguiendo === true || item.siguiendo === 'true';
279 const meId = Auth.getUser() && Auth.getUser().id;
280 const clickeable = autorId && String(autorId) !== String(meId);
281 const hrefAutor = `usuario.html?id=${encodeURIComponent(autorId)}`;
282 const avatarWrap = clickeable
283 ? `<a class="user-link" href="${hrefAutor}" aria-label="Ver perfil de ${this.esc(autor)}">${Utils.avatarHtml(autor, autorFoto, 'hub-avatar', autor)}</a>`
284 : Utils.avatarHtml(autor, autorFoto, 'hub-avatar', autor);
285 const nombreWrap = clickeable
286 ? `<a class="user-link" href="${hrefAutor}"><strong>${this.esc(autor)}</strong></a>`
287 : `<strong>${this.esc(autor)}</strong>`;
288 const followBtn = (autorId && String(autorId) !== String(meId))
289 ? `<button class="follow-btn${loSigo ? ' siguiendo' : ''}" type="button" data-id="${this.esc(autorId)}" data-siguiendo="${loSigo ? 'true' : 'false'}">${loSigo ? 'Siguiendo' : 'Seguir'}</button>`
290 : '';
291
292 const badges = [
293 materia ? `<span class="hub-badge badge-materia">${this.esc(materia)}</span>` : '',
294 resuelto ? `<span class="hub-badge badge-solved">Solución Aceptada</span>` : ''
295 ].join('');
296
297 const solucionBox = (resuelto && solucion)
298 ? `<div class="solved-box"><strong>Solución validada por el autor:</strong><p>${this.esc(solucion)}</p></div>`
299 : '';
300
301 const accionRespuesta = resuelto
302 ? `<button class="btn-link foro-action" data-action="respuesta">${respuestas} Respuestas</button>`
303 : `<button class="btn-link foro-action" data-action="respuesta" style="color:#005a2b;font-weight:bold;">Aportar Respuesta</button>`;
304
305 return `
306 <article class="card">
307 <header class="post-header">
308 <div class="foro-autor">
309 ${avatarWrap}
310 <div>
311 <div class="foro-autor-line">${nombreWrap}${followBtn}</div>
312 <span class="post-meta">${tiempo ? this.esc(tiempo) : 'Recientemente'}</span>
313 </div>
314 </div>
315 <div style="display:flex;gap:6px;flex-wrap:wrap;">${badges}</div>
316 </header>
317 <h3 class="post-title">${this.esc(titulo)}</h3>
318 <p class="post-desc">${this.esc(desc)}</p>
319 ${solucionBox}
320 <footer class="post-footer">
321 <div class="post-actions">
322 <button class="btn-link foro-action" data-action="voto">${votos} Votos</button>
323 ${accionRespuesta}
324 <button class="btn-link foro-action" data-action="guardar">Guardar</button>
325 </div>
326 <span>${resuelto ? 'Última aportación reciente' : 'Esperando solución'}</span>
327 </footer>
328 </article>`;
329 },
330
331 apunteCard(item) {
332 const id = this.field(item, 'id');
333 const titulo = this.field(item, 'titulo');
334 const materia = this.materiaNombre(item);
335 const autor = this.autorNombre(item);
336 const tipo = this.field(item, 'tipo') || 'PDF';
337 const tamanoBytes = Number(item.adjuntoTamano);
338 const tamano = (item.adjuntoTamano != null && Number.isFinite(tamanoBytes))
339 ? this.formatBytes(tamanoBytes)
340 : (this.field(item, 'tamano') || this.field(item, 'tamanio') || '');
341 const descargas = this.num(item, 'descargas');
342 const rating = this.field(item, 'rating') || '';
343 const url = this.field(item, 'url') || this.field(item, 'archivoUrl') || '#';
344 const interno = item.interno === true || item.interno === 'true' || url.startsWith('archivo:');
345 const esLink = tipo.toUpperCase() === 'LINK';
346 const adjuntoNombre = this.field(item, 'adjuntoNombre');
347
348 const infoExtra = [
349 adjuntoNombre ? `Archivo: ${this.esc(adjuntoNombre)}` : '',
350 tamano ? `Tamaño: ${this.esc(tamano)}` : '',
351 `${descargas} descargas`,
352 rating ? `${this.esc(rating)}` : ''
353 ].filter(Boolean).join('');
354
355 const accion = interno
356 ? `<button class="btn-solid apunte-descargar" type="button" data-id="${this.esc(id)}" data-nombre="${this.esc(adjuntoNombre || '')}">${esLink ? 'Abrir Enlace' : 'Descargar Documento'}</button>`
357 : `<button class="btn-solid" type="button" data-href="${this.esc(url)}">${esLink ? 'Abrir Enlace' : 'Descargar Documento'}</button>`;
358
359 return `
360 <article class="card apunte-card">
361 <div class="apunte-info">
362 <div class="file-icon">${this.esc(tipo)}</div>
363 <div class="apunte-details">
364 <h4>${this.esc(titulo)}</h4>
365 <p>Materia: <strong>${this.esc(materia)}</strong> • Subido por: <strong>${this.esc(autor)}</strong></p>
366 <p style="margin-top:4px;color:#888;">${infoExtra}</p>
367 </div>
368 </div>
369 ${accion}
370 </article>`;
371 },
372
373 tipCard(item) {
374 const id = this.field(item, 'id');
375 const titulo = this.field(item, 'titulo');
376 const texto = this.field(item, 'texto') || this.field(item, 'descripcion');
377 const materia = this.materiaNombre(item);
378 const autor = this.autorNombre(item);
379 const votos = this.num(item, 'votos');
380 const verificado = item.verificado === true || item.verificado === 'true';
381 const itemVotado = item.votado === true || item.votado === 'true';
382
383 const verified = verificado
384 ? `<span class="hub-badge badge-verified">Verificado por Docente</span>`
385 : '';
386
387 return `
388 <article class="card tip-card" data-tip-id="${this.esc(id)}">
389 <div>
390 <div class="tip-head">
391 <span class="hub-badge badge-materia">${this.esc(materia)}</span>
392 ${verified}
393 </div>
394 <h4 class="tip-title">${this.esc(titulo)}</h4>
395 <p class="tip-text">${this.esc(texto)}</p>
396 </div>
397 <div class="post-footer">
398 <span>
399 <button class="vote-btn${itemVotado ? ' votado' : ''}" type="button" data-id="${this.esc(id)}" data-votado="${itemVotado}">${votos} votos</button>
400 </span>
401 <span style="font-size:0.78rem;color:#888;">Por: ${this.esc(autor)}</span>
402 </div>
403 </article>`;
404 },
405
406 // ---- modal de acción contextual (compartir tip, etc.) ----
407 async abrirModal(tabId) {
408 const modal = document.getElementById('modal-root');
409 if (!modal) return;
410
411 // Garantiza que las materias estén cargadas antes de abrir el modal.
412 await this.cargarMateriasSelect();
413
414 let body = '';
415 let titulo = 'Acción';
416 const sub = 'Completa la información solicitada.';
417 let enviarLabel = 'Publicar';
418
419 if (tabId === 'tips') {
420 titulo = 'Compartir Tip Académico';
421 body = this.modalFormTips();
422 } else if (tabId === 'foro-dudas') {
423 titulo = 'Preguntar una Duda';
424 enviarLabel = 'Publicar Duda';
425 body = [
426 '<label for="modal-du-titulo">Título de tu duda</label>',
427 '<input type="text" id="modal-du-titulo" class="input-search" maxlength="200" placeholder="Ej. Diferencia entre 1FN y 2FN">',
428 '<label for="modal-du-texto">Tu duda</label>',
429 '<textarea id="modal-du-texto" maxlength="4000" placeholder="Escribe tu duda académica de manera clara y concreta..."></textarea>',
430 '<label for="modal-du-materia">Materia</label>',
431 '<select id="modal-du-materia" class="select-materia"></select>'
432 ].join('');
433 } else {
434 titulo = 'Subir Apunte';
435 body = [
436 '<label for="modal-ap-nombre">Nombre del apunte</label>',
437 '<input type="text" id="modal-ap-nombre" class="input-search" maxlength="200" placeholder="Ej. Guía de repaso: estructuras de datos">',
438 '<label for="modal-ap-desc">Descripción (opcional)</label>',
439 '<textarea id="modal-ap-desc" maxlength="2000" placeholder="Describe brevemente el contenido del apunte..."></textarea>',
440 '<label for="modal-ap-materia">Materia</label>',
441 '<select id="modal-ap-materia" class="select-materia"></select>',
442 '<label for="modal-ap-tipo">Tipo de archivo</label>',
443 '<select id="modal-ap-tipo" class="select-materia">' +
444 '<option value="PDF">PDF</option>' +
445 '<option value="DOC">DOC / Word</option>' +
446 '<option value="PPT">Presentación</option>' +
447 '<option value="LINK">Enlace externo</option>' +
448 '</select>',
449 '<label for="modal-ap-archivo">Sube tu documento (opcional)</label>',
450 '<div class="file-pick-row">' +
451 '<input type="file" id="modal-ap-archivo" class="input-search" ' +
452 'accept=".pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.csv,.txt,.zip,.png,.jpg,.jpeg,.gif">' +
453 '<span id="modal-ap-archivo-nombre" class="file-pick-nombre"></span>' +
454 '</div>',
455 '<label for="modal-ap-url">…o enlace de descarga externo (opcional)</label>',
456 '<input type="url" id="modal-ap-url" class="input-search" maxlength="500" placeholder="https://...">'
457 ].join('');
458 }
459
460 modal.innerHTML = `
461 <div class="hub-modal-overlay" id="hub-modal">
462 <div class="hub-modal" role="dialog" aria-modal="true">
463 <h3>${this.esc(titulo)}</h3>
464 <p class="modal-sub">${this.esc(sub)}</p>
465 <form id="hub-modal-form">
466 ${body}
467 <div class="modal-msg" id="modal-msg"></div>
468 <div class="modal-actions">
469 <button class="btn-clean" type="button" id="modal-cancel">Cancelar</button>
470 <button class="btn-solid" type="submit">${this.esc(enviarLabel)}</button>
471 </div>
472 </form>
473 </div>
474 </div>`;
475
476 const overlay = document.getElementById('hub-modal');
477 overlay?.addEventListener('click', e => {
478 if (e.target === overlay) this.cerrarModal();
479 });
480 document.getElementById('modal-cancel')?.addEventListener('click', () => this.cerrarModal());
481 document.getElementById('hub-modal-form')?.addEventListener('submit', e => {
482 e.preventDefault();
483 this.enviarFormularioAccion(tabId);
484 });
485
486 this.llenarSelectMateriaModal(tabId);
487
488 if (tabId === 'foro-dudas') {
489 const pendiente = this.state.pendienteDuda;
490 if (pendiente) {
491 const tInput = document.getElementById('modal-du-titulo');
492 const cInput = document.getElementById('modal-du-texto');
493 const mSelect = document.getElementById('modal-du-materia');
494 if (tInput) tInput.value = pendiente.titulo || '';
495 if (cInput) cInput.value = pendiente.contenido || '';
496 if (mSelect && pendiente.idMateria) mSelect.value = String(pendiente.idMateria);
497 document.getElementById('modal-du-titulo')?.focus();
498 }
499 } else if (tabId === 'tips') {
500 document.getElementById('modal-tip-contenido')?.focus();
501 } else if (tabId === 'apuntes') {
502 const archivoInput = document.getElementById('modal-ap-archivo');
503 archivoInput?.addEventListener('change', () => {
504 const nombre = document.getElementById('modal-ap-archivo-nombre');
505 const f = archivoInput.files && archivoInput.files[0];
506 if (!nombre) return;
507 nombre.textContent = f ? `${f.name} (${this.formatBytes(f.size)})` : '';
508 });
509 }
510 },
511
512 selectIdMateria(tabId) {
513 return tabId === 'tips' ? 'modal-tip-materia' : (tabId === 'foro-dudas' ? 'modal-du-materia' : 'modal-ap-materia');
514 },
515
516 refrescarSelectsMateria() {
517 ['tips', 'foro-dudas', 'apuntes'].forEach(tabId => {
518 if (document.getElementById(this.selectIdMateria(tabId))) {
519 this.llenarSelectMateriaModal(tabId);
520 }
521 });
522 },
523
524 llenarSelectMateriaModal(tabId) {
525 const select = document.getElementById(this.selectIdMateria(tabId));
526 if (!select) return;
527 const opciones = this.state.materiasModal || [];
528 if (!opciones.length) {
529 select.innerHTML = '<option value="">General</option>';
530 return;
531 }
532 select.innerHTML = '<option value="">General</option>' + opciones.map(m => {
533 const id = m.id != null ? m.id : m;
534 const nombre = this.materiaNombre(m);
535 return `<option value="${id}">${this.esc(nombre)}</option>`;
536 }).join('');
537 },
538
539 modalFormTips() {
540 return [
541 '<label for="modal-tip-contenido">Contenido del tip</label>',
542 '<textarea id="modal-tip-contenido" maxlength="4000" placeholder="Comparte un consejo o truco académico útil para la comunidad..."></textarea>',
543 '<label for="modal-tip-materia">Materia</label>',
544 '<select id="modal-tip-materia" class="select-materia"></select>'
545 ].join('');
546 },
547
548 cerrarModal() {
549 const overlay = document.getElementById('hub-modal');
550 if (overlay) overlay.remove();
551 },
552
553 mensajeModal(texto, tipo) {
554 const msg = document.getElementById('modal-msg');
555 if (msg) {
556 msg.textContent = texto;
557 msg.className = 'modal-msg ' + (tipo || '');
558 }
559 },
560
561 async enviarFormularioAccion(tabId) {
562 if (tabId === 'tips') {
563 await this.crearTip();
564 } else if (tabId === 'foro-dudas') {
565 await this.crearDuda();
566 } else {
567 await this.crearApunte();
568 }
569 },
570
571 async crearTip() {
572 const contenido = (document.getElementById('modal-tip-contenido')?.value || '').trim();
573 const select = document.getElementById('modal-tip-materia');
574 const idMateria = select && select.value ? Number(select.value) : null;
575
576 if (!contenido) {
577 this.mensajeModal('El contenido del tip no puede estar vacío.', 'err');
578 return;
579 }
580
581 try {
582 await API.request('/tips', {
583 method: 'POST',
584 body: JSON.stringify({contenido, idMateria})
585 });
586 this.mensajeModal('¡Tip publicado correctamente!', 'ok');
587 this.cerrarModal();
588 this.state.loaded.delete('tips');
589 this.state.tips = [];
590 await this.loadTips();
591 } catch (e) {
592 this.mensajeModal(e.message || 'No se pudo publicar el tip.', 'err');
593 }
594 },
595
596 async crearDuda() {
597 const titulo = (document.getElementById('modal-du-titulo')?.value || '').trim();
598 const contenido = (document.getElementById('modal-du-texto')?.value || '').trim();
599 const select = document.getElementById('modal-du-materia');
600 const idMateria = select && select.value ? Number(select.value) : null;
601
602 if (!titulo) {
603 this.mensajeModal('Escribe un título claro para tu duda.', 'err');
604 return;
605 }
606 if (!contenido) {
607 this.mensajeModal('Describe tu duda para que la comunidad pueda ayudarte.', 'err');
608 return;
609 }
610
611 const pendiente = {titulo, contenido, idMateria};
612
613 try {
614 await API.request('/foro', {
615 method: 'POST',
616 body: JSON.stringify(pendiente)
617 });
618 this.state.pendienteDuda = null;
619 this.cerrarModal();
620 this.state.loaded.delete('foro');
621 this.state.publicaciones = [];
622 await this.loadForo();
623 } catch (e) {
624 if (e.status === 409 && e.data && e.data.publicacion) {
625 this.renderDuplicado(e.data, pendiente);
626 } else {
627 this.mensajeModal(e.message || 'No se pudo publicar la duda.', 'err');
628 }
629 }
630 },
631
632 renderDuplicado(dup, pendiente) {
633 const pub = dup.publicacion || {};
634 const respuestas = dup.respuestas || [];
635 const tips = dup.tips || [];
636 const recursos = dup.recursos || [];
637 const similitud = Math.round((Number(dup.similitud) || 0) * 100);
638
639 const respuestasHtml = respuestas.length
640 ? `<h4 class="dup-section-title">Respuestas existentes (${respuestas.length})</h4>` +
641 respuestas.map(r => {
642 const esSolucion = r.esSolucion === true || r.esSolucion === 'true';
643 return `
644 <div class="hub-respuesta">
645 <div class="respuesta-head">
646 <span class="respuesta-autor">${this.esc(this.autorNombre(r))}</span>
647 ${esSolucion ? '<span class="hub-badge badge-solucion">Solución de la comunidad</span>' : ''}
648 </div>
649 <p class="respuesta-texto">${this.esc(r.contenido)}</p>
650 </div>`;
651 }).join('')
652 : '<p class="dup-vacio">Aún no tiene respuestas.</p>';
653
654 const tipsHtml = tips.length
655 ? tips.map(t => this.tipCard(t)).join('')
656 : '<p class="dup-vacio">No hay tips relacionados disponibles.</p>';
657
658 const recursosHtml = recursos.length
659 ? recursos.map(r => this.apunteCard(r)).join('')
660 : '<p class="dup-vacio">No hay recursos relacionados disponibles.</p>';
661
662 const contenido = (this.field(pub, 'descripcion') || this.field(pub, 'contenido')).slice(0, 320);
663
664 const modal = document.getElementById('hub-modal');
665 if (!modal) return;
666
667 modal.className = 'hub-modal-overlay dup-overlay';
668 modal.innerHTML = `
669 <div class="hub-modal dup-modal">
670 <div class="dup-notice">
671 <strong class="dup-titulo">Esta duda ya fue preguntada en la comunidad</strong>
672 <p>Encontramos una conversación muy parecida (coincidencia de ${similitud}%). Únete a ella para recibir ayuda más rápido o revisa su contenido antes de publicar de nuevo.</p>
673 </div>
674 <div class="dup-question">
675 <div class="tip-head">
676 <span class="hub-badge badge-materia">${this.esc(this.materiaNombre(pub))}</span>
677 </div>
678 <h4 class="tip-title">${this.esc(pub.titulo)}</h4>
679 <p class="tip-text">${this.esc(contenido)}</p>
680 <p class="dup-por">Preguntado por: ${this.esc(this.autorNombre(pub))}</p>
681 </div>
682 <div class="dup-sections">
683 <div class="dup-col">
684 <h4 class="dup-section-title">Respuestas</h4>
685 <div class="dup-respuestas">${respuestasHtml}</div>
686 </div>
687 <div class="dup-col">
688 <h4 class="dup-section-title">Tips y recursos relacionados</h4>
689 <div class="dup-tips">${tipsHtml}</div>
690 <div class="dup-recursos">${recursosHtml}</div>
691 </div>
692 </div>
693 <p class="modal-sub" style="margin-top:12px;">¿Quieres publicar tu duda de todos modos o ajustar tu pregunta?</p>
694 <div class="modal-msg" id="dup-msg"></div>
695 <div class="modal-actions">
696 <button class="btn-clean" type="button" id="dup-cerrar">Cancelar</button>
697 <button class="btn-clean" type="button" id="dup-editar">Editar mi pregunta</button>
698 <button class="btn-solid" type="button" id="dup-publicar">Publicar de todos modos</button>
699 </div>
700 </div>`;
701
702 document.getElementById('dup-cerrar')?.addEventListener('click', () => this.cerrarModal());
703 document.getElementById('dup-editar')?.addEventListener('click', () => this.reabrirFormularioDuda(pendiente));
704 document.getElementById('dup-publicar')?.addEventListener('click', () => this.publicarDudaForzada(pendiente));
705 },
706
707 reabrirFormularioDuda(pendiente) {
708 this.state.pendienteDuda = pendiente;
709 this.cerrarModal();
710 this.abrirModal('foro-dudas');
711 },
712
713 async publicarDudaForzada(pendiente) {
714 try {
715 await API.request('/foro', {
716 method: 'POST',
717 body: JSON.stringify({...pendiente, ignorarDuplicado: true})
718 });
719 this.state.pendienteDuda = null;
720 this.cerrarModal();
721 this.state.loaded.delete('foro');
722 this.state.publicaciones = [];
723 await this.loadForo();
724 } catch (e) {
725 const msg = document.getElementById('dup-msg');
726 if (msg) {
727 msg.textContent = e.message || 'No se pudo publicar la duda.';
728 msg.className = 'modal-msg err';
729 }
730 }
731 },
732
733 async crearApunte() {
734 const titulo = (document.getElementById('modal-ap-nombre')?.value || '').trim();
735 const descripcion = (document.getElementById('modal-ap-desc')?.value || '').trim();
736 const url = (document.getElementById('modal-ap-url')?.value || '').trim();
737 const tipo = document.getElementById('modal-ap-tipo')?.value || 'PDF';
738 const select = document.getElementById('modal-ap-materia');
739 const idMateria = select && select.value ? Number(select.value) : null;
740 const archivoInput = document.getElementById('modal-ap-archivo');
741 const archivo = (archivoInput && archivoInput.files && archivoInput.files[0]) || null;
742
743 if (!titulo) {
744 this.mensajeModal('Escribe el nombre del apunte.', 'err');
745 return;
746 }
747 if (!idMateria) {
748 this.mensajeModal('Selecciona la materia del apunte.', 'err');
749 return;
750 }
751 if (!archivo && !url) {
752 this.mensajeModal('Sube un archivo o agrega una URL de descarga.', 'err');
753 return;
754 }
755
756 try {
757 if (archivo) {
758 await this.subirRecurso({titulo, descripcion, idMateria, archivo});
759 } else {
760 await API.request('/recursos', {
761 method: 'POST',
762 body: JSON.stringify({titulo, descripcion, idMateria, url, tipo})
763 });
764 }
765 this.cerrarModal();
766 this.state.loaded.delete('apuntes');
767 this.state.apuntes = [];
768 await this.loadApuntes();
769 Utils.toast('Apunte publicado correctamente.', 'success');
770 } catch (e) {
771 this.mensajeModal(e.message || 'No se pudo publicar el apunte.', 'err');
772 }
773 },
774
775 async subirRecurso({titulo, descripcion, idMateria, archivo}) {
776 const token = localStorage.getItem('token');
777 const formData = new FormData();
778 formData.append('titulo', titulo);
779 if (descripcion) formData.append('descripcion', descripcion);
780 formData.append('idMateria', String(idMateria));
781 formData.append('archivo', archivo);
782
783 const response = await fetch(`${API_BASE_URL}/recursos/upload`, {
784 method: 'POST',
785 headers: token ? {Authorization: `Bearer ${token}`} : {},
786 body: formData
787 });
788 const body = await response.json().catch(() => null);
789 if (!response.ok) {
790 const error = new Error((body && body.message) || `Error ${response.status}`);
791 error.status = response.status;
792 error.data = body;
793 throw error;
794 }
795 return (body && body.data !== undefined) ? body.data : body;
796 },
797
798 async descargarRecurso(id, nombre) {
799 const token = localStorage.getItem('token');
800 try {
801 const response = await fetch(`${API_BASE_URL}/recursos/${encodeURIComponent(id)}/archivo`, {
802 headers: token ? {Authorization: `Bearer ${token}`} : {}
803 });
804 if (!response.ok) {
805 Utils.toast('No se pudo descargar el documento', 'error');
806 return;
807 }
808 const blob = await response.blob();
809 const url = URL.createObjectURL(blob);
810 const a = document.createElement('a');
811 a.href = url;
812 a.download = nombre || 'recurso-descarga';
813 document.body.appendChild(a);
814 a.click();
815 a.remove();
816 URL.revokeObjectURL(url);
817 } catch (e) {
818 Utils.toast('No se pudo descargar el documento', 'error');
819 }
820 },
821
822 async votarTip(id, votado) {
823 if (!id) return;
824 try {
825 if (votado) {
826 await API.request(`/tips/${id}/voto`, {method: 'DELETE'});
827 } else {
828 await API.request(`/tips/${id}/voto`, {method: 'POST'});
829 }
830 this.state.loaded.delete('tips');
831 this.state.tips = [];
832 await this.loadTips();
833 } catch (e) {
834 // se ignora; se mantiene el estado actual
835 }
836 },
837
838 // ---- helpers ----
839 field(obj, key) {
840 if (!obj) return '';
841 return obj[key] != null ? String(obj[key]) : '';
842 },
843
844 num(obj, key) {
845 const v = obj && obj[key];
846 const n = Number(v);
847 return Number.isFinite(n) ? n : 0;
848 },
849
850 materiaNombre(item) {
851 if (!item) return '';
852 if (typeof item.materia === 'string') return item.materia;
853 if (item.materia && typeof item.materia === 'object') return item.materia.nombre || item.materia.nom || '';
854 if (item.nombre) return item.nombre;
855 if (item.materiaNombre) return item.materiaNombre;
856 return 'General';
857 },
858
859 autorNombre(item) {
860 if (!item) return 'Anónimo';
861 if (typeof item.autor === 'string') return item.autor;
862 if (item.autor && typeof item.autor === 'object') return item.autor.nombre || item.autor.nom || 'Anónimo';
863 if (item.autorNombre) return item.autorNombre;
864 return 'Anónimo';
865 },
866
867 toArray(res) {
868 if (!res) return [];
869 if (Array.isArray(res)) return res;
870 if (Array.isArray(res.data)) return res.data;
871 if (Array.isArray(res.contenido)) return res.contenido;
872 return [];
873 },
874
875 formatBytes(bytes) {
876 const n = Number(bytes);
877 if (!isFinite(n) || n <= 0) return '';
878 if (n < 1024) return `${n} B`;
879 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
880 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
881 },
882
883 esc(str) {
884 return String(str == null ? '' : str)
885 .replace(/&/g, '&amp;')
886 .replace(/</g, '&lt;')
887 .replace(/>/g, '&gt;')
888 .replace(/"/g, '&quot;')
889 .replace(/'/g, '&#39;');
890 }
891 };
892
893 document.addEventListener('DOMContentLoaded', () => Comunidad.init());