main
js 244 lines 12.6 KB
Raw
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' })[character]);
244 }