| 1 | /** |
| 2 | * Círculos de estudio. |
| 3 | * Contrato esperado de GET /circulos: |
| 4 | * { sesiones, misGrupos } con sesiones [{ id, titulo, descripcion, materia, |
| 5 | * modalidad, ubicacion, fecha (yyyy-MM-dd), horaInicio (HH:mm), duracionMin, |
| 6 | * cupoMax, estado, organizador, organizadorId, inscritos, inscrito, |
| 7 | * organizadorYo }] y misGrupos [{ id, nombre, descripcion, miembros }]. |
| 8 | */ |
| 9 | document.addEventListener('DOMContentLoaded', async () => { |
| 10 | Layout.init('circulos.html'); |
| 11 | Layout.setPageTitle('Círculos de Estudio'); |
| 12 | |
| 13 | const content = document.getElementById('main-content'); |
| 14 | if (!content) return; |
| 15 | content.innerHTML = '<div class="circulos-container"><p class="loading-message">Cargando círculos de estudio…</p></div>'; |
| 16 | await loadCircles(content); |
| 17 | }); |
| 18 | |
| 19 | async function loadCircles(content, search = '') { |
| 20 | let sessions = []; |
| 21 | let groups = []; |
| 22 | try { |
| 23 | const data = await API.request(`/circulos${search ? `?busqueda=${encodeURIComponent(search)}` : ''}`); |
| 24 | sessions = Array.isArray(data.sesiones) ? data.sesiones : []; |
| 25 | groups = Array.isArray(data.misGrupos) ? data.misGrupos : []; |
| 26 | } catch (error) { |
| 27 | Utils.toast(error.message || 'No se pudieron cargar los círculos.', 'error'); |
| 28 | } |
| 29 | renderCircles(content, sessions, groups, search); |
| 30 | } |
| 31 | |
| 32 | function renderCircles(content, sessions, groups, search = '') { |
| 33 | content.innerHTML = ` |
| 34 | <div class="circulos-container"> |
| 35 | <header class="module-heading"><h1>Círculos de estudio</h1><p>Encuentra sesiones de repaso y comunidades para aprender en conjunto.</p></header> |
| 36 | <form class="top-action-bar" id="circle-search-form"> |
| 37 | <label class="visually-hidden" for="circle-search">Buscar círculo o sesión</label> |
| 38 | <input class="search-input" id="circle-search" name="busqueda" type="search" value="${escapeHtml(search)}" placeholder="Buscar por materia, título u organizador..."> |
| 39 | <button class="btn-solid" type="button" id="create-sesion">+ Crear sesión de repaso</button> |
| 40 | </form> |
| 41 | <div class="circulos-grid"> |
| 42 | <section><h2 class="section-title">Próximas sesiones de repaso</h2>${sessions.length ? sessions.map(renderSession).join('') : renderEmptySessions()}</section> |
| 43 | <aside><h2 class="section-title">Tus grupos permanentes</h2>${groups.length ? `<section class="card">${groups.map(renderGroup).join('')}</section>` : ''}<section class="card rules-card"><h2>Normas del círculo</h2><p>Los círculos son espacios colaborativos. Comparte material libre de plagio y mantén el respeto en las salas de videollamada.</p></section></aside> |
| 44 | </div> |
| 45 | ${renderSesionModal()} |
| 46 | </div> |
| 47 | `; |
| 48 | |
| 49 | document.getElementById('circle-search-form')?.addEventListener('submit', event => { |
| 50 | event.preventDefault(); |
| 51 | loadCircles(content, new FormData(event.currentTarget).get('busqueda')?.trim()); |
| 52 | }); |
| 53 | document.getElementById('create-sesion')?.addEventListener('click', abrirModalSesion); |
| 54 | |
| 55 | document.getElementById('sesion-modal-cancel')?.addEventListener('click', cerrarModalSesion); |
| 56 | document.getElementById('sesion-modal')?.addEventListener('click', e => { |
| 57 | if (e.target === document.getElementById('sesion-modal')) cerrarModalSesion(); |
| 58 | }); |
| 59 | document.getElementById('sesion-form')?.addEventListener('submit', enviarFormSesion); |
| 60 | |
| 61 | // Delegación para acciones dentro de tarjetas que se renderizan dinámicamente. |
| 62 | content.addEventListener('click', e => { |
| 63 | const target = e.target.closest('button[data-action]'); |
| 64 | if (!target) return; |
| 65 | const id = Number(target.dataset.id); |
| 66 | switch (target.dataset.action) { |
| 67 | case 'unirse-sesion': |
| 68 | cambiarInscripcion(id, true, content); |
| 69 | break; |
| 70 | case 'dejar-sesion': |
| 71 | cambiarInscripcion(id, false, content); |
| 72 | break; |
| 73 | case 'ver-grupo': |
| 74 | Utils.toast('El detalle del grupo se habilita próximamente.', 'info'); |
| 75 | break; |
| 76 | } |
| 77 | }); |
| 78 | } |
| 79 | |
| 80 | function renderSession(session) { |
| 81 | const llena = session.estado === 'CERRADA' || Number(session.inscritos) >= Number(session.cupoMax); |
| 82 | const badges = [ |
| 83 | `<span class="badge badge-time">${escapeHtml(fechaLegible(session.fecha))}</span>`, |
| 84 | `<span class="badge badge-hora">${escapeHtml(horaRango(session.horaInicio, session.duracionMin))}</span>`, |
| 85 | session.materia ? `<span class="badge badge-materia">${escapeHtml(session.materia)}</span>` : '', |
| 86 | llena ? '<span class="badge badge-cupo">Cupo lleno</span>' : '' |
| 87 | ].filter(Boolean).join(''); |
| 88 | |
| 89 | const info = [ |
| 90 | `Organiza: <strong>${escapeHtml(session.organizador || 'Docente')}</strong>`, |
| 91 | session.modalidad ? `Modalidad: <strong>${escapeHtml(session.modalidad)}</strong>` : '', |
| 92 | session.ubicacion ? `Ubicación: <strong>${escapeHtml(session.ubicacion)}</strong>` : '' |
| 93 | ].filter(Boolean).join(' · '); |
| 94 | |
| 95 | const acciones = session.organizadorYo |
| 96 | ? '<span class="organizer-label">Tú organizas esta sesión</span>' |
| 97 | : (llena |
| 98 | ? '<button class="btn-clean" type="button" disabled>Cupo lleno</button>' |
| 99 | : (session.inscrito |
| 100 | ? '<button class="btn-clean" type="button" data-action="dejar-sesion" data-id="' + session.id + '">Salir de la sesión</button>' |
| 101 | : '<button class="btn-solid" type="button" data-action="unirse-sesion" data-id="' + session.id + '">Unirme</button>')); |
| 102 | |
| 103 | return ` |
| 104 | <article class="card session-card"> |
| 105 | <div class="session-header"><div class="badges-wrap">${badges}</div></div> |
| 106 | <h2 class="session-title">${escapeHtml(session.titulo || 'Sesión de repaso')}</h2> |
| 107 | <p class="session-info">${info}</p> |
| 108 | ${session.descripcion ? `<p class="session-desc">${escapeHtml(session.descripcion)}</p>` : ''} |
| 109 | <div class="session-footer"> |
| 110 | <span class="attendees-count">${Number(session.inscritos)} de ${Number(session.cupoMax)} confirmados</span> |
| 111 | <div class="session-actions">${acciones}</div> |
| 112 | </div> |
| 113 | </article> |
| 114 | `; |
| 115 | } |
| 116 | |
| 117 | function renderGroup(group) { |
| 118 | return `<article class="group-item"><span class="group-name">${escapeHtml(group.nombre || 'Grupo de estudio')}</span>${group.descripcion ? `<span class="group-description">${escapeHtml(group.descripcion)}</span>` : ''}<div class="group-meta">${Number.isFinite(Number(group.miembros)) ? `<span>${Number(group.miembros)} miembros</span>` : '<span></span>'}<button class="group-link" type="button" data-action="ver-grupo">Ver grupo →</button></div></article>`; |
| 119 | } |
| 120 | |
| 121 | function renderSesionModal() { |
| 122 | return ` |
| 123 | <div class="sesion-modal-overlay" id="sesion-modal" hidden> |
| 124 | <div class="sesion-modal" role="dialog" aria-modal="true"> |
| 125 | <h3>Nueva sesión de repaso</h3> |
| 126 | <p class="modal-sub">Organiza una sesión abierta y tus compañeros podrán unirse.</p> |
| 127 | <form id="sesion-form"> |
| 128 | <label for="s-titulo">Título *</label> |
| 129 | <input class="sesion-input" id="s-titulo" type="text" maxlength="200" required placeholder="Ej. Repaso de SQL para el parcial"> |
| 130 | <label for="s-materia">Materia *</label> |
| 131 | <input class="sesion-input" id="s-materia" type="text" maxlength="200" required placeholder="Ej. Bases de datos"> |
| 132 | <label for="s-descripcion">Descripción</label> |
| 133 | <textarea class="sesion-textarea" id="s-descripcion" maxlength="2000" placeholder="¿Qué repasarán? (opcional)"></textarea> |
| 134 | <div class="sesion-field-row"> |
| 135 | <div><label for="s-fecha">Fecha *</label><input class="sesion-input" id="s-fecha" type="date" required></div> |
| 136 | <div><label for="s-hora">Hora de inicio *</label><input class="sesion-input" id="s-hora" type="time" required></div> |
| 137 | </div> |
| 138 | <div class="sesion-field-row"> |
| 139 | <div><label for="s-duracion">Duración (min)</label><input class="sesion-input" id="s-duracion" type="number" min="15" max="480" step="15" value="90"></div> |
| 140 | <div><label for="s-cupo">Cupo máximo</label><input class="sesion-input" id="s-cupo" type="number" min="1" max="300" value="30"></div> |
| 141 | </div> |
| 142 | <label for="s-modalidad">Modalidad</label> |
| 143 | <select class="sesion-input" id="s-modalidad"> |
| 144 | <option value="Grupal">Grupal</option> |
| 145 | <option value="En línea">En línea</option> |
| 146 | <option value="Individual">Individual</option> |
| 147 | </select> |
| 148 | <label for="s-ubicacion">Ubicación / plataforma</label> |
| 149 | <input class="sesion-input" id="s-ubicacion" type="text" maxlength="200" placeholder="Ej. Biblioteca Central, Sala 2 o Google Meet"> |
| 150 | <div class="modal-msg" id="sesion-modal-msg"></div> |
| 151 | <div class="modal-actions"> |
| 152 | <button class="btn-clean" type="button" id="sesion-modal-cancel">Cancelar</button> |
| 153 | <button class="btn-solid" type="submit">Crear sesión</button> |
| 154 | </div> |
| 155 | </form> |
| 156 | </div> |
| 157 | </div> |
| 158 | `; |
| 159 | } |
| 160 | |
| 161 | function abrirModalSesion() { |
| 162 | const overlay = document.getElementById('sesion-modal'); |
| 163 | if (!overlay) return; |
| 164 | const fecha = document.getElementById('s-fecha'); |
| 165 | if (fecha) { |
| 166 | fecha.min = new Date().toISOString().slice(0, 10); |
| 167 | fecha.value = fecha.min; |
| 168 | } |
| 169 | const msg = document.getElementById('sesion-modal-msg'); |
| 170 | if (msg) msg.textContent = ''; |
| 171 | overlay.hidden = false; |
| 172 | } |
| 173 | |
| 174 | function cerrarModalSesion() { |
| 175 | const overlay = document.getElementById('sesion-modal'); |
| 176 | if (overlay) overlay.hidden = true; |
| 177 | } |
| 178 | |
| 179 | async function enviarFormSesion(event) { |
| 180 | event.preventDefault(); |
| 181 | const msg = document.getElementById('sesion-modal-msg'); |
| 182 | const btn = event.currentTarget.querySelector('button[type="submit"]'); |
| 183 | if (btn) btn.disabled = true; |
| 184 | |
| 185 | const payload = { |
| 186 | titulo: document.getElementById('s-titulo').value.trim(), |
| 187 | materia: document.getElementById('s-materia').value.trim(), |
| 188 | descripcion: document.getElementById('s-descripcion').value.trim(), |
| 189 | fecha: document.getElementById('s-fecha').value, |
| 190 | horaInicio: document.getElementById('s-hora').value, |
| 191 | duracionMin: Number(document.getElementById('s-duracion').value) || 90, |
| 192 | cupoMax: Number(document.getElementById('s-cupo').value) || 30, |
| 193 | modalidad: document.getElementById('s-modalidad').value, |
| 194 | ubicacion: document.getElementById('s-ubicacion').value.trim() |
| 195 | }; |
| 196 | |
| 197 | try { |
| 198 | await API.request('/circulos/sesiones', { method: 'POST', body: JSON.stringify(payload) }); |
| 199 | cerrarModalSesion(); |
| 200 | Utils.toast('Sesión de repaso creada.', 'success'); |
| 201 | const content = document.getElementById('main-content'); |
| 202 | if (content) await loadCircles(content); |
| 203 | } catch (error) { |
| 204 | if (msg) msg.textContent = error.message || 'No se pudo crear la sesión.'; |
| 205 | } finally { |
| 206 | if (btn) btn.disabled = false; |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | async function cambiarInscripcion(id, unirse, content) { |
| 211 | try { |
| 212 | const data = await API.request(`/circulos/sesiones/${id}/inscribirse`, { method: unirse ? 'POST' : 'DELETE' }); |
| 213 | Utils.toast(unirse ? `Asistencia confirmada a "${data.titulo}".` : 'Te has dado de baja de la sesión.', 'success'); |
| 214 | } catch (error) { |
| 215 | Utils.toast(error.message || 'No se pudo actualizar tu inscripción.', 'error'); |
| 216 | } |
| 217 | if (content) await loadCircles(content); |
| 218 | } |
| 219 | |
| 220 | function fechaLegible(iso) { |
| 221 | if (!iso) return ''; |
| 222 | const d = new Date(`${iso}T12:00:00`); |
| 223 | if (isNaN(d.getTime())) return iso; |
| 224 | const texto = d.toLocaleDateString('es-MX', { weekday: 'long', day: 'numeric', month: 'long' }); |
| 225 | return texto.charAt(0).toUpperCase() + texto.slice(1); |
| 226 | } |
| 227 | |
| 228 | function horaRango(inicio, duracionMin) { |
| 229 | if (!inicio) return ''; |
| 230 | const [h, m] = inicio.split(':').map(Number); |
| 231 | const total = h * 60 + m + (Number(duracionMin) || 90); |
| 232 | const eh = Math.floor(total / 60) % 24; |
| 233 | const em = total % 60; |
| 234 | const fin = `${String(eh).padStart(2, '0')}:${String(em).padStart(2, '0')}`; |
| 235 | return `${inicio} - ${fin}`; |
| 236 | } |
| 237 | |
| 238 | function renderEmptySessions() { |
| 239 | return '<section class="card empty-circles"><h2>Aún no hay sesiones programadas</h2><p>Organiza una sesión de repaso o espera a que un compañero cree una.</p></section>'; |
| 240 | } |
| 241 | |
| 242 | function escapeHtml(value) { |
| 243 | return String(value ?? '').replace(/[&<>'"]/g, character => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]); |
| 244 | } |