feat(frontend): subir frontend completo - pagina de ajustes, estilos/skins (incluye El Castillo), color y tema; solo frontend

Daniel Silva committed Sep 6, 2026 at 10:53 UTC 997811919809ab90481a471cb6ff10803f839408
54 files changed +18980 -312
frontend/css/admin.css new
+405
@@ -0,0 +1,405 @@
1 +/* MAPS Connect - Panel Administrativo y Reportería */
2 +
3 +.admin-container {
4 + max-width: 1150px;
5 + margin: 0 auto;
6 + width: 100%;
7 +}
8 +
9 +/* Cabecera del administrador */
10 +.admin-header {
11 + display: flex;
12 + justify-content: space-between;
13 + align-items: center;
14 + flex-wrap: wrap;
15 + gap: 16px;
16 + border-left: 4px solid var(--accent, #087527);
17 +}
18 +
19 +.admin-title h2 {
20 + font-size: 1.3rem;
21 + color: #111;
22 +}
23 +
24 +.admin-title p {
25 + font-size: 0.85rem;
26 + color: #555;
27 + margin-top: 3px;
28 +}
29 +
30 +/* Botones */
31 +.btn-solid {
32 + background-color: var(--accent, #087527);
33 + color: #ffffff;
34 + border: none;
35 + padding: 9px 16px;
36 + font-size: 0.88rem;
37 + font-weight: bold;
38 + cursor: pointer;
39 + display: inline-flex;
40 + align-items: center;
41 + gap: 6px;
42 +}
43 +
44 +.btn-solid:hover {
45 + background-color: var(--accent-deep, #064913);
46 +}
47 +
48 +.btn-outline {
49 + background: transparent;
50 + color: var(--accent, #087527);
51 + border: 1px solid var(--accent, #087527);
52 + padding: 8px 14px;
53 + font-size: 0.85rem;
54 + font-weight: 600;
55 + cursor: pointer;
56 +}
57 +
58 +.btn-outline:hover {
59 + background-color: var(--accent-soft, #f0fdf4);
60 +}
61 +
62 +.btn-danger-clean {
63 + background: transparent;
64 + color: #b91c1c;
65 + border: 1px solid #f87171;
66 + padding: 5px 10px;
67 + font-size: 0.78rem;
68 + font-weight: 600;
69 + cursor: pointer;
70 +}
71 +
72 +.btn-danger-clean:hover {
73 + background: #fef2f2;
74 +}
75 +
76 +.btn-success-clean {
77 + background: transparent;
78 + color: #15803d;
79 + border: 1px solid var(--accent-soft-border, #86efac);
80 + padding: 5px 10px;
81 + font-size: 0.78rem;
82 + font-weight: 600;
83 + cursor: pointer;
84 +}
85 +
86 +.btn-success-clean:hover {
87 + background: var(--accent-soft, #f0fdf4);
88 +}
89 +
90 +/* Tarjetas KPI */
91 +.kpi-grid {
92 + display: grid;
93 + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
94 + gap: 16px;
95 + margin-bottom: 20px;
96 +}
97 +
98 +.kpi-card {
99 + background: #ffffff;
100 + border: 1px solid #dcdfe6;
101 + padding: 16px;
102 + border-top: 3px solid var(--accent, #087527);
103 +}
104 +
105 +.kpi-card.alert {
106 + border-top-color: #b91c1c;
107 +}
108 +
109 +.kpi-value {
110 + font-size: 1.8rem;
111 + font-weight: bold;
112 + color: #111;
113 + line-height: 1.1;
114 + margin-bottom: 4px;
115 +}
116 +
117 +.kpi-label {
118 + font-size: 0.8rem;
119 + color: #666;
120 + text-transform: uppercase;
121 + letter-spacing: 0.5px;
122 +}
123 +
124 +.kpi-sub {
125 + font-size: 0.75rem;
126 + color: #888;
127 + margin-top: 8px;
128 +}
129 +
130 +/* Sección generador de reportes */
131 +.section-header {
132 + font-size: 1.05rem;
133 + color: #222;
134 + margin-bottom: 14px;
135 + border-bottom: 1px solid #e0e0e0;
136 + padding-bottom: 8px;
137 + display: flex;
138 + justify-content: space-between;
139 + align-items: center;
140 +}
141 +
142 +.report-form {
143 + display: grid;
144 + grid-template-columns: 2fr 1fr 1fr auto;
145 + gap: 12px;
146 + align-items: end;
147 +}
148 +
149 +@media (max-width: 850px) {
150 + .report-form {
151 + grid-template-columns: 1fr;
152 + }
153 +}
154 +
155 +.form-group label {
156 + display: block;
157 + font-size: 0.8rem;
158 + font-weight: 600;
159 + color: #444;
160 + margin-bottom: 5px;
161 +}
162 +
163 +.form-control {
164 + width: 100%;
165 + padding: 9px 12px;
166 + border: 1px solid #dcdfe6;
167 + background: #ffffff;
168 + font-size: 0.88rem;
169 + outline: none;
170 +}
171 +
172 +.form-control:focus {
173 + border-color: var(--accent, #087527);
174 +}
175 +
176 +/* Tabla de supervisión / moderación */
177 +.table-responsive {
178 + width: 100%;
179 + overflow-x: auto;
180 +}
181 +
182 +.admin-table {
183 + width: 100%;
184 + border-collapse: collapse;
185 + font-size: 0.88rem;
186 + text-align: left;
187 +}
188 +
189 +.admin-table th {
190 + background-color: #f9fafb;
191 + color: #333;
192 + padding: 12px 14px;
193 + border-bottom: 2px solid #dcdfe6;
194 + font-weight: 600;
195 +}
196 +
197 +.admin-table td {
198 + padding: 12px 14px;
199 + border-bottom: 1px solid #ebeef5;
200 + color: #444;
201 + vertical-align: middle;
202 +}
203 +
204 +.admin-table tr:hover {
205 + background-color: #fcfcfc;
206 +}
207 +
208 +.badge {
209 + padding: 3px 8px;
210 + font-size: 0.75rem;
211 + font-weight: bold;
212 + display: inline-block;
213 +}
214 +
215 +.badge-warning {
216 + background-color: #fef3c7;
217 + color: #92400e;
218 + border: 1px solid #fde68a;
219 +}
220 +
221 +.badge-danger {
222 + background-color: #fee2e2;
223 + color: #991b1b;
224 + border: 1px solid #fecaca;
225 +}
226 +
227 +.badge-info {
228 + background-color: #e0f2fe;
229 + color: #0369a1;
230 + border: 1px solid #bae6fd;
231 +}
232 +
233 +.badge-green {
234 + background-color: var(--accent-soft, #dcfce7);
235 + color: #166534;
236 + border: 1px solid var(--accent-soft-border, #86efac);
237 +}
238 +
239 +.admin-empty {
240 + padding: 30px;
241 + text-align: center;
242 + color: #888;
243 +}
244 +
245 +.usuarios-buscar {
246 + margin-bottom: 12px;
247 + max-width: 420px;
248 +}
249 +.usuarios-buscar .form-control {
250 + width: 100%;
251 + padding: 10px 14px;
252 + border: 1px solid #cbd5e1;
253 + border-radius: 8px;
254 + font-size: 0.9rem;
255 +}
256 +.usuarios-buscar .form-control:focus {
257 + outline: none;
258 + border-color: #0369a1;
259 + box-shadow: 0 0 0 2px rgba(3, 105, 161, 0.12);
260 +}
261 +
262 +.loading-message {
263 + padding: 40px;
264 + text-align: center;
265 + color: #666;
266 +}
267 +
268 +/* ============================================================
269 + MODO OSCURO — Panel Administrativo y Reportería (inversión pareja)
270 + ============================================================ */
271 +html.dark-mode .admin-container {
272 + color: #e6edf3;
273 +}
274 +
275 +html.dark-mode .admin-title h2 {
276 + color: #e6edf3;
277 +}
278 +
279 +html.dark-mode .admin-title p {
280 + color: #8b949e;
281 +}
282 +
283 +html.dark-mode .btn-outline {
284 + color: var(--accent-bright, #3fb950);
285 + border-color: var(--accent-mid, #2ea043);
286 +}
287 +
288 +html.dark-mode .btn-outline:hover {
289 + background-color: rgba(46, 160, 67, 0.15);
290 +}
291 +
292 +html.dark-mode .btn-danger-clean {
293 + color: #f87171;
294 + border-color: #dc2626;
295 +}
296 +
297 +html.dark-mode .btn-danger-clean:hover {
298 + background: #361d1a;
299 +}
300 +
301 +html.dark-mode .btn-success-clean {
302 + color: #34d399;
303 + border-color: #22c55e;
304 +}
305 +
306 +html.dark-mode .btn-success-clean:hover {
307 + background: #12291d;
308 +}
309 +
310 +html.dark-mode .kpi-card {
311 + background: #161b22;
312 + border-color: #30363d;
313 +}
314 +
315 +html.dark-mode .kpi-card.alert {
316 + border-top-color: #dc2626;
317 +}
318 +
319 +html.dark-mode .kpi-value {
320 + color: #e6edf3;
321 +}
322 +
323 +html.dark-mode .kpi-label {
324 + color: #8b949e;
325 +}
326 +
327 +html.dark-mode .kpi-sub {
328 + color: #8b949e;
329 +}
330 +
331 +html.dark-mode .section-header {
332 + color: #e6edf3;
333 + border-bottom-color: #30363d;
334 +}
335 +
336 +html.dark-mode .form-group label {
337 + color: #c9d1d9;
338 +}
339 +
340 +html.dark-mode .form-control {
341 + background: #0d1117;
342 + color: #e6edf3;
343 + border-color: #30363d;
344 +}
345 +
346 +html.dark-mode .form-control:focus {
347 + border-color: var(--accent-bright, #3fb950);
348 +}
349 +
350 +html.dark-mode .admin-table th {
351 + background-color: #0d1117;
352 + color: #c9d1d9;
353 + border-bottom-color: #30363d;
354 +}
355 +
356 +html.dark-mode .admin-table td {
357 + border-bottom-color: #21262d;
358 + color: #c9d1d9;
359 +}
360 +
361 +html.dark-mode .admin-table tr:hover {
362 + background-color: #161b22;
363 +}
364 +
365 +html.dark-mode .badge-warning {
366 + background-color: #332a12;
367 + color: #fbbf24;
368 + border-color: #4d3d17;
369 +}
370 +
371 +html.dark-mode .badge-danger {
372 + background-color: #361d1a;
373 + color: #fca5a5;
374 + border-color: #7f1d1d;
375 +}
376 +
377 +html.dark-mode .badge-info {
378 + background-color: #0b1520;
379 + color: #38bdf8;
380 + border-color: #123f5c;
381 +}
382 +
383 +html.dark-mode .badge-green {
384 + background-color: #12291d;
385 + color: #34d399;
386 + border-color: #14532d;
387 +}
388 +
389 +html.dark-mode .admin-empty {
390 + color: #8b949e;
391 +}
392 +
393 +html.dark-mode .usuarios-buscar .form-control {
394 + background: #0d1117;
395 + color: #e6edf3;
396 + border-color: #30363d;
397 +}
398 +
399 +html.dark-mode .usuarios-buscar .form-control:focus {
400 + border-color: #38bdf8;
401 +}
402 +
403 +html.dark-mode .loading-message {
404 + color: #8b949e;
405 +}
frontend/css/ajustes.css new
+250
@@ -0,0 +1,250 @@
1 +/* MAPS Connect - Ajustes */
2 +
3 +.ajustes-container {
4 + max-width: 760px;
5 + margin: 0 auto;
6 +}
7 +
8 +.ajustes-fila {
9 + display: flex;
10 + align-items: center;
11 + justify-content: space-between;
12 + gap: 1rem;
13 + flex-wrap: wrap;
14 +}
15 +
16 +.ajustes-fila-texto strong {
17 + display: block;
18 + font-size: 1rem;
19 + color: var(--heading-color);
20 +}
21 +
22 +.ajustes-fila-texto p {
23 + color: var(--gray-600);
24 + font-size: 0.88rem;
25 + margin: 0.25rem 0 0;
26 + max-width: 40rem;
27 +}
28 +
29 +/* --- Personalización del color de la página --- */
30 +.personaliza-seccion {
31 + display: flex;
32 + flex-direction: column;
33 + gap: 0.9rem;
34 + margin-top: 0.75rem;
35 +}
36 +
37 +.personaliza-label {
38 + display: block;
39 + font-size: 0.82rem;
40 + font-weight: 700;
41 + color: var(--heading-color);
42 + letter-spacing: 0.02em;
43 +}
44 +
45 +.personaliza-swatches {
46 + display: flex;
47 + flex-wrap: wrap;
48 + gap: 0.75rem;
49 + align-items: center;
50 +}
51 +
52 +.color-swatch {
53 + width: 34px;
54 + height: 34px;
55 + border-radius: 50%;
56 + border: 3px solid transparent;
57 + background-color: var(--swatch);
58 + cursor: pointer;
59 + position: relative;
60 + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
61 +}
62 +
63 +.color-swatch:hover {
64 + transform: scale(1.12);
65 +}
66 +
67 +.color-swatch.activo {
68 + border-color: var(--heading-color);
69 + box-shadow: 0 0 0 3px var(--accent-soft, #e8f5e9);
70 +}
71 +
72 +.color-swatch.activo::after {
73 + content: '✓';
74 + position: absolute;
75 + inset: 0;
76 + display: flex;
77 + align-items: center;
78 + justify-content: center;
79 + font-size: 0.9rem;
80 + font-weight: 800;
81 + color: #ffffff;
82 + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
83 +}
84 +
85 +html.dark-mode .color-swatch.activo {
86 + border-color: #ffffff;
87 +}
88 +
89 +.personaliza-custom {
90 + display: flex;
91 + align-items: center;
92 + gap: 0.75rem;
93 +}
94 +
95 +.personaliza-custom input[type="color"] {
96 + width: 44px;
97 + height: 34px;
98 + padding: 2px;
99 + border: 1px solid var(--gray-300);
100 + border-radius: 8px;
101 + background: #ffffff;
102 + cursor: pointer;
103 +}
104 +
105 +.personaliza-custom-texto {
106 + font-family: "SF Mono", Consolas, monospace;
107 + font-size: 0.85rem;
108 + color: var(--gray-600);
109 +}
110 +
111 +html.dark-mode .personaliza-custom input[type="color"] {
112 + background: #161b22;
113 + border-color: #30363d;
114 +}
115 +
116 +/* --- Estilos de la página (selector de skins) --- */
117 +.estilos-seccion {
118 + display: grid;
119 + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
120 + gap: 0.75rem;
121 + margin-top: 0.5rem;
122 +}
123 +
124 +.estilo-opcion {
125 + position: relative;
126 + display: flex;
127 + flex-direction: column;
128 + align-items: flex-start;
129 + gap: 0.45rem;
130 + padding: 0.9rem;
131 + text-align: left;
132 + cursor: pointer;
133 + background: var(--card-bg, #ffffff);
134 + border: 2px solid var(--gray-300);
135 + border-radius: 12px;
136 + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
137 +}
138 +
139 +.estilo-opcion:hover {
140 + border-color: var(--accent-mid);
141 + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
142 + transform: translateY(-2px);
143 +}
144 +
145 +.estilo-opcion:disabled {
146 + cursor: not-allowed;
147 + opacity: 0.5;
148 +}
149 +
150 +.estilo-opcion.activo {
151 + border-color: var(--accent);
152 + box-shadow: 0 0 0 3px var(--accent-soft, #e8f5e9);
153 +}
154 +
155 +.estilo-preview {
156 + width: 100%;
157 + height: 54px;
158 + border-radius: 8px;
159 + border: 1px solid var(--gray-300);
160 + display: block;
161 +}
162 +
163 +.estilo-nombre {
164 + font-weight: 800;
165 + font-size: 0.95rem;
166 + color: var(--heading-color);
167 +}
168 +
169 +.estilo-desc {
170 + font-size: 0.82rem;
171 + line-height: 1.45;
172 + color: var(--gray-600);
173 +}
174 +
175 +.estilo-check {
176 + display: none;
177 + position: absolute;
178 + top: 0.55rem;
179 + right: 0.55rem;
180 + width: 24px;
181 + height: 24px;
182 + align-items: center;
183 + justify-content: center;
184 + border-radius: 50%;
185 + background: var(--accent);
186 + color: var(--accent-contrast, #ffffff);
187 + font-weight: 800;
188 + font-size: 0.85rem;
189 +}
190 +
191 +.estilo-opcion.activo .estilo-check {
192 + display: flex;
193 +}
194 +
195 +html.dark-mode .estilo-opcion {
196 + background: #161b22;
197 + border-color: #30363d;
198 +}
199 +
200 +html.dark-mode .estilo-desc {
201 + color: #8b949e;
202 +}
203 +
204 +/* Personalización de color deshabilitada por un estilo activo */
205 +.personaliza-bloqueado {
206 + opacity: 0.5;
207 + pointer-events: none;
208 +}
209 +
210 +/* --- Secciones de ajustes --- */
211 +.ajustes-seccion {
212 + margin-bottom: 2.25rem;
213 +}
214 +
215 +.ajustes-seccion-titulo {
216 + font-size: 1.3rem;
217 + font-weight: 800;
218 + letter-spacing: -0.02em;
219 + color: var(--heading-color);
220 + margin: 0 0 0.25rem;
221 +}
222 +
223 +.ajustes-seccion-desc {
224 + font-size: 0.9rem;
225 + color: var(--gray-600);
226 + margin: 0 0 1.1rem;
227 + max-width: 46rem;
228 +}
229 +
230 +html.dark-mode .ajustes-seccion-desc {
231 + color: #8b949e;
232 +}
233 +
234 +/* --- Mi cuenta --- */
235 +.cuenta-form {
236 + margin-top: 1rem;
237 +}
238 +
239 +.cuenta-form .form-control {
240 + max-width: 420px;
241 +}
242 +
243 +.cuenta-form .btn-primary {
244 + margin-top: 0.25rem;
245 +}
246 +
247 +#cuenta-email {
248 + font-family: "SF Mono", Consolas, monospace;
249 + font-size: 0.88rem;
250 +}
frontend/css/base.css
+214
@@ -17,6 +17,16 @@
17 --header-height: 60px;
18 --border-radius: 4px;
19
20 + /* Paleta de acento (verde por defecto; se personaliza con js/colores.js) */
21 + --accent: #087527;
22 + --accent-dark: #005a2b;
23 + --accent-mid: #2ea043;
24 + --accent-bright: #3fb950;
25 + --accent-deep: #064913;
26 + --accent-soft: #e8f5e9;
27 + --accent-soft-border: #c8e6c9;
28 + --accent-contrast: #ffffff;
29 +
30 /* Texto del contenido (independiente del sidebar) */
31 --text-color: #000000;
32 --heading-color: #111827;
@@ -78,3 +88,207 @@ h1, h2, h3 {
88 color: var(--gray-600);
89 margin-bottom: 1.5rem;
90 }
91 +
92 +/* ============================================================
93 + MODO OSCURO
94 + Se activa añadiendo la clase "dark-mode" al elemento <html>.
95 + Las variables se redefinen y la mayoría de componentes la
96 + heredan; los colores fijos se sobrescriben con bloques .dark-mode.
97 + ============================================================ */
98 +html.dark-mode {
99 + --primary-color: #2ea043;
100 + --secondary-color: #8b5cf6;
101 + --gray-50: #161b22;
102 + --gray-100: #0d1117;
103 + --gray-200: #21262d;
104 + --gray-300: #30363d;
105 + --gray-600: #8b949e;
106 + --gray-800: #c9d1d9;
107 + --gray-900: #e6edf3;
108 + --text-color: #e6edf3;
109 + --heading-color: #ffffff;
110 + --link-color: #3fb950;
111 + --btn-outline-border: #ffffff;
112 + --btn-outline-text: #ffffff;
113 +
114 + background-color: #0d1117;
115 + color: #e6edf3;
116 +}
117 +
118 +html.dark-mode body {
119 + background-color: #0d1117;
120 + color: #e6edf3;
121 +}
122 +
123 +html.dark-mode .card {
124 + background: #161b22;
125 + border-color: #30363d;
126 +}
127 +
128 +html.dark-mode .form-control {
129 + background: #0d1117;
130 + color: #e6edf3;
131 + border-color: #30363d;
132 +}
133 +
134 +html.dark-mode .form-control::placeholder {
135 + color: #8b949e;
136 +}
137 +
138 +html.dark-mode .global-search-input,
139 +html.dark-mode .global-search-dropdown,
140 +html.dark-mode .notif-toggle,
141 +html.dark-mode .notif-panel {
142 + background: #161b22;
143 + color: #e6edf3;
144 + border-color: #30363d;
145 +}
146 +
147 +html.dark-mode .global-search-info strong,
148 +html.dark-mode .notif-item-info strong {
149 + color: #e6edf3;
150 +}
151 +
152 +html.dark-mode .global-search-info span,
153 +html.dark-mode .notif-item-info span {
154 + color: #8b949e;
155 +}
156 +
157 +html.dark-mode .global-search-item:hover,
158 +html.dark-mode .notif-item:hover,
159 +html.dark-mode .notif-toggle:hover {
160 + background: #21262d;
161 +}
162 +
163 +html.dark-mode .toast {
164 + background: #21262d;
165 + color: #e6edf3;
166 + border-color: #30363d;
167 +}
168 +
169 +html.dark-mode .alert-error {
170 + background: #3a1d1d;
171 + color: #fecaca;
172 + border-color: #7f1d1d;
173 +}
174 +
175 +html.dark-mode .alert-success {
176 + background: #12291d;
177 + color: #a7f3d0;
178 + border-color: #14532d;
179 +}
180 +
181 +html.dark-mode .follow-btn {
182 + border-color: var(--accent-bright, #3fb950);
183 + color: var(--accent-bright, #3fb950);
184 +}
185 +
186 +html.dark-mode .follow-btn:hover {
187 + background: rgba(62, 185, 80, 0.15);
188 +}
189 +
190 +html.dark-mode .follow-btn.siguiendo {
191 + background: var(--accent-bright, #3fb950);
192 + color: var(--accent-contrast, #04160a);
193 +}
194 +
195 +/* Toggle de tema en Ajustes */
196 +.tema-switch {
197 + position: relative;
198 + display: inline-block;
199 + width: 54px;
200 + height: 30px;
201 + vertical-align: middle;
202 +}
203 +.tema-switch input {
204 + opacity: 0;
205 + width: 0;
206 + height: 0;
207 +}
208 +.tema-switch .slider {
209 + position: absolute;
210 + cursor: pointer;
211 + inset: 0;
212 + background-color: var(--gray-300);
213 + transition: background-color 0.2s ease;
214 + border-radius: 999px;
215 +}
216 +.tema-switch .slider::before {
217 + content: '';
218 + position: absolute;
219 + height: 24px;
220 + width: 24px;
221 + left: 3px;
222 + bottom: 3px;
223 + background: #ffffff;
224 + transition: transform 0.2s ease;
225 + border-radius: 50%;
226 +}
227 +.tema-switch input:checked + .slider {
228 + background-color: var(--primary-color, #2ea043);
229 +}
230 +.tema-switch input:checked + .slider::before {
231 + transform: translateX(24px);
232 +}
233 +
234 +.login-page html.dark-mode,
235 +html.dark-mode .login-page .auth-layout,
236 +html.dark-mode .register-page .auth-layout {
237 + background-color: #0d1117;
238 +}
239 +
240 +/* ============================================================
241 + MODO OSCURO — REFUERZO GLOBAL (controles, tablas, listas)
242 + Aplica a todas las páginas para que todo se vea parejo.
243 + ============================================================ */
244 +html.dark-mode input,
245 +html.dark-mode select,
246 +html.dark-mode textarea {
247 + background-color: #0d1117;
248 + color: #e6edf3;
249 + border-color: #30363d;
250 +}
251 +
252 +html.dark-mode select option {
253 + background-color: #161b22;
254 + color: #e6edf3;
255 +}
256 +
257 +html.dark-mode input::placeholder,
258 +html.dark-mode textarea::placeholder {
259 + color: #8b949e;
260 +}
261 +
262 +html.dark-mode label,
263 +html.dark-mode legend {
264 + color: #e6edf3;
265 +}
266 +
267 +html.dark-mode th {
268 + color: #e6edf3;
269 +}
270 +
271 +html.dark-mode td {
272 + color: #c9d1d9;
273 +}
274 +
275 +html.dark-mode hr {
276 + border-color: #30363d;
277 +}
278 +
279 +html.dark-mode .btn-outline {
280 + color: #e6edf3;
281 +}
282 +
283 +html.dark-mode .btn-secondary {
284 + color: #ffffff;
285 +}
286 +
287 +html.dark-mode .pagina-body,
288 +html.dark-mode .muestra-list-item,
289 +html.dark-mode .visita-item,
290 +html.dark-mode .estadistica-box,
291 +html.dark-mode .item-lista {
292 + background-color: #161b22;
293 + color: #e6edf3;
294 +}
frontend/css/certificados.css new
+129
@@ -0,0 +1,129 @@
1 +.ruta-container { width: 100%; max-width: 1050px; margin: 0 auto; }
2 +
3 +.ruta-container .card, .ruta-container .btn, .ruta-container .badge { border-radius: 0; }
4 +
5 +.ruta-container .btn-solid { display: inline-block; border: 1px solid var(--sidebar-bg); background: var(--sidebar-bg); padding: 0.5rem 1rem; color: white; font: inherit; font-size: 0.9rem; font-weight: 700; cursor: pointer; text-align: center; }
6 +.ruta-container .btn-solid:hover { background: #043b10; color: white; text-decoration: none; }
7 +.ruta-container .btn-solid:disabled { border-color: var(--gray-300); background: var(--gray-200); color: var(--gray-400); cursor: not-allowed; }
8 +
9 +.ruta-container .btn-clean { display: inline-block; border: 1px solid var(--sidebar-bg); background: transparent; padding: 0.45rem 0.875rem; color: var(--sidebar-bg); font: inherit; font-size: 0.85rem; font-weight: 600; cursor: pointer; text-align: center; }
10 +.ruta-container .btn-clean:hover { background: var(--accent-soft, #f0fdf4); text-decoration: none; }
11 +
12 +.module-heading { margin-bottom: 1.25rem; }
13 +.module-heading h1 { font-size: 1.5rem; }
14 +.module-heading p { color: var(--gray-600); font-size: 0.9rem; }
15 +
16 +.ruta-banner { margin-bottom: 1.5rem; border-left: 4px solid var(--sidebar-bg); padding: 1.25rem; }
17 +.ruta-banner h2 { margin: 0 0 0.35rem; font-size: 1.2rem; }
18 +.ruta-banner p { margin: 0; color: var(--gray-600); font-size: 0.9rem; line-height: 1.5; }
19 +
20 +.ruta-section { margin-bottom: 2rem; }
21 +.ruta-section-title { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin: 0 0 0.875rem; font-size: 1.05rem; }
22 +.ruta-section-title small { color: var(--gray-600); font-weight: 400; font-size: 0.85rem; }
23 +
24 +.ruta-block { margin-bottom: 1rem; padding: 1.1rem 1.25rem; }
25 +.ruta-block-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: 0.5rem; }
26 +.ruta-block-title { margin: 0; font-size: 1.05rem; }
27 +.ruta-block-range { display: block; margin-top: 0.15rem; color: var(--gray-600); font-size: 0.82rem; }
28 +.ruta-block-desc { margin: 0.15rem 0 0.75rem; color: var(--gray-600); font-size: 0.88rem; line-height: 1.5; }
29 +
30 +.ruta-materias { display: flex; flex-wrap: wrap; gap: 0.375rem; margin-bottom: 0.75rem; }
31 +.materia-chip { display: inline-block; border: 1px solid #c7d2fe; background: #eef2ff; color: #3730a3; padding: 0.2rem 0.6rem; font-size: 0.78rem; }
32 +
33 +.ruta-badges { display: flex; flex-wrap: wrap; gap: 0.375rem; }
34 +.badge-encurso { display: inline-block; border: 1px solid var(--accent-soft-border, #c8e6c9); background: var(--accent-soft, #e8f5e9); color: var(--sidebar-bg); padding: 0.24rem 0.6rem; font-size: 0.78rem; font-weight: 600; }
35 +.badge-cursado { display: inline-block; border: 1px solid #bae6fd; background: #e0f2fe; color: #0369a1; padding: 0.24rem 0.6rem; font-size: 0.78rem; font-weight: 600; }
36 +.badge-proximo { display: inline-block; border: 1px solid #e5e7eb; background: #f9fafb; color: var(--gray-500); padding: 0.24rem 0.6rem; font-size: 0.78rem; font-weight: 600; }
37 +.badge-ruta { display: inline-block; border: 1px solid #fde68a; background: #fffbeb; color: #92400e; padding: 0.24rem 0.6rem; font-size: 0.78rem; font-weight: 600; }
38 +
39 +.ruta-actions { display: flex; justify-content: flex-end; border-top: 1px solid var(--gray-200); padding-top: 0.75rem; }
40 +
41 +.empty-state-box { border-left: 4px solid var(--gray-300); text-align: center; padding: 1.75rem 1.25rem; margin-bottom: 1.25rem; }
42 +.empty-state-box h2 { margin: 0 0 0.4rem; font-size: 1.1rem; }
43 +.empty-state-box p { margin: 0 0 0.875rem; color: var(--gray-600); font-size: 0.9rem; }
44 +
45 +.ruta-footer-note { margin-top: 1.25rem; color: var(--gray-500); font-size: 0.82rem; text-align: center; }
46 +
47 +@media (max-width: 560px) {
48 + .ruta-block-head { flex-direction: column; }
49 + .ruta-section-title { flex-direction: column; align-items: flex-start; gap: 0.25rem; }
50 + .ruta-actions { justify-content: stretch; }
51 + .ruta-actions .btn-solid { width: 100%; }
52 +}
53 +
54 +/* ============================================================
55 + MODO OSCURO — Certificados / Ruta de aprendizaje (inversión pareja)
56 + ============================================================ */
57 +html.dark-mode .ruta-container {
58 + color: #e6edf3;
59 +}
60 +
61 +html.dark-mode .module-heading p {
62 + color: #8b949e;
63 +}
64 +
65 +html.dark-mode .ruta-banner h2 {
66 + color: #e6edf3;
67 +}
68 +
69 +html.dark-mode .ruta-banner p,
70 +html.dark-mode .ruta-section-title small,
71 +html.dark-mode .ruta-block-range,
72 +html.dark-mode .ruta-block-desc {
73 + color: #8b949e;
74 +}
75 +
76 +html.dark-mode .ruta-block {
77 + background: #161b22;
78 + border-color: #30363d;
79 +}
80 +
81 +html.dark-mode .materia-chip {
82 + border-color: #4338ca;
83 + background: #1e1b4b;
84 + color: #a5b4fc;
85 +}
86 +
87 +html.dark-mode .badge-encurso {
88 + border-color: #14532d;
89 + background: #12291d;
90 + color: var(--accent-bright, #3fb950);
91 +}
92 +
93 +html.dark-mode .badge-cursado {
94 + border-color: #123f5c;
95 + background: #0b1520;
96 + color: #38bdf8;
97 +}
98 +
99 +html.dark-mode .badge-proximo {
100 + border-color: #30363d;
101 + background: #21262d;
102 + color: #8b949e;
103 +}
104 +
105 +html.dark-mode .badge-ruta {
106 + border-color: #4d3d17;
107 + background: #332a12;
108 + color: #fbbf24;
109 +}
110 +
111 +html.dark-mode .ruta-actions {
112 + border-top-color: #21262d;
113 +}
114 +
115 +html.dark-mode .empty-state-box {
116 + border-left-color: #30363d;
117 +}
118 +
119 +html.dark-mode .empty-state-box p {
120 + color: #8b949e;
121 +}
122 +
123 +html.dark-mode .ruta-footer-note {
124 + color: #8b949e;
125 +}
126 +
127 +html.dark-mode .ruta-container .btn-clean:hover {
128 + background: rgba(46, 160, 67, 0.15);
129 +}
\ No newline at end of file
frontend/css/circulos.css
+69 -3
@@ -1,4 +1,4 @@
1 -/* MAPS Connect - Círculos de estudio */
1 +/* MAPS Connect - Círculos de estudio */
2
3 .circulos-container { width: 100%; max-width: 1050px; margin: 0 auto; }
4 .module-heading { margin-bottom: 1.5rem; }
@@ -24,8 +24,13 @@
24 .session-footer { align-items: center; border-top: 1px solid var(--gray-200); padding-top: 0.75rem; flex-wrap: wrap; }
25 .session-actions, .badges-wrap { display: flex; flex-wrap: wrap; gap: 0.5rem; }
26 .badge-live { border: 1px solid #fecaca; background: #fee2e2; color: #991b1b; }
27 -.badge-materia { border: 1px solid #c8e6c9; background: #e8f5e9; color: var(--sidebar-bg); }
27 +.badge-materia { border: 1px solid var(--accent-soft-border, #c8e6c9); background: var(--accent-soft, #e8f5e9); color: var(--sidebar-bg); }
28 .badge-time { border: 1px solid #bae6fd; background: #e0f2fe; color: #0369a1; }
29 +.badge-hora { border: 1px solid #ede9fe; background: #ede9fe; color: #5b21b6; }
30 +.badge-cupo { border: 1px solid #fecaca; background: #fee2e2; color: #991b1b; }
31 +.organizer-label { color: var(--sidebar-bg); font-size: 0.85rem; font-weight: 700; }
32 +.session-actions .btn-clean, .session-actions .btn-solid { white-space: nowrap; }
33 +.session-actions .btn-clean:disabled { border-color: var(--gray-300); color: var(--gray-400); cursor: not-allowed; }
34 .group-item { border-bottom: 1px solid var(--gray-200); padding: 0.75rem 0; }
35 .group-item:first-child { padding-top: 0; }
36 .group-item:last-child { border-bottom: 0; padding-bottom: 0; }
@@ -40,5 +45,66 @@
45 .empty-circles h2 { font-size: 1.05rem; }
46 .loading-message { color: var(--gray-600); }
47
48 +.sesion-modal-overlay {
49 + position: fixed;
50 + inset: 0;
51 + background: rgba(0, 0, 0, 0.45);
52 + display: flex;
53 + align-items: flex-start;
54 + justify-content: center;
55 + padding: 60px 16px 16px;
56 + z-index: 1000;
57 +}
58 +
59 +.sesion-modal-overlay[hidden] { display: none; }
60 +
61 +.circulos-container .btn-clean:hover { background: var(--accent-soft, #f0fdf4); text-decoration: none; }
62 +
63 +.sesion-modal {
64 + background: #ffffff;
65 + max-width: 520px;
66 + width: 100%;
67 + max-height: calc(100vh - 90px);
68 + overflow-y: auto;
69 + padding: 24px;
70 + border-top: 3px solid var(--sidebar-bg);
71 + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
72 + border-radius: 0;
73 +}
74 +
75 +.sesion-modal h3 { margin: 0 0 4px; font-size: 1.15rem; color: var(--gray-900); }
76 +.sesion-modal .modal-sub { margin: 0 0 16px; font-size: 0.85rem; color: var(--gray-600); }
77 +.sesion-modal label { display: block; font-size: 0.85rem; font-weight: bold; color: var(--gray-800); margin: 12px 0 6px; }
78 +.sesion-input {
79 + width: 100%;
80 + box-sizing: border-box;
81 + padding: 0.625rem 0.75rem;
82 + border: 1px solid var(--gray-300);
83 + background: #fff;
84 + font: inherit;
85 + font-size: 0.9rem;
86 + color: var(--gray-900);
87 + border-radius: 0;
88 + outline: none;
89 +}
90 +.sesion-input:focus { border-color: var(--sidebar-bg); }
91 +.sesion-textarea {
92 + width: 100%;
93 + box-sizing: border-box;
94 + min-height: 100px;
95 + padding: 0.625rem 0.75rem;
96 + border: 1px solid var(--gray-300);
97 + font: inherit;
98 + font-size: 0.9rem;
99 + color: var(--gray-900);
100 + outline: none;
101 + resize: vertical;
102 + border-radius: 0;
103 +}
104 +.sesion-textarea:focus { border-color: var(--sidebar-bg); }
105 +.sesion-field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
106 +.sesion-modal .modal-msg { font-size: 0.85rem; color: var(--danger-color); margin-top: 12px; }
107 +
108 @media (max-width: 850px) { .circulos-grid { grid-template-columns: 1fr; } }
44 -@media (max-width: 560px) { .top-action-bar { align-items: stretch; flex-direction: column; } .search-input, .top-action-bar .btn-solid { width: 100%; } .session-footer { align-items: flex-start; flex-direction: column; } }
109 +@media (max-width: 560px) { .top-action-bar { align-items: stretch; flex-direction: column; } .search-input, .top-action-bar .btn-solid { width: 100%; } .session-footer { align-items: flex-start; flex-direction: column; } .sesion-field-row { grid-template-columns: 1fr; gap: 0.5rem; } }
110 +
frontend/css/colores.css new
+33
@@ -0,0 +1,33 @@
1 +/* MAPS Connect - Personalización del color
2 + Cuando <html> lleva el atributo data-color (gestionado por js/colores.js),
3 + estas reglas reemplazan las variables del tema por las variables --accent-*
4 + calculadas a partir del color elegido por el usuario. El resto de la app
5 + se "sintoniza" automáticamente porque consume estas variables. */
6 +
7 +html[data-color] {
8 + --secondary-color: var(--accent-dark);
9 + --link-color: var(--accent-dark);
10 + --sidebar-bg: var(--accent-deep);
11 + --sidebar-border: var(--accent-dark);
12 + --sidebar-item-hover-bg: var(--accent-dark);
13 + --sidebar-item-active-bg: var(--accent-dark);
14 + --sidebar-accent: var(--accent-bright);
15 + --topbar-bg: var(--accent-dark);
16 + --btn-primary-text: var(--accent-dark);
17 +
18 + /* Módulos con paleta propia (perfil, mensajes, comunidad) */
19 + --hub-accent: var(--accent-dark);
20 + --hub-accent-dark: var(--accent-deep);
21 + --hub-accent-soft: var(--accent-soft);
22 +}
23 +
24 +/* En modo oscuro el acento debe verse claro sobre fondo oscuro. */
25 +html[data-color].dark-mode {
26 + --primary-color: var(--accent-mid);
27 + --link-color: var(--accent-bright);
28 + --secondary-color: var(--accent-bright);
29 + --btn-primary-text: var(--accent-bright);
30 + --hub-accent: var(--accent-bright);
31 + --hub-accent-dark: var(--accent);
32 + --hub-accent-soft: var(--accent-deep);
33 +}
\ No newline at end of file
frontend/css/components.css
+193
@@ -123,3 +123,196 @@
123 background: #d1fae5;
124 color: #065f46;
125 }
126 +
127 +/* ---------- Toast / notificaciones flotantes ---------- */
128 +#toast-root {
129 + position: fixed;
130 + bottom: 1.25rem;
131 + right: 1.25rem;
132 + z-index: 5000;
133 + display: flex;
134 + flex-direction: column;
135 + gap: 0.5rem;
136 + max-width: min(22rem, calc(100vw - 2.5rem));
137 +}
138 +
139 +.toast {
140 + padding: 0.75rem 1rem;
141 + border-radius: var(--border-radius);
142 + font-size: 0.9rem;
143 + line-height: 1.35;
144 + color: #1f2937;
145 + background: #ffffff;
146 + border: 1px solid var(--gray-200);
147 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14);
148 + opacity: 0;
149 + transform: translateY(8px);
150 + transition: opacity 0.2s ease, transform 0.2s ease;
151 +}
152 +
153 +.toast.show {
154 + opacity: 1;
155 + transform: translateY(0);
156 +}
157 +
158 +.toast-info { border-left: 4px solid var(--primary-color); }
159 +.toast-success { border-left: 4px solid #059669; }
160 +.toast-error { border-left: 4px solid var(--danger-color); }
161 +
162 +/* ---------- Avatar con foto ---------- */
163 +.avatar-foto {
164 + object-fit: cover;
165 + display: block;
166 + flex-shrink: 0;
167 +}
168 +
169 +/* ---------- Enlace a perfil p├║blico ---------- */
170 +.user-link {
171 + color: inherit;
172 + text-decoration: none;
173 + display: inline-flex;
174 + align-items: center;
175 + gap: 8px;
176 +}
177 +
178 +.user-link:hover {
179 + text-decoration: underline;
180 +}
181 +
182 +/* ---------- Bot├│n Seguir / Siguiendo ---------- */
183 +.follow-btn {
184 + background: transparent;
185 + border: 1px solid var(--accent-dark, #005a2b);
186 + color: var(--accent-dark, #005a2b);
187 + padding: 3px 12px;
188 + font-size: 0.75rem;
189 + font-weight: 600;
190 + cursor: pointer;
191 + border-radius: 0;
192 + white-space: nowrap;
193 +}
194 +
195 +.follow-btn:hover {
196 + background: var(--accent-soft, #e8f5e9);
197 +}
198 +
199 +.follow-btn.siguiendo {
200 + background: var(--accent-dark, #005a2b);
201 + color: #ffffff;
202 +}
203 +
204 +.follow-btn.siguiendo:hover {
205 + background: #004420;
206 +}
207 +
208 +.follow-btn:disabled {
209 + opacity: 0.6;
210 + cursor: default;
211 +}
212 +
213 +/* ---------- Modal de Aviso de Privacidad / T├®rminos ---------- */
214 +.aviso-modal-overlay {
215 + position: fixed;
216 + inset: 0;
217 + z-index: 6000;
218 + background: rgba(0, 0, 0, 0.55);
219 + display: flex;
220 + align-items: center;
221 + justify-content: center;
222 + padding: 1rem;
223 +}
224 +
225 +.aviso-modal {
226 + background: #ffffff;
227 + color: var(--text-color);
228 + border: 1px solid var(--gray-300);
229 + border-radius: var(--border-radius);
230 + width: 100%;
231 + max-width: 620px;
232 + max-height: 88vh;
233 + display: flex;
234 + flex-direction: column;
235 + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
236 +}
237 +
238 +html.dark-mode .aviso-modal {
239 + background: #161b22;
240 + color: #e6edf3;
241 +}
242 +
243 +.aviso-modal-head {
244 + display: flex;
245 + justify-content: space-between;
246 + align-items: center;
247 + padding: 1rem 1.25rem;
248 + border-bottom: 1px solid var(--gray-200);
249 +}
250 +
251 +.aviso-modal-head h3 {
252 + margin: 0;
253 + font-size: 1.05rem;
254 +}
255 +
256 +.aviso-modal-cerrar {
257 + background: none;
258 + border: none;
259 + font-size: 1.5rem;
260 + line-height: 1;
261 + cursor: pointer;
262 + color: var(--gray-600);
263 +}
264 +
265 +.aviso-modal-body {
266 + padding: 1rem 1.25rem;
267 + overflow-y: auto;
268 + font-size: 0.9rem;
269 + line-height: 1.5;
270 +}
271 +
272 +.aviso-modal-body h4 {
273 + margin: 1rem 0 0.35rem;
274 + font-size: 0.95rem;
275 + color: var(--heading-color);
276 +}
277 +
278 +.aviso-modal-body p {
279 + margin: 0 0 0.5rem;
280 + color: var(--gray-600);
281 +}
282 +
283 +html.dark-mode .aviso-modal-body p {
284 + color: #c9d1d9;
285 +}
286 +
287 +.aviso-modal-body hr {
288 + margin: 1rem 0;
289 + border: none;
290 + border-top: 1px solid var(--gray-200);
291 +}
292 +
293 +.aviso-modal-foot {
294 + padding: 0.85rem 1.25rem;
295 + border-top: 1px solid var(--gray-200);
296 + text-align: right;
297 +}
298 +
299 +/* ---------- Checkbox de aceptaci├│n (registro) ---------- */
300 +.terminos-checkbox {
301 + display: flex;
302 + gap: 0.5rem;
303 + align-items: flex-start;
304 + font-size: 0.85rem;
305 + color: var(--gray-600);
306 + line-height: 1.4;
307 + margin-top: 0.25rem;
308 +}
309 +.terminos-checkbox input {
310 + margin-top: 0.15rem;
311 + flex-shrink: 0;
312 +}
313 +.terminos-checkbox a {
314 + cursor: pointer;
315 + color: var(--link-color);
316 + text-decoration: underline;
317 +}
318 +
frontend/css/comunidad.css
+300 -10
@@ -1,20 +1,22 @@
1 /* MAPS Connect - Comunidad y Recursos (Foro + Apuntes + Tips) */
2
3 -.academic-hub-container {
4 - --hub-accent: #005a2b;
3 +:root {
4 + --hub-accent: var(--accent-dark, #005a2b);
5 --hub-accent-dark: #004420;
6 - --hub-accent-soft: #e8f5e9;
6 + --hub-accent-soft: var(--accent-soft, #e8f5e9);
7 --hub-border: #dcdfe6;
8 --hub-bg: #f4f6f8;
9 --hub-text: #1a1a1a;
10 --hub-muted: #666;
11 +}
12
13 +.academic-hub-container {
14 max-width: 1050px;
15 margin: 0 auto;
16 width: 100%;
17 }
18
17 -/* Bordes rectos institucionales (scopeado a la página) */
19 +/* Bordes rectos institucionales (scopeado a la página) */
20 .academic-hub-container .card,
21 .academic-hub-container .input-search,
22 .academic-hub-container .select-materia,
@@ -33,7 +35,20 @@
35 margin-bottom: 16px;
36 }
37
36 -/* --- 1. BARRA DE BÚSQUEDA Y FILTRO SUPERIOR --- */
38 +/* Aviso de filtro por carrera */
39 +.filter-note {
40 + display: inline-block;
41 + margin: 0 0 0.75rem;
42 + padding: 0.35rem 0.625rem;
43 + background: var(--hub-accent-soft);
44 + border: 1px solid var(--accent-soft-border, #c8e6c9);
45 + color: var(--hub-accent-dark);
46 + font-size: 0.83rem;
47 +}
48 +
49 +.filter-note[hidden] { display: none; }
50 +
51 +/* --- 1. BARRA DE BÚSQUEDA Y FILTRO SUPERIOR --- */
52 .hub-filter-bar {
53 display: grid;
54 grid-template-columns: 2fr 1.2fr auto;
@@ -62,6 +77,31 @@
77 border-color: var(--hub-accent);
78 }
79
80 +/* Selector de archivo en el modal de apuntes */
81 +.file-pick-row {
82 + display: flex;
83 + align-items: center;
84 + gap: 10px;
85 +}
86 +
87 +.file-pick-row input[type="file"] {
88 + padding: 9px 14px;
89 + border: 1px solid var(--hub-border);
90 + background: #ffffff;
91 + font-size: 0.88rem;
92 + color: var(--hub-text);
93 + max-width: 60%;
94 +}
95 +
96 +.file-pick-nombre {
97 + font-size: 0.78rem;
98 + color: var(--hub-muted);
99 + white-space: nowrap;
100 + overflow: hidden;
101 + text-overflow: ellipsis;
102 + max-width: 40%;
103 +}
104 +
105 .btn-solid {
106 background-color: var(--hub-accent);
107 color: #ffffff;
@@ -91,7 +131,7 @@
131 background-color: var(--hub-accent-soft);
132 }
133
94 -/* --- 2. PESTAÑAS DE SUB-NAVEGACIÓN --- */
134 +/* --- 2. PESTAÑAS DE SUB-NAVEGACIÓN --- */
135 .hub-tabs {
136 display: flex;
137 border-bottom: 2px solid var(--hub-border);
@@ -142,11 +182,11 @@
182 .badge-materia {
183 background-color: var(--hub-accent-soft);
184 color: var(--hub-accent);
145 - border: 1px solid #c8e6c9;
185 + border: 1px solid var(--accent-soft-border, #c8e6c9);
186 }
187
188 .badge-solved {
149 - background-color: #dcfce7;
189 + background-color: var(--accent-soft, #dcfce7);
190 color: #166534;
191 border: 1px solid #bbf7d0;
192 }
@@ -172,6 +212,44 @@
212 color: #888;
213 }
214
215 +.hub-avatar {
216 + width: 38px;
217 + height: 38px;
218 + background-color: var(--accent-dark, #005a2b);
219 + color: #ffffff;
220 + display: flex;
221 + align-items: center;
222 + justify-content: center;
223 + font-weight: bold;
224 + font-size: 0.85rem;
225 + flex-shrink: 0;
226 + border-radius: 0;
227 +}
228 +
229 +.hub-avatar.avatar-foto {
230 + object-fit: cover;
231 + width: 38px;
232 + height: 38px;
233 +}
234 +
235 +.foro-autor {
236 + display: flex;
237 + align-items: center;
238 + gap: 10px;
239 +}
240 +
241 +.foro-autor-line {
242 + display: flex;
243 + align-items: center;
244 + gap: 8px;
245 + margin-bottom: 2px;
246 +}
247 +
248 +.foro-autor-line strong {
249 + font-size: 0.9rem;
250 + color: #222;
251 +}
252 +
253 .post-title {
254 font-size: 1.1rem;
255 color: #111;
@@ -254,7 +332,7 @@
332 justify-content: center;
333 font-weight: bold;
334 font-size: 0.85rem;
257 - border: 1px solid #c8e6c9;
335 + border: 1px solid var(--accent-soft-border, #c8e6c9);
336 flex-shrink: 0;
337 }
338
@@ -269,7 +347,7 @@
347 color: var(--hub-muted);
348 }
349
272 -/* --- ESTILOS: TIPS ACADÉMICOS --- */
350 +/* --- ESTILOS: TIPS ACADÉMICOS --- */
351 .tip-grid {
352 display: grid;
353 grid-template-columns: 1fr 1fr;
@@ -321,3 +399,215 @@
399 font-weight: bold;
400 color: var(--hub-accent);
401 }
402 +
403 +.vote-btn {
404 + background: none;
405 + border: 1px solid transparent;
406 + color: var(--hub-accent);
407 + font-size: 0.82rem;
408 + font-weight: bold;
409 + cursor: pointer;
410 + padding: 4px 8px;
411 + border-radius: 0;
412 +}
413 +
414 +.vote-btn:hover {
415 + border-color: var(--hub-accent);
416 + background-color: var(--hub-accent-soft);
417 +}
418 +
419 +.vote-btn.votado {
420 + background-color: var(--hub-accent);
421 + color: #ffffff;
422 +}
423 +
424 +/* --- MODAL (compartir tip / nueva duda / subir apunte) --- */
425 +.hub-modal-overlay {
426 + position: fixed;
427 + inset: 0;
428 + background: rgba(0, 0, 0, 0.45);
429 + display: flex;
430 + align-items: flex-start;
431 + justify-content: center;
432 + padding: 60px 16px 16px;
433 + z-index: 1000;
434 +}
435 +
436 +.hub-modal {
437 + background: #ffffff;
438 + max-width: 520px;
439 + width: 100%;
440 + max-height: calc(100vh - 90px);
441 + overflow-y: auto;
442 + padding: 24px;
443 + border-top: 3px solid var(--hub-accent);
444 + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
445 +}
446 +
447 +.hub-modal h3 {
448 + margin: 0 0 4px;
449 + font-size: 1.15rem;
450 + color: #111;
451 +}
452 +
453 +.hub-modal .modal-sub {
454 + margin: 0 0 16px;
455 + font-size: 0.85rem;
456 + color: var(--hub-muted);
457 +}
458 +
459 +.hub-modal label {
460 + display: block;
461 + font-size: 0.85rem;
462 + font-weight: bold;
463 + color: #333;
464 + margin: 12px 0 6px;
465 +}
466 +
467 +.hub-modal .input-search,
468 +.hub-modal .select-materia,
469 +.hub-modal textarea {
470 + width: 100%;
471 + box-sizing: border-box;
472 +}
473 +
474 +.hub-modal textarea {
475 + min-height: 120px;
476 + padding: 10px 14px;
477 + border: 1px solid var(--hub-border);
478 + font-size: 0.9rem;
479 + font-family: inherit;
480 + color: var(--hub-text);
481 + outline: none;
482 + resize: vertical;
483 +}
484 +
485 +.hub-modal textarea:focus {
486 + border-color: var(--hub-accent);
487 +}
488 +
489 +.modal-actions {
490 + display: flex;
491 + justify-content: flex-end;
492 + gap: 10px;
493 + margin-top: 20px;
494 +}
495 +
496 +.modal-msg {
497 + font-size: 0.85rem;
498 + margin-top: 12px;
499 + min-height: 18px;
500 +}
501 +
502 +.modal-msg.ok { color: var(--hub-accent); }
503 +.modal-msg.err { color: #b3261e; }
504 +
505 +/* --- MODAL DE DUPLICADO (pregunta ya existente) --- */
506 +.dup-overlay { align-items: center; }
507 +
508 +.dup-modal {
509 + max-width: 760px;
510 + max-height: 84vh;
511 + overflow: auto;
512 +}
513 +
514 +.dup-notice {
515 + background: #fff7e6;
516 + border: 1px solid #f5d78a;
517 + padding: 14px 16px;
518 + margin-bottom: 16px;
519 +}
520 +
521 +.dup-notice .dup-titulo {
522 + display: block;
523 + color: #7a5900;
524 + font-size: 0.98rem;
525 + margin-bottom: 4px;
526 +}
527 +
528 +.dup-notice p {
529 + margin: 0;
530 + font-size: 0.85rem;
531 + color: #7a5f22;
532 +}
533 +
534 +.dup-question {
535 + border: 1px solid var(--hub-border);
536 + background: #fbfdfb;
537 + padding: 14px 16px;
538 + margin-bottom: 16px;
539 +}
540 +
541 +.dup-question .tip-title { margin: 8px 0 4px; }
542 +
543 +.dup-question .dup-por {
544 + margin: 8px 0 0;
545 + font-size: 0.78rem;
546 + color: var(--hub-muted);
547 +}
548 +
549 +.dup-sections {
550 + display: grid;
551 + grid-template-columns: 1fr 1fr;
552 + gap: 16px;
553 +}
554 +
555 +@media (max-width: 640px) {
556 + .dup-sections { grid-template-columns: 1fr; }
557 +}
558 +
559 +.dup-section-title {
560 + margin: 0 0 8px;
561 + font-size: 0.88rem;
562 + color: #333;
563 + border-bottom: 1px solid var(--hub-border);
564 + padding-bottom: 6px;
565 +}
566 +
567 +.dup-respuestas, .dup-tips, .dup-recursos {
568 + display: flex;
569 + flex-direction: column;
570 + gap: 10px;
571 +}
572 +
573 +.dup-vacio {
574 + font-size: 0.82rem;
575 + color: var(--hub-muted);
576 + margin: 4px 0;
577 +}
578 +
579 +.dup-modal .tip-card, .dup-modal .apunte-card { margin: 0; }
580 +
581 +.hub-respuesta {
582 + border: 1px solid var(--hub-border);
583 + padding: 10px 12px;
584 + background: #fff;
585 +}
586 +
587 +.respuesta-head {
588 + display: flex;
589 + justify-content: space-between;
590 + align-items: center;
591 + gap: 8px;
592 + margin-bottom: 4px;
593 +}
594 +
595 +.respuesta-autor {
596 + font-size: 0.78rem;
597 + font-weight: bold;
598 + color: #555;
599 +}
600 +
601 +.respuesta-texto {
602 + margin: 0;
603 + font-size: 0.86rem;
604 + color: var(--hub-text);
605 + white-space: pre-line;
606 +}
607 +
608 +.badge-solucion {
609 + background-color: var(--accent-soft, #dcfce7);
610 + color: #166534;
611 + border: 1px solid #bbf7d0;
612 +}
613 +
frontend/css/docente.css new
+194
@@ -0,0 +1,194 @@
1 +.docente-container { max-width: 1050px; margin: 0 auto; }
2 +
3 +.docente-header {
4 + border-left: 4px solid #0284c7;
5 + display: flex;
6 + justify-content: space-between;
7 + align-items: center;
8 + flex-wrap: wrap;
9 + gap: 16px;
10 +}
11 +
12 +.docente-header .header-info h2 { font-size: 1.3rem; }
13 +.docente-header .header-info p { font-size: 0.85rem; color: #666; margin-top: 3px; }
14 +
15 +.docente-badge {
16 + background-color: #e0f2fe;
17 + color: #0369a1;
18 + padding: 4px 10px;
19 + font-size: 0.78rem;
20 + font-weight: bold;
21 + border: 1px solid #bae6fd;
22 +}
23 +
24 +.docente-kpi-grid {
25 + display: grid;
26 + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
27 + gap: 16px;
28 + margin-bottom: 20px;
29 +}
30 +
31 +.docente-kpi {
32 + background: #ffffff;
33 + border: 1px solid #dcdfe6;
34 + padding: 16px;
35 + border-top: 3px solid #0284c7;
36 +}
37 +
38 +.docente-kpi .val { font-size: 1.8rem; font-weight: bold; color: #111; }
39 +.docente-kpi .lbl { font-size: 0.8rem; color: #666; text-transform: uppercase; margin-top: 4px; }
40 +
41 +.docente-section { margin-bottom: 20px; }
42 +
43 +.docente-section-header {
44 + display: flex;
45 + justify-content: space-between;
46 + align-items: center;
47 + margin-bottom: 12px;
48 +}
49 +
50 +.docente-section-header h3 {
51 + font-size: 1.05rem;
52 + border-bottom: 1px solid #eee;
53 + padding-bottom: 6px;
54 + flex: 1;
55 +}
56 +
57 +.btn-outline {
58 + background: transparent;
59 + color: #0284c7;
60 + border: 1px solid #0284c7;
61 + padding: 6px 12px;
62 + font-size: 0.82rem;
63 + font-weight: 600;
64 + cursor: pointer;
65 +}
66 +
67 +.btn-solid {
68 + background-color: var(--accent-dark, #005a2b);
69 + color: #ffffff;
70 + border: none;
71 + padding: 8px 16px;
72 + font-size: 0.85rem;
73 + font-weight: bold;
74 + cursor: pointer;
75 +}
76 +
77 +.docente-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; margin-top: 10px; }
78 +.docente-table th { background: #f9fafb; padding: 10px; border-bottom: 2px solid #dcdfe6; text-align: left; }
79 +.docente-table td { padding: 12px 10px; border-bottom: 1px solid #ebeef5; }
80 +
81 +.docente-empty { padding: 20px; text-align: center; color: #999; font-size: 0.9rem; }
82 +
83 +.docente-meta { font-size: 0.85rem; color: #666; margin-bottom: 12px; }
84 +.docente-link { color: #0284c7; font-weight: bold; text-decoration: none; }
85 +
86 +.modal-overlay {
87 + position: fixed; inset: 0; background: rgba(15, 23, 42, 0.55);
88 + display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px;
89 +}
90 +.docente-asistencia-modal {
91 + background: #fff; border-radius: 14px; padding: 24px; width: 100%; max-width: 560px;
92 + box-shadow: 0 18px 50px rgba(0,0,0,0.25);
93 +}
94 +.docente-asistencia-modal h3 { margin: 0 0 16px; color: #0f172a; }
95 +.docente-asistencia-modal label { display: block; font-size: 0.82rem; color: #475569; margin: 10px 0 4px; font-weight: 600; }
96 +.docente-asistencia-modal input,
97 +.docente-asistencia-modal select,
98 +.docente-asistencia-modal textarea {
99 + width: 100%; padding: 9px 12px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 0.9rem; box-sizing: border-box;
100 +}
101 +.docente-asistencia-modal input:focus,
102 +.docente-asistencia-modal select:focus,
103 +.docente-asistencia-modal textarea:focus { outline: none; border-color: #0284c7; }
104 +.docente-asistencia-modal .ag-row { display: flex; gap: 12px; }
105 +.docente-asistencia-modal .ag-row > div { flex: 1; }
106 +.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
107 +
108 +/* ============================================================
109 + MODO OSCURO — Docente (inversión pareja)
110 + ============================================================ */
111 +html.dark-mode .docente-container {
112 + color: #e6edf3;
113 +}
114 +
115 +html.dark-mode .docente-header .header-info p {
116 + color: #8b949e;
117 +}
118 +
119 +html.dark-mode .docente-badge {
120 + background-color: #0b1520;
121 + color: #38bdf8;
122 + border-color: #123f5c;
123 +}
124 +
125 +html.dark-mode .docente-kpi {
126 + background: #161b22;
127 + border-color: #30363d;
128 +}
129 +
130 +html.dark-mode .docente-kpi .val {
131 + color: #e6edf3;
132 +}
133 +
134 +html.dark-mode .docente-kpi .lbl {
135 + color: #8b949e;
136 +}
137 +
138 +html.dark-mode .docente-section-header h3 {
139 + border-bottom-color: #30363d;
140 +}
141 +
142 +html.dark-mode .btn-outline {
143 + color: #38bdf8;
144 + border-color: #38bdf8;
145 +}
146 +
147 +html.dark-mode .docente-table th {
148 + background: #0d1117;
149 + color: #c9d1d9;
150 + border-bottom-color: #30363d;
151 +}
152 +
153 +html.dark-mode .docente-table td {
154 + border-bottom-color: #21262d;
155 + color: #c9d1d9;
156 +}
157 +
158 +html.dark-mode .docente-empty {
159 + color: #8b949e;
160 +}
161 +
162 +html.dark-mode .docente-meta {
163 + color: #8b949e;
164 +}
165 +
166 +html.dark-mode .docente-link {
167 + color: #38bdf8;
168 +}
169 +
170 +html.dark-mode .docente-asistencia-modal {
171 + background: #161b22;
172 +}
173 +
174 +html.dark-mode .docente-asistencia-modal h3 {
175 + color: #e6edf3;
176 +}
177 +
178 +html.dark-mode .docente-asistencia-modal label {
179 + color: #8b949e;
180 +}
181 +
182 +html.dark-mode .docente-asistencia-modal input,
183 +html.dark-mode .docente-asistencia-modal select,
184 +html.dark-mode .docente-asistencia-modal textarea {
185 + background: #0d1117;
186 + color: #e6edf3;
187 + border-color: #30363d;
188 +}
189 +
190 +html.dark-mode .docente-asistencia-modal input:focus,
191 +html.dark-mode .docente-asistencia-modal select:focus,
192 +html.dark-mode .docente-asistencia-modal textarea:focus {
193 + border-color: #38bdf8;
194 +}
frontend/css/empresarial.css
+48 -3
@@ -6,6 +6,7 @@
6 .module-heading p { margin-top: 0.25rem; color: var(--gray-600); }
7 .empresarial-container .card, .empresarial-container .btn, .empresarial-container .badge, .empresarial-container input, .empresarial-container select { border-radius: 0; }
8 .filter-bar { display: grid; grid-template-columns: 2fr 1fr 1fr auto; gap: 0.625rem; margin-bottom: 1.5rem; }
9 +.filter-note { display: inline-block; margin-top: 0.5rem; padding: 0.35rem 0.625rem; background: var(--accent-soft, #e8f5e9); border: 1px solid var(--accent-soft-border, #c8e6c9); color: var(--sidebar-bg); font-size: 0.83rem; }
10 .input-search, .select-filter { min-width: 0; padding: 0.625rem 0.75rem; border: 1px solid var(--gray-300); background: white; font: inherit; font-size: 0.9rem; }
11 .input-search:focus, .select-filter:focus { outline: none; border-color: var(--sidebar-bg); }
12 .company-card { margin-bottom: 1.75rem; padding: 1.25rem; }
@@ -19,7 +20,7 @@
20 .rating-count { display: block; font-size: 0.8rem; }
21 .company-desc { margin-bottom: 0.75rem; color: var(--gray-600); font-size: 0.9rem; line-height: 1.5; }
22 .tags-group { display: flex; flex-wrap: wrap; gap: 0.375rem; margin-bottom: 0.875rem; }
22 -.badge-green { border: 1px solid #c8e6c9; background: #e8f5e9; color: var(--sidebar-bg); }
23 +.badge-green { border: 1px solid var(--accent-soft-border, #c8e6c9); background: var(--accent-soft, #e8f5e9); color: var(--sidebar-bg); }
24 .badge-blue { border: 1px solid #bae6fd; background: #e0f2fe; color: #0284c7; }
25 .experience-box { margin-top: 0.875rem; border-left: 3px solid var(--sidebar-bg); background: var(--gray-50); padding: 0.75rem 0.875rem; }
26 .experience-author { display: block; margin-bottom: 0.25rem; font-size: 0.85rem; }
@@ -28,11 +29,55 @@
29 .empresarial-container .btn-solid { display: inline-block; border: 1px solid var(--sidebar-bg); background: var(--sidebar-bg); padding: 0.5rem 1rem; color: white; font: inherit; font-size: 0.9rem; font-weight: 700; cursor: pointer; text-align: center; }
30 .empresarial-container .btn-solid:hover { background: #043b10; color: white; text-decoration: none; }
31 .empresarial-container .btn-clean { display: inline-block; border: 1px solid var(--sidebar-bg); background: transparent; padding: 0.45rem 0.875rem; color: var(--sidebar-bg); font: inherit; font-size: 0.85rem; font-weight: 600; cursor: pointer; text-align: center; }
31 -.empresarial-container .btn-clean:hover { background: #f0fdf4; text-decoration: none; }
32 +.empresarial-container .btn-clean:hover { background: var(--accent-soft, #f0fdf4); text-decoration: none; }
33 .empty-companies { border-left: 4px solid var(--gray-300); text-align: center; }
34 .empty-companies h1 { font-size: 1.1rem; }
35 .empty-companies p { margin: 0.5rem 0 0; color: var(--gray-600); }
36 .loading-message { color: var(--gray-600); }
37
38 +/* Modal de experiencias y rese├▒as */
39 +.empresa-modal-open { overflow: hidden; }
40 +.empresa-modal-backdrop { position: fixed; inset: 0; z-index: 1200; background: rgba(0, 0, 0, 0.45); display: flex; align-items: flex-start; justify-content: center; padding: 2rem 1rem; overflow-y: auto; }
41 +.empresa-modal { background: white; width: 100%; max-width: 640px; max-height: calc(100vh - 4rem); display: flex; flex-direction: column; border-radius: 0; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); }
42 +.empresa-modal-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--gray-200); }
43 +.empresa-modal-title { margin: 0; font-size: 1.25rem; color: var(--sidebar-bg); }
44 +.empresa-modal-sub { display: block; margin-top: 0.25rem; color: var(--gray-600); font-size: 0.85rem; }
45 +.empresa-modal-close { border: none; background: transparent; font-size: 1.75rem; line-height: 1; color: var(--gray-500); cursor: pointer; padding: 0 0.25rem; }
46 +.empresa-modal-close:hover { color: var(--sidebar-bg); }
47 +.empresa-modal-body { padding: 1.25rem 1.5rem; overflow-y: auto; }
48 +.empresa-modal-footer { display: flex; justify-content: flex-end; padding: 1rem 1.5rem; border-top: 1px solid var(--gray-200); }
49 +
50 +.experiencias-section { margin-bottom: 1.5rem; }
51 +.experiencias-section h3 { margin: 0 0 0.75rem; font-size: 1rem; color: var(--sidebar-bg); }
52 +.review-item { border: 1px solid var(--gray-200); padding: 0.875rem 1rem; margin-bottom: 0.75rem; background: var(--gray-50); }
53 +.review-item.review-mine { border-color: #a5d6a7; background: #f1f8f1; }
54 +.review-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 0.5rem; }
55 +.review-stars { color: var(--warning-color); font-weight: 700; letter-spacing: 1px; white-space: nowrap; }
56 +.review-author { font-size: 0.9rem; }
57 +.review-date { margin-left: 0.5rem; color: var(--gray-600); font-size: 0.8rem; }
58 +.review-field { margin: 0.375rem 0 0; color: var(--gray-600); font-size: 0.88rem; line-height: 1.45; }
59 +.review-field strong { color: var(--gray-800); }
60 +.empty-experiences { color: var(--gray-600); font-size: 0.9rem; }
61 +.my-experience-note { color: var(--sidebar-bg); font-size: 0.9rem; }
62 +.my-experience-note .link-action { border: none; background: transparent; padding: 0; color: var(--sidebar-bg); font: inherit; font-size: 0.9rem; font-weight: 700; text-decoration: underline; cursor: pointer; }
63 +.review-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.75rem; border-top: 1px solid var(--gray-200); padding-top: 0.625rem; }
64 +.review-actions .btn-clean { padding: 0.3rem 0.625rem; font-size: 0.78rem; }
65 +.review-actions .btn-danger { border-color: #dc2626; color: #dc2626; }
66 +.review-actions .btn-danger:hover { background: #fef2f2; }
67 +
68 +.resena-form-wrap { border-top: 1px solid var(--gray-200); padding-top: 1.25rem; }
69 +.resena-title { margin: 0 0 1rem; font-size: 1rem; color: var(--sidebar-bg); }
70 +.resena-form .field-row { margin-bottom: 0.875rem; }
71 +.resena-label { display: block; margin-bottom: 0.375rem; font-size: 0.85rem; font-weight: 700; color: var(--gray-700); }
72 +.star-picker { display: inline-flex; gap: 0.25rem; }
73 +.star-btn { border: none; background: transparent; font-size: 1.75rem; line-height: 1; color: var(--gray-300); cursor: pointer; padding: 0; }
74 +.star-btn.is-selected { color: var(--warning-color); }
75 +.star-picker-label { margin-left: 0.75rem; color: var(--gray-500); font-size: 0.82rem; }
76 +.resena-input { width: 100%; border: 1px solid var(--gray-300); background: white; padding: 0.625rem 0.75rem; font: inherit; font-size: 0.9rem; resize: vertical; border-radius: 0; }
77 +.resena-input:focus { outline: none; border-color: var(--sidebar-bg); }
78 +.resena-actions { display: flex; justify-content: flex-end; margin-bottom: 0; }
79 +.resena-form .btn-solid:disabled { opacity: 0.6; cursor: not-allowed; }
80 +
81 @media (max-width: 768px) { .filter-bar { grid-template-columns: 1fr; } }
38 -@media (max-width: 560px) { .company-header, .company-footer { align-items: flex-start; flex-direction: column; } .rating-box { text-align: left; } .company-footer .btn-solid, .company-footer .btn-clean { width: 100%; } }
82 +@media (max-width: 560px) { .company-header, .company-footer { align-items: flex-start; flex-direction: column; } .rating-box { text-align: left; } .company-footer .btn-solid, .company-footer .btn-clean { width: 100%; } .empresa-modal-header, .empresa-modal-body, .empresa-modal-footer { padding-left: 1rem; padding-right: 1rem; } }
83 +
frontend/css/estilos.css new
+9462
@@ -0,0 +1,9462 @@
1 +/* ============================================================
2 + MAPS Connect - Estilos visuales (skins)
3 + Cada estilo se activa con html[data-estilo="..."] sobre <html>.
4 + El estilo por defecto es "clasico" (sin bloque: usa las hojas
5 + normales). Los skins de aquí van después de las demás hojas para
6 + ganar el empate en especificidad con el mismo atributo como clase.
7 + ============================================================ */
8 +
9 +/* ------------------------------------------------------------
10 + ESTILO: CYBERPUNK NETWORK
11 + Red neuronal de neón (cian/magenta) sobre fondo de terminal.
12 + ------------------------------------------------------------ */
13 +
14 +/* --- Paleta global del skin (sincroniza los componentes que usan --accent) --- */
15 +html[data-estilo="cyber"] {
16 + --accent: #00f0ff;
17 + --accent-dark: #0891b2;
18 + --accent-mid: #22d3ee;
19 + --accent-bright: #67e8f9;
20 + --accent-deep: #164e63;
21 + --accent-soft: #0b1626;
22 + --accent-soft-border: #164e63;
23 + --accent-contrast: #010204;
24 +
25 + --primary-color: #00f0ff;
26 + --secondary-color: #ff007f;
27 + --link-color: #00f0ff;
28 + --heading-color: #ffffff;
29 + --text-color: #e6edf3;
30 +
31 + --sidebar-bg: #070a14;
32 + --sidebar-border: #00f0ff;
33 + --sidebar-text: #8b949e;
34 + --sidebar-text-hover: #ffffff;
35 + --sidebar-item-hover-bg: rgba(0, 240, 255, 0.1);
36 + --sidebar-item-active-bg: rgba(0, 240, 255, 0.22);
37 + --sidebar-accent: #ff007f;
38 +
39 + --topbar-bg: rgba(4, 7, 12, 0.85);
40 +
41 + --btn-primary-bg: linear-gradient(135deg, #1a0033 0%, #ff007f 100%);
42 + --btn-primary-text: #ffffff;
43 + --btn-outline-border: #00f0ff;
44 + --btn-outline-text: #00f0ff;
45 +}
46 +
47 +/* --- Tipografía y geometría --- */
48 +html[data-estilo="cyber"] * {
49 + font-family: 'Courier New', Courier, monospace, sans-serif;
50 + border-radius: 0 !important;
51 +}
52 +
53 +html[data-estilo="cyber"] body {
54 + background-color: #010204;
55 + color: #00f0ff;
56 + position: relative;
57 +}
58 +
59 +html[data-estilo="cyber"] a { color: #00f0ff; }
60 +
61 +html[data-estilo="cyber"] h1,
62 +html[data-estilo="cyber"] h2,
63 +html[data-estilo="cyber"] h3,
64 +html[data-estilo="cyber"] h4 {
65 + color: #ffffff;
66 + letter-spacing: 0.5px;
67 +}
68 +
69 +html[data-estilo="cyber"] .page-title { text-shadow: 0 0 12px rgba(0, 240, 255, 0.5); }
70 +html[data-estilo="cyber"] .page-description { color: #8b949e; }
71 +
72 +/* La app queda encima del fondo neuronal */
73 +html[data-estilo="cyber"] .app-layout {
74 + position: relative;
75 + z-index: 2;
76 +}
77 +
78 +/* ============================================================
79 + FONDO DE RED NEURONAL (parallax 3D injected por estilos.js)
80 + ============================================================ */
81 +html[data-estilo="cyber"] .cyber-bg-viewport {
82 + position: fixed;
83 + top: 0; left: 0;
84 + width: 100vw;
85 + height: 100vh;
86 + pointer-events: none;
87 + z-index: 0;
88 + overflow: hidden;
89 + transform-style: preserve-3d;
90 +}
91 +
92 +html[data-estilo="cyber"] .cyber-svg-layer {
93 + position: absolute;
94 + top: -10%;
95 + left: -10%;
96 + width: 120vw;
97 + height: 120vh;
98 + transform: translate3d(0px, 0px, 0px) scale(1) rotateX(15deg);
99 + transition: transform 1.6s cubic-bezier(0.2, 1, 0.3, 1), filter 1.6s ease;
100 + filter: drop-shadow(0 0 40px rgba(0, 240, 255, 0.3));
101 + transform-style: preserve-3d;
102 +}
103 +
104 +html[data-estilo="cyber"] .data-node {
105 + fill: #ffffff;
106 + stroke: #00f0ff;
107 + stroke-width: 1.5;
108 + filter: drop-shadow(0 0 8px #00f0ff);
109 + animation: nodePulse 3s infinite alternate;
110 +}
111 +
112 +html[data-estilo="cyber"] .data-link {
113 + stroke: rgba(0, 240, 255, 0.4);
114 + stroke-width: 1;
115 + animation: linkPulse 3s infinite alternate;
116 +}
117 +
118 +html[data-estilo="cyber"] .core-node {
119 + fill: #ff007f;
120 + stroke: #ffffff;
121 + stroke-width: 2;
122 + filter: drop-shadow(0 0 15px #ff007f);
123 + animation: corePulse 2s infinite alternate;
124 +}
125 +
126 +@keyframes nodePulse {
127 + to { filter: drop-shadow(0 0 12px #00f0ff); opacity: 1; }
128 +}
129 +
130 +@keyframes linkPulse {
131 + to { stroke: rgba(0, 240, 255, 0.7); stroke-width: 1.5; }
132 +}
133 +
134 +@keyframes corePulse {
135 + to { filter: drop-shadow(0 0 25px #ff007f); transform: scale(1.05); }
136 +}
137 +
138 +html[data-estilo="cyber"] .atmosphere-overlay {
139 + position: fixed;
140 + top: 0; left: 0;
141 + width: 100vw;
142 + height: 100vh;
143 +background:
144 + radial-gradient(circle at 70% 20%, rgba(255, 0, 128, 0.1) 0%, rgba(1, 2, 4, 0.55) 75%),
145 + linear-gradient(to bottom, rgba(1, 2, 4, 0.15) 0%, rgba(1, 2, 4, 0.6) 100%);
146 + pointer-events: none;
147 + z-index: 1;
148 +}
149 +
150 +/* ============================================================
151 + SIDEBAR CIBERNÉTICO
152 + ============================================================ */
153 +html[data-estilo="cyber"] .sidebar {
154 + background: linear-gradient(180deg, #070a14 0%, #010204 100%);
155 + border-right: 2px solid #00f0ff;
156 + box-shadow: 20px 0 60px rgba(0, 0, 0, 0.95);
157 +}
158 +
159 +html[data-estilo="cyber"] .sidebar-header {
160 + border-bottom: 1px solid rgba(0, 240, 255, 0.3);
161 +}
162 +
163 +html[data-estilo="cyber"] .sidebar-header h1 {
164 + color: #ffffff;
165 + font-weight: 900;
166 + letter-spacing: 2px;
167 + text-transform: uppercase;
168 + text-shadow: 0 0 10px rgba(0, 240, 255, 0.5);
169 +}
170 +
171 +html[data-estilo="cyber"] .sidebar-header small {
172 + color: #8b949e;
173 +}
174 +
175 +html[data-estilo="cyber"] .sidebar-user {
176 + background: rgba(0, 240, 255, 0.08);
177 + border: 1px solid rgba(0, 240, 255, 0.3);
178 +}
179 +
180 +html[data-estilo="cyber"] .sidebar-user small { color: #ffffff; }
181 +
182 +html[data-estilo="cyber"] .sidebar-avatar {
183 + border: 1px solid #00f0ff;
184 + box-shadow: 0 0 8px rgba(0, 240, 255, 0.5);
185 +}
186 +
187 +html[data-estilo="cyber"] .sidebar-nav a {
188 + color: #8b949e;
189 + border-left: 3px solid transparent;
190 + text-transform: uppercase;
191 + letter-spacing: 0.5px;
192 + transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.25s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), color 0.25s cubic-bezier(0.16, 1, 0.3, 1);
193 +}
194 +
195 +html[data-estilo="cyber"] .sidebar-nav a:hover {
196 + background: rgba(0, 240, 255, 0.1);
197 + color: #ffffff;
198 + border-left-color: #00f0ff;
199 + padding-left: 16px;
200 +}
201 +
202 +html[data-estilo="cyber"] .sidebar-nav a.active {
203 + background: rgba(0, 240, 255, 0.22);
204 + color: #ffffff;
205 + border-left-color: #ff007f;
206 + box-shadow: inset 0 0 12px rgba(0, 240, 255, 0.3);
207 +}
208 +
209 +html[data-estilo="cyber"] .sidebar-footer {
210 + border-top: 1px solid rgba(0, 240, 255, 0.25);
211 +}
212 +
213 +html[data-estilo="cyber"] .sidebar-footer .btn {
214 + background: #1a0033;
215 + border: 1px solid #ff007f;
216 + color: #ff007f;
217 + text-transform: uppercase;
218 + font-weight: bold;
219 +}
220 +
221 +html[data-estilo="cyber"] .sidebar-footer .btn:hover {
222 + background: #ff007f;
223 + color: #ffffff;
224 + box-shadow: 0 0 20px rgba(255, 0, 127, 0.8);
225 +}
226 +
227 +/* ============================================================
228 + TOPBAR
229 + ============================================================ */
230 +html[data-estilo="cyber"] .topbar {
231 + background: var(--topbar-bg);
232 + border-bottom: 1px solid rgba(0, 240, 255, 0.35);
233 + backdrop-filter: none;
234 +}
235 +
236 +html[data-estilo="cyber"] .menu-toggle {
237 + color: #00f0ff;
238 + background: rgba(10, 14, 22, 0.85);
239 + border: 1px solid #1f3044;
240 +}
241 +
242 +html[data-estilo="cyber"] .menu-toggle:hover {
243 + border-color: #ff007f;
244 + box-shadow: 0 0 20px rgba(255, 0, 127, 0.5);
245 +}
246 +
247 +html[data-estilo="cyber"] .menu-backdrop {
248 + background: rgba(0, 0, 0, 0.6);
249 +}
250 +
251 +/* --- Global search --- */
252 +html[data-estilo="cyber"] .global-search-input {
253 + background: rgba(10, 14, 22, 0.85);
254 + border: 1px solid #1f3044;
255 + color: #00f0ff;
256 + box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.8);
257 +}
258 +
259 +html[data-estilo="cyber"] .global-search-input::placeholder { color: #44536b; }
260 +
261 +html[data-estilo="cyber"] .global-search-input:focus {
262 + border-color: #00f0ff;
263 + box-shadow: 0 0 20px rgba(0, 240, 255, 0.3), inset 0 0 8px rgba(0, 0, 0, 0.8);
264 +}
265 +
266 +html[data-estilo="cyber"] .global-search-dropdown {
267 + background: #070b14;
268 + border: 1px solid #1f3044;
269 + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.9);
270 +}
271 +
272 +html[data-estilo="cyber"] .global-search-item { border-bottom: 1px solid #161f2e; }
273 +html[data-estilo="cyber"] .global-search-item:hover { background: #0d1526; }
274 +
275 +html[data-estilo="cyber"] .global-search-info strong { color: #ffffff; }
276 +html[data-estilo="cyber"] .global-search-info span { color: #8b949e; }
277 +
278 +html[data-estilo="cyber"] .global-search-chat {
279 + border: 1px solid #00f0ff;
280 + color: #00f0ff;
281 + background: rgba(0, 240, 255, 0.08);
282 +}
283 +
284 +html[data-estilo="cyber"] .global-search-chat:hover {
285 + background: #00f0ff;
286 + color: #010204;
287 +}
288 +
289 +html[data-estilo="cyber"] .global-search-empty { color: #8b949e; }
290 +
291 +/* --- Notificaciones --- */
292 +html[data-estilo="cyber"] .notif-toggle {
293 + background: rgba(10, 14, 22, 0.85);
294 + border: 1px solid #1f3044;
295 + color: #00f0ff;
296 + box-shadow: 0 0 10px rgba(0, 0, 0, 0.8);
297 +}
298 +
299 +html[data-estilo="cyber"] .notif-toggle:hover {
300 + transform: scale(1.1);
301 + border-color: #ff007f;
302 + box-shadow: 0 0 20px rgba(255, 0, 127, 0.5);
303 + background: rgba(26, 0, 51, 0.9);
304 +}
305 +
306 +html[data-estilo="cyber"] .notif-badge {
307 + background: #ff007f;
308 + color: #ffffff;
309 + box-shadow: 0 0 8px #ff007f;
310 +}
311 +
312 +html[data-estilo="cyber"] .notif-panel {
313 + background: #070b14;
314 + border: 1px solid #1f3044;
315 + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.9);
316 +}
317 +
318 +html[data-estilo="cyber"] .notif-panel-head { border-bottom: 1px solid #161f2e; }
319 +html[data-estilo="cyber"] .notif-marcar-todas { color: #00f0ff; }
320 +html[data-estilo="cyber"] .notif-marcar-todas:hover { color: #ffffff; }
321 +html[data-estilo="cyber"] .notif-item:hover { background: #0d1526; }
322 +html[data-estilo="cyber"] .notif-item-info strong { color: #ffffff; }
323 +html[data-estilo="cyber"] .notif-item-info span { color: #8b949e; }
324 +html[data-estilo="cyber"] .notif-item-badge {
325 + background: rgba(255, 0, 127, 0.2);
326 + color: #ff8ac2;
327 +}
328 +html[data-estilo="cyber"] .notif-vacio { color: #8b949e; }
329 +
330 +/* ============================================================
331 + CONTENIDO Y COMPONENTES
332 + ============================================================ */
333 +html[data-estilo="cyber"] .main-content { background: transparent; }
334 +
335 +/* Entrada escalonada de las secciones (viewEntrance del mockup v8.0) */
336 +html[data-estilo="cyber"] .main-content > * {
337 + opacity: 0;
338 + transform: translateY(22px) scale(0.98);
339 + animation: viewEntrance 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
340 +}
341 +
342 +html[data-estilo="cyber"] .main-content > *:nth-child(1) { animation-delay: 0.05s; }
343 +html[data-estilo="cyber"] .main-content > *:nth-child(2) { animation-delay: 0.12s; }
344 +html[data-estilo="cyber"] .main-content > *:nth-child(3) { animation-delay: 0.19s; }
345 +html[data-estilo="cyber"] .main-content > *:nth-child(4) { animation-delay: 0.26s; }
346 +
347 +@keyframes viewEntrance {
348 + to {
349 + opacity: 1;
350 + transform: translateY(0) scale(1);
351 + }
352 +}
353 +
354 +html[data-estilo="cyber"] .card {
355 + background: linear-gradient(135deg, rgba(10, 16, 26, 0.92) 0%, rgba(4, 7, 12, 0.96) 100%);
356 + border: 1px solid #1f3044;
357 + border-left: 4px solid #00f0ff;
358 + box-shadow: 0 15px 40px rgba(0, 0, 0, 0.8);
359 + position: relative;
360 + overflow: hidden;
361 + backdrop-filter: none;
362 + transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.35s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.35s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.35s cubic-bezier(0.16, 1, 0.3, 1), color 0.35s cubic-bezier(0.16, 1, 0.3, 1);
363 +}
364 +
365 +/* Marca "//" esquina superior derecha de las tarjetas (mockup v8.0) */
366 +html[data-estilo="cyber"] .card::after {
367 + content: '//';
368 + position: absolute;
369 + top: 10px;
370 + right: 15px;
371 + font-size: 1.2rem;
372 + color: #1f3044;
373 + pointer-events: none;
374 +}
375 +
376 +html[data-estilo="cyber"] .card:hover {
377 + border-color: #00f0ff;
378 + border-left-color: #00f0ff;
379 + box-shadow: 0 20px 50px rgba(0, 240, 255, 0.25);
380 + transform: translateY(-3px);
381 +}
382 +
383 +html[data-estilo="cyber"] .card-title {
384 + color: #ffffff;
385 + font-weight: 900;
386 +}
387 +
388 +/* --- Botones --- */
389 +html[data-estilo="cyber"] .btn {
390 + border: 1px solid #1f3044;
391 + color: #c9d1d9;
392 + background: rgba(10, 14, 22, 0.9);
393 + text-transform: uppercase;
394 + letter-spacing: 0.5px;
395 + font-weight: bold;
396 + transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.25s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), color 0.25s cubic-bezier(0.16, 1, 0.3, 1);
397 +}
398 +
399 +html[data-estilo="cyber"] .btn:hover {
400 + border-color: #00f0ff;
401 + box-shadow: 0 0 15px rgba(0, 240, 255, 0.3);
402 + color: #00f0ff;
403 +}
404 +
405 +html[data-estilo="cyber"] .btn-primary,
406 +html[data-estilo="cyber"] .btn-primary:hover {
407 + background: linear-gradient(135deg, #1a0033 0%, #ff007f 100%);
408 + border: 1px solid #00f0ff;
409 + color: #ffffff;
410 + box-shadow: 0 0 15px rgba(255, 0, 127, 0.5);
411 + animation: cyberPulse 3s infinite;
412 +}
413 +
414 +html[data-estilo="cyber"] .btn-primary:hover { transform: scale(1.03); }
415 +
416 +@keyframes cyberPulse {
417 + 0%, 100% { box-shadow: 0 0 15px rgba(255, 0, 127, 0.5); }
418 + 50% { box-shadow: 0 0 25px rgba(0, 240, 255, 0.8); }
419 +}
420 +
421 +html[data-estilo="cyber"] .btn-secondary {
422 + background: rgba(10, 14, 22, 0.9);
423 + border: 1px solid #1f3044;
424 + color: #00f0ff;
425 +}
426 +
427 +html[data-estilo="cyber"] .btn-outline {
428 + background: transparent;
429 + border: 1px solid #00f0ff;
430 + color: #00f0ff;
431 +}
432 +
433 +html[data-estilo="cyber"] .btn-outline:hover {
434 + background: #00f0ff;
435 + color: #010204;
436 + box-shadow: 0 0 20px rgba(0, 240, 255, 0.8);
437 +}
438 +
439 +html[data-estilo="cyber"] .btn-danger {
440 + border: 1px solid #ff2d55;
441 + color: #ff2d55;
442 + background: transparent;
443 +}
444 +
445 +html[data-estilo="cyber"] .btn-danger:hover {
446 + background: #ff2d55;
447 + color: #ffffff;
448 +}
449 +
450 +/* --- Formularios --- */
451 +html[data-estilo="cyber"] input,
452 +html[data-estilo="cyber"] select,
453 +html[data-estilo="cyber"] textarea,
454 +html[data-estilo="cyber"] .form-control {
455 + background: rgba(4, 7, 12, 0.9);
456 + border: 1px solid #1f3044;
457 + color: #00f0ff;
458 + box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.8);
459 + outline: none;
460 + transition: transform 0.3s, opacity 0.3s, box-shadow 0.3s, border-color 0.3s, background-color 0.3s, color 0.3s;
461 +}
462 +
463 +html[data-estilo="cyber"] input:focus,
464 +html[data-estilo="cyber"] select:focus,
465 +html[data-estilo="cyber"] textarea:focus,
466 +html[data-estilo="cyber"] .form-control:focus {
467 + border-color: #00f0ff;
468 + box-shadow: 0 0 20px rgba(0, 240, 255, 0.3), inset 0 0 8px rgba(0, 0, 0, 0.8);
469 +}
470 +
471 +html[data-estilo="cyber"] select option { background: #070b14; color: #00f0ff; }
472 +
473 +html[data-estilo="cyber"] input::placeholder,
474 +html[data-estilo="cyber"] textarea::placeholder { color: #44536b; }
475 +
476 +html[data-estilo="cyber"] label,
477 +html[data-estilo="cyber"] legend,
478 +html[data-estilo="cyber"] th { color: #e6edf3; }
479 +
480 +html[data-estilo="cyber"] td { color: #c9d1d9; }
481 +
482 +html[data-estilo="cyber"] hr { border-color: #1f3044; }
483 +
484 +/* --- Alerts, badges, avatares --- */
485 +html[data-estilo="cyber"] .alert {
486 + border: 1px solid #1f3044;
487 + background: rgba(10, 14, 22, 0.9);
488 + color: #c9d1d9;
489 +}
490 +
491 +html[data-estilo="cyber"] .alert-error {
492 + background: #2a1215;
493 + color: #fecaca;
494 + border-color: #7f1d1d;
495 +}
496 +
497 +html[data-estilo="cyber"] .alert-success {
498 + background: #052e26;
499 + color: #a7f3d0;
500 + border-color: #065f46;
501 +}
502 +
503 +html[data-estilo="cyber"] .badge-primary {
504 + background: rgba(0, 240, 255, 0.12);
505 + color: #00f0ff;
506 + border: 1px solid #00f0ff;
507 +}
508 +
509 +html[data-estilo="cyber"] .badge-success {
510 + background: rgba(0, 240, 255, 0.1);
511 + color: #00f0ff;
512 + border: 1px solid #1f3044;
513 +}
514 +
515 +html[data-estilo="cyber"] .avatar {
516 + border: 1px solid #00f0ff;
517 +}
518 +
519 +/* --- Toast --- */
520 +html[data-estilo="cyber"] .toast {
521 + background: #0b1320;
522 + color: #e6edf3;
523 + border: 1px solid #1f3044;
524 + box-shadow: 0 15px 40px rgba(0, 0, 0, 0.9);
525 +}
526 +
527 +html[data-estilo="cyber"] .toast-info { border-left: 4px solid #00f0ff; }
528 +html[data-estilo="cyber"] .toast-success { border-left: 4px solid #00f0ff; }
529 +html[data-estilo="cyber"] .toast-error { border-left: 4px solid #ff2d55; }
530 +
531 +/* --- Follow button --- */
532 +html[data-estilo="cyber"] .follow-btn {
533 + border: 1px solid #00f0ff;
534 + color: #00f0ff;
535 + background: transparent;
536 +}
537 +
538 +html[data-estilo="cyber"] .follow-btn:hover {
539 + background: rgba(0, 240, 255, 0.15);
540 +}
541 +
542 +html[data-estilo="cyber"] .follow-btn.siguiendo {
543 + background: #00f0ff;
544 + color: #010204;
545 +}
546 +
547 +/* ============================================================
548 + POP-UP DE BIENVENIDA: TERMINAL NEURAL (inicio)
549 + ============================================================ */
550 +html[data-estilo="cyber"] .welcome-overlay {
551 + background-color: rgba(1, 2, 4, 0.97);
552 + backdrop-filter: none;
553 +}
554 +
555 +html[data-estilo="cyber"] .welcome-popup {
556 + background: linear-gradient(135deg, #070b14 0%, #020305 100%);
557 + border: 2px solid #00f0ff;
558 + box-shadow: 0 0 80px rgba(0, 240, 255, 0.35), inset 0 0 30px rgba(0, 240, 255, 0.15);
559 + overflow: hidden;
560 +}
561 +
562 +html[data-estilo="cyber"] .welcome-popup::after {
563 + background: linear-gradient(90deg, transparent, #ff007f, #00f0ff, transparent);
564 + box-shadow: none;
565 + animation: terminalScan 2s linear infinite;
566 +}
567 +
568 +html[data-estilo="cyber"] .popup-tag {
569 + color: #ff007f;
570 + letter-spacing: 2px;
571 +}
572 +
573 +html[data-estilo="cyber"] .pulse-mini {
574 + background-color: #ff007f;
575 + box-shadow: 0 0 6px #ff007f;
576 +}
577 +
578 +html[data-estilo="cyber"] .popup-title {
579 + color: #ffffff;
580 + letter-spacing: 1px;
581 + text-shadow: 0 0 10px rgba(0, 240, 255, 0.5);
582 +}
583 +
584 +html[data-estilo="cyber"] .popup-username {
585 + background: rgba(0, 240, 255, 0.1);
586 + border: 1px solid rgba(0, 240, 255, 0.4);
587 + color: #00f0ff;
588 + box-shadow: 0 0 12px rgba(0, 240, 255, 0.3);
589 +}
590 +
591 +html[data-estilo="cyber"] .popup-status { color: #8b949e; }
592 +
593 +html[data-estilo="cyber"] .welcome-popup,
594 +html[data-estilo="cyber"] .popup-tag,
595 +html[data-estilo="cyber"] .popup-title,
596 +html[data-estilo="cyber"] .popup-username,
597 +html[data-estilo="cyber"] .popup-status { text-align: center; }
598 +
599 +/* ============================================================
600 + MATERIAL COMPLETO DEL MOCKUP "CYBERPUNK NETWORK v8.0"
601 + (transiciones, animaciones y componentes del prototipo)
602 + ============================================================ */
603 +
604 +/* --- Cortina de transición entre páginas: ACORDEÓN NEURAL --- */
605 +html[data-estilo="cyber"] .cyber-blade-transition {
606 + position: fixed;
607 + top: 0;
608 + left: 0;
609 + width: 100vw;
610 + height: 100vh;
611 + display: flex;
612 + flex-direction: column;
613 + z-index: 9999;
614 + pointer-events: none;
615 + visibility: hidden;
616 +}
617 +
618 +html[data-estilo="cyber"] .cyber-blade-transition.active {
619 + visibility: visible;
620 +}
621 +
622 +html[data-estilo="cyber"] .cyber-blade-transition .accordion-panel {
623 + flex: 1;
624 + background: #0a0a12;
625 + border-bottom: 1px solid #00f0ff;
626 + transform: scaleY(0);
627 + transform-origin: center;
628 + transition: transform 0.35s cubic-bezier(0.77, 0, 0.175, 1);
629 +}
630 +
631 +html[data-estilo="cyber"] .cyber-blade-transition.active .accordion-panel {
632 + transform: scaleY(1);
633 +}
634 +
635 +/* Retrasos escalonados para el efecto de acordeón/persiana fluida */
636 +html[data-estilo="cyber"] .cyber-blade-transition .accordion-panel:nth-child(1) { transition-delay: 0.0s; }
637 +html[data-estilo="cyber"] .cyber-blade-transition .accordion-panel:nth-child(2) { transition-delay: 0.05s; }
638 +html[data-estilo="cyber"] .cyber-blade-transition .accordion-panel:nth-child(3) { transition-delay: 0.1s; }
639 +html[data-estilo="cyber"] .cyber-blade-transition .accordion-panel:nth-child(4) { transition-delay: 0.15s; }
640 +
641 +/* --- Pop-up de bienvenida del prototipo: terminal de acceso neural --- */
642 +html[data-estilo="cyber"] .cyber-welcome-overlay {
643 + position: fixed;
644 + top: 0;
645 + left: 0;
646 + width: 100vw;
647 + height: 100vh;
648 + background-color: rgba(1, 2, 4, 0.97);
649 + backdrop-filter: none;
650 + z-index: 99999;
651 + display: flex;
652 + align-items: center;
653 + justify-content: center;
654 + transition: opacity 0.8s ease, visibility 0.8s;
655 +}
656 +
657 +html[data-estilo="cyber"] .cyber-welcome-overlay.fade-out {
658 + opacity: 0;
659 + visibility: hidden;
660 + pointer-events: none;
661 +}
662 +
663 +html[data-estilo="cyber"] .terminal-box {
664 + background: linear-gradient(135deg, #070b14 0%, #020305 100%);
665 + border: 2px solid #00f0ff;
666 + box-shadow: 0 0 80px rgba(0, 240, 255, 0.35), inset 0 0 30px rgba(0, 240, 255, 0.15);
667 + padding: 45px 55px;
668 + max-width: 580px;
669 + width: 90%;
670 + position: relative;
671 + z-index: 2;
672 + overflow: hidden;
673 + animation: terminalIn 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
674 +}
675 +
676 +html[data-estilo="cyber"] .terminal-box::before {
677 + content: '';
678 + position: absolute;
679 + top: 0;
680 + left: -100%;
681 + width: 100%;
682 + height: 2px;
683 + background: linear-gradient(90deg, transparent, #ff007f, #00f0ff, transparent);
684 + animation: terminalScan 2s linear infinite;
685 +}
686 +
687 +html[data-estilo="cyber"] .terminal-header {
688 + font-size: 0.72rem;
689 + color: #ff007f;
690 + letter-spacing: 3px;
691 + text-transform: uppercase;
692 + margin-bottom: 14px;
693 + border-bottom: 1px dashed rgba(0, 240, 255, 0.3);
694 + padding-bottom: 6px;
695 +}
696 +
697 +html[data-estilo="cyber"] .terminal-grid-info {
698 + display: flex;
699 + align-items: center;
700 + gap: 20px;
701 + margin-bottom: 18px;
702 +}
703 +
704 +html[data-estilo="cyber"] .terminal-avatar {
705 + width: 80px;
706 + height: 80px;
707 + border: 2px solid #00f0ff;
708 + padding: 3px;
709 + background: #000;
710 + box-shadow: 0 0 15px rgba(0, 240, 255, 0.5);
711 + flex-shrink: 0;
712 +}
713 +
714 +html[data-estilo="cyber"] .terminal-avatar img {
715 + width: 100%;
716 + height: 100%;
717 + object-fit: cover;
718 + display: block;
719 +}
720 +
721 +html[data-estilo="cyber"] .terminal-text-area { text-align: left; }
722 +
723 +html[data-estilo="cyber"] .terminal-title {
724 + font-size: 1.4rem;
725 + font-weight: 900;
726 + color: #ffffff;
727 + letter-spacing: 1px;
728 + margin-bottom: 4px;
729 + text-shadow: 0 0 10px rgba(0, 240, 255, 0.5);
730 + text-transform: uppercase;
731 +}
732 +
733 +html[data-estilo="cyber"] .terminal-name {
734 + font-size: 0.95rem;
735 + color: #00f0ff;
736 + font-weight: bold;
737 + letter-spacing: 0.5px;
738 +}
739 +
740 +html[data-estilo="cyber"] .terminal-console-log {
741 + background: #000000;
742 + border: 1px solid #1f3044;
743 + padding: 12px;
744 + font-size: 0.78rem;
745 + color: #8b949e;
746 + text-align: left;
747 + margin-top: 14px;
748 + line-height: 1.4;
749 +}
750 +
751 +html[data-estilo="cyber"] .terminal-console-log span { color: #38bdf8; }
752 +
753 +/* --- Componentes reales de la app sintonizados al prototipo --- */
754 +
755 +/* Botón principal sólido (Publicar duda, Entrar a sala...) con pulso cyber */
756 +html[data-estilo="cyber"] .btn-solid {
757 + background: linear-gradient(135deg, #1a0033 0%, #ff007f 100%);
758 + color: #ffffff;
759 + border: 1px solid #00f0ff;
760 + box-shadow: 0 0 15px rgba(255, 0, 127, 0.5);
761 + transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1);
762 + animation: cyberPulse 3s infinite;
763 + letter-spacing: 0.5px;
764 + text-transform: uppercase;
765 + font-weight: 900;
766 +}
767 +
768 +html[data-estilo="cyber"] .btn-solid:hover {
769 + transform: scale(1.03);
770 + background: #00f0ff;
771 + color: #010204;
772 + box-shadow: 0 0 25px rgba(0, 240, 255, 0.8);
773 +}
774 +
775 +/* Enlaces de acción limpios (widget cards) */
776 +html[data-estilo="cyber"] .btn-clean,
777 +html[data-estilo="cyber"] .side-link {
778 + color: #00f0ff;
779 + border: 1px solid #00f0ff;
780 + background: transparent;
781 + transition: transform 0.3s, opacity 0.3s, box-shadow 0.3s, border-color 0.3s, background-color 0.3s, color 0.3s;
782 + text-transform: uppercase;
783 + font-size: 0.78rem;
784 + font-weight: bold;
785 +}
786 +
787 +html[data-estilo="cyber"] .btn-clean:hover,
788 +html[data-estilo="cyber"] .side-link:hover {
789 + background: #00f0ff;
790 + color: #010204;
791 + box-shadow: 0 0 20px rgba(0, 240, 255, 0.8);
792 +}
793 +
794 +/* Botones de acción en la tarjeta de publicación */
795 +html[data-estilo="cyber"] .btn-action {
796 + border: 1px solid transparent;
797 + color: #8b949e;
798 + background: transparent;
799 + transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.25s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), color 0.25s cubic-bezier(0.16, 1, 0.3, 1);
800 + text-transform: uppercase;
801 + font-size: 0.75rem;
802 +}
803 +
804 +html[data-estilo="cyber"] .btn-action:hover {
805 + color: #00f0ff;
806 + border-color: #00f0ff;
807 + box-shadow: 0 0 12px rgba(0, 240, 255, 0.3);
808 +}
809 +
810 +html[data-estilo="cyber"] .btn-action.voted {
811 + color: #ff007f;
812 + border-color: #ff007f;
813 + box-shadow: 0 0 12px rgba(255, 0, 127, 0.4);
814 +}
815 +
816 +html[data-estilo="cyber"] .post-card,
817 +html[data-estilo="cyber"] .side-card {
818 + backdrop-filter: none;
819 + transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.35s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.35s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.35s cubic-bezier(0.16, 1, 0.3, 1), color 0.35s cubic-bezier(0.16, 1, 0.3, 1);
820 +}
821 +
822 +html[data-estilo="cyber"] .post-card:hover,
823 +html[data-estilo="cyber"] .side-card:hover,
824 +html[data-estilo="cyber"] .widget-card:hover {
825 + transform: translateY(-3px);
826 + border-color: #00f0ff;
827 + box-shadow: 0 20px 50px rgba(0, 240, 255, 0.25);
828 +}
829 +
830 +/* Progreso de carrera (avance) con relleno animado cian-magenta */
831 +html[data-estilo="cyber"] .progress-bar {
832 + height: 6px;
833 + background: #030508;
834 + border: 1px solid #1f3044;
835 + overflow: hidden;
836 + box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.8);
837 +}
838 +
839 +html[data-estilo="cyber"] .progress-fill {
840 + background: linear-gradient(90deg, #ff007f, #00f0ff);
841 + box-shadow: 0 0 12px #00f0ff;
842 + transition: width 1.6s cubic-bezier(0.16, 1, 0.3, 1) 0.4s;
843 +}
844 +
845 +html[data-estilo="cyber"] .progress-labels {
846 + font-size: 0.78rem;
847 + color: #8b949e;
848 +}
849 +
850 +/* Insignias de estado de la app según la paleta cyber */
851 +html[data-estilo="cyber"] .badge-verified,
852 +html[data-estilo="cyber"] .badge-solved {
853 + background: rgba(0, 240, 255, 0.12);
854 + color: #00f0ff;
855 + border: 1px solid #00f0ff;
856 + text-transform: uppercase;
857 +}
858 +
859 +html[data-estilo="cyber"] .badge-interacted {
860 + background: rgba(255, 0, 127, 0.12);
861 + color: #ff8ac2;
862 + border: 1px solid #ff007f;
863 + text-transform: uppercase;
864 +}
865 +
866 +html[data-estilo="cyber"] .maps-banner .banner-content,
867 +html[data-estilo="cyber"] .banner-title {
868 + text-shadow: 0 0 10px rgba(0, 240, 255, 0.4);
869 +}
870 +
871 +html[data-estilo="cyber"] .empty-state h2 { color: #ffffff; }
872 +html[data-estilo="cyber"] .empty-state p,
873 +html[data-estilo="cyber"] .filter-note { color: #8b949e; }
874 +
875 +/* --- Keyframes extra del prototipo --- */
876 +@keyframes terminalIn {
877 + 0% { transform: scale(0.85) translateY(25px); opacity: 0; filter: blur(10px); }
878 + 100% { transform: scale(1) translateY(0); opacity: 1; filter: blur(0); }
879 +}
880 +
881 +@keyframes terminalScan {
882 + 0% { left: -100%; }
883 + 100% { left: 100%; }
884 +}
885 +
886 +/* ============================================================
887 + COMUNIDAD: FORO, RESPUESTAS, APUNTES, TIPS, MODALES
888 + ============================================================ */
889 +
890 +/* Barra de filtros y pestañas del hub */
891 +html[data-estilo="cyber"] .hub-filter-bar {
892 + background: rgba(10, 16, 26, 0.9);
893 + border: 1px solid #1f3044;
894 +}
895 +
896 +html[data-estilo="cyber"] .hub-tabs button {
897 + background: transparent;
898 + color: #8b949e;
899 + border: 1px solid transparent;
900 + text-transform: uppercase;
901 + letter-spacing: 0.5px;
902 + transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.25s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), color 0.25s cubic-bezier(0.16, 1, 0.3, 1);
903 +}
904 +
905 +html[data-estilo="cyber"] .hub-tabs button:hover {
906 + color: #00f0ff;
907 + border-color: #00f0ff;
908 + box-shadow: 0 0 12px rgba(0, 240, 255, 0.3);
909 +}
910 +
911 +html[data-estilo="cyber"] .hub-tabs button.active {
912 + background: rgba(0, 240, 255, 0.12);
913 + color: #00f0ff;
914 + border: 1px solid #00f0ff;
915 +}
916 +
917 +html[data-estilo="cyber"] .input-search {
918 + background: rgba(4, 7, 12, 0.9);
919 + border: 1px solid #1f3044;
920 + color: #00f0ff;
921 + box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.8);
922 +}
923 +
924 +html[data-estilo="cyber"] .input-search:focus {
925 + border-color: #00f0ff;
926 + box-shadow: 0 0 20px rgba(0, 240, 255, 0.3), inset 0 0 8px rgba(0, 0, 0, 0.8);
927 +}
928 +
929 +/* Insignias del hub */
930 +html[data-estilo="cyber"] .hub-badge {
931 + background: rgba(0, 240, 255, 0.12);
932 + color: #00f0ff;
933 + border: 1px solid #00f0ff;
934 + text-transform: uppercase;
935 + letter-spacing: 0.5px;
936 +}
937 +
938 +html[data-estilo="cyber"] .hub-badge.badge-solucion,
939 +html[data-estilo="cyber"] .hub-badge.badge-solved {
940 + background: rgba(255, 0, 127, 0.12);
941 + color: #ff8ac2;
942 + border-color: #ff007f;
943 +}
944 +
945 +/* Cabecera de publicación del foro */
946 +html[data-estilo="cyber"] .foro-autor-line strong,
947 +html[data-estilo="cyber"] .user-data-line strong { color: #ffffff; }
948 +
949 +html[data-estilo="cyber"] .post-title {
950 + color: #ffffff;
951 + font-weight: 900;
952 + letter-spacing: 0.5px;
953 +}
954 +
955 +html[data-estilo="cyber"] .post-desc {
956 + color: #c9d1d9;
957 + line-height: 1.55;
958 +}
959 +
960 +html[data-estilo="cyber"] .post-desc code {
961 + background: #070b14;
962 + border: 1px solid #1f3044;
963 + color: #00f0ff;
964 +}
965 +
966 +/* Caja de solución aceptada */
967 +html[data-estilo="cyber"] .solved-box {
968 + background: rgba(255, 0, 127, 0.08);
969 + border: 1px solid rgba(255, 0, 127, 0.4);
970 + color: #ff8ac2;
971 + box-shadow: 0 0 20px rgba(255, 0, 127, 0.2);
972 +}
973 +
974 +/* Acciones enlazadas del foro */
975 +html[data-estilo="cyber"] .btn-link,
976 +html[data-estilo="cyber"] .foro-action {
977 + color: #00f0ff;
978 + background: transparent;
979 + transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.25s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), color 0.25s cubic-bezier(0.16, 1, 0.3, 1);
980 + text-transform: uppercase;
981 + letter-spacing: 0.5px;
982 +}
983 +
984 +html[data-estilo="cyber"] .btn-link:hover,
985 +html[data-estilo="cyber"] .foro-action:hover {
986 + color: #ffffff;
987 + text-shadow: 0 0 10px rgba(0, 240, 255, 0.8);
988 +}
989 +
990 +/* Tarjeta de apunte */
991 +html[data-estilo="cyber"] .apunte-card {
992 + backdrop-filter: none;
993 + transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.35s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.35s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.35s cubic-bezier(0.16, 1, 0.3, 1), color 0.35s cubic-bezier(0.16, 1, 0.3, 1);
994 +}
995 +
996 +html[data-estilo="cyber"] .apunte-card:hover {
997 + transform: translateY(-3px);
998 + border-color: #00f0ff;
999 + box-shadow: 0 20px 50px rgba(0, 240, 255, 0.25);
1000 +}
1001 +
1002 +html[data-estilo="cyber"] .file-icon {
1003 + background: rgba(0, 240, 255, 0.12);
1004 + border: 1px solid #00f0ff;
1005 + color: #00f0ff;
1006 +}
1007 +
1008 +html[data-estilo="cyber"] .apunte-details h4 { color: #ffffff; }
1009 +html[data-estilo="cyber"] .apunte-details p { color: #8b949e; }
1010 +
1011 +/* Tarjeta de tip */
1012 +html[data-estilo="cyber"] .tip-card {
1013 + backdrop-filter: none;
1014 + transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.35s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.35s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.35s cubic-bezier(0.16, 1, 0.3, 1), color 0.35s cubic-bezier(0.16, 1, 0.3, 1);
1015 +}
1016 +
1017 +html[data-estilo="cyber"] .tip-card:hover {
1018 + transform: translateY(-3px);
1019 + border-color: #00f0ff;
1020 + box-shadow: 0 20px 50px rgba(0, 240, 255, 0.25);
1021 +}
1022 +
1023 +html[data-estilo="cyber"] .tip-title,
1024 +html[data-estilo="cyber"] .tip-card h4 { color: #ffffff; }
1025 +html[data-estilo="cyber"] .tip-text { color: #c9d1d9; }
1026 +html[data-estilo="cyber"] .tip-text code {
1027 + background: #070b14;
1028 + border: 1px solid #1f3044;
1029 + color: #00f0ff;
1030 +}
1031 +
1032 +/* Votos de tips */
1033 +html[data-estilo="cyber"] .vote-btn {
1034 + background: rgba(10, 14, 22, 0.9);
1035 + border: 1px solid #1f3044;
1036 + color: #8b949e;
1037 + transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.25s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), color 0.25s cubic-bezier(0.16, 1, 0.3, 1);
1038 +}
1039 +
1040 +html[data-estilo="cyber"] .vote-btn:hover {
1041 + border-color: #00f0ff;
1042 + color: #00f0ff;
1043 + box-shadow: 0 0 12px rgba(0, 240, 255, 0.3);
1044 +}
1045 +
1046 +html[data-estilo="cyber"] .vote-btn.votado {
1047 + border-color: #ff007f;
1048 + color: #ff007f;
1049 + box-shadow: 0 0 12px rgba(255, 0, 127, 0.4);
1050 +}
1051 +
1052 +/* --- MODALES DEL HUB (crear duda/apunte/tip) --- */
1053 +html[data-estilo="cyber"] .hub-modal-overlay {
1054 + background: rgba(1, 2, 4, 0.9);
1055 + backdrop-filter: none;
1056 +}
1057 +
1058 +html[data-estilo="cyber"] .hub-modal {
1059 + background: linear-gradient(135deg, #070b14 0%, #020305 100%);
1060 + border: 2px solid #00f0ff;
1061 + box-shadow: 0 0 80px rgba(0, 240, 255, 0.35), inset 0 0 30px rgba(0, 240, 255, 0.15);
1062 +}
1063 +
1064 +html[data-estilo="cyber"] .hub-modal h3,
1065 +html[data-estilo="cyber"] .hub-modal h4 { color: #ffffff; }
1066 +
1067 +html[data-estilo="cyber"] .hub-modal .modal-sub { color: #8b949e; }
1068 +html[data-estilo="cyber"] .hub-modal label { color: #e6edf3; }
1069 +html[data-estilo="cyber"] .modal-msg { color: #c9d1d9; }
1070 +
1071 +/* --- MODAL DE POST Y RESPUESTAS (duda repetida / respuestas) --- */
1072 +html[data-estilo="cyber"] .dup-modal { border: 2px solid #00f0ff; }
1073 +html[data-estilo="cyber"] .dup-notice {
1074 + background: rgba(255, 0, 127, 0.08);
1075 + border: 1px solid rgba(255, 0, 127, 0.4);
1076 + box-shadow: 0 0 20px rgba(255, 0, 127, 0.25);
1077 +}
1078 +
1079 +html[data-estilo="cyber"] .dup-titulo { color: #ff8ac2; }
1080 +html[data-estilo="cyber"] .dup-notice p,
1081 +html[data-estilo="cyber"] .dup-por { color: #8b949e; }
1082 +
1083 +html[data-estilo="cyber"] .dup-question {
1084 + background: rgba(10, 16, 26, 0.92);
1085 + border-left: 4px solid #00f0ff;
1086 +}
1087 +
1088 +html[data-estilo="cyber"] .dup-section-title {
1089 + color: #ffffff;
1090 + text-transform: uppercase;
1091 + letter-spacing: 0.5px;
1092 +}
1093 +
1094 +html[data-estilo="cyber"] .dup-respuestas,
1095 +html[data-estilo="cyber"] .dup-tips,
1096 +html[data-estilo="cyber"] .dup-recursos {
1097 + border-color: #1f3044;
1098 +}
1099 +
1100 +html[data-estilo="cyber"] .dup-vacio { color: #8b949e; }
1101 +
1102 +/* --- RESPUESTAS A UN POST --- */
1103 +html[data-estilo="cyber"] .hub-respuesta {
1104 + background: linear-gradient(135deg, rgba(10, 16, 26, 0.92) 0%, rgba(4, 7, 12, 0.96) 100%);
1105 + border: 1px solid #1f3044;
1106 + border-left: 4px solid #00f0ff;
1107 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
1108 + backdrop-filter: none;
1109 + transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1);
1110 +}
1111 +
1112 +html[data-estilo="cyber"] .hub-respuesta:hover {
1113 + border-color: #00f0ff;
1114 + box-shadow: 0 12px 30px rgba(0, 240, 255, 0.2);
1115 +}
1116 +
1117 +html[data-estilo="cyber"] .respuesta-autor {
1118 + color: #ff007f;
1119 + font-weight: 900;
1120 + text-transform: uppercase;
1121 + letter-spacing: 0.5px;
1122 + text-shadow: 0 0 8px rgba(255, 0, 127, 0.4);
1123 +}
1124 +
1125 +html[data-estilo="cyber"] .respuesta-texto {
1126 + color: #c9d1d9;
1127 + line-height: 1.55;
1128 +}
1129 +
1130 +/* ============================================================
1131 + MENSAJES: CHAT COMPLETO
1132 + ============================================================ */
1133 +html[data-estilo="cyber"] .chat-sidebar {
1134 + background: linear-gradient(180deg, rgba(7, 10, 20, 0.95) 0%, rgba(1, 2, 4, 0.98) 100%);
1135 + border-right: 2px solid rgba(0, 240, 255, 0.35);
1136 +}
1137 +
1138 +html[data-estilo="cyber"] .chat-search-box { border-bottom-color: #1f3044; }
1139 +html[data-estilo="cyber"] .chat-search-box input {
1140 + background: rgba(4, 7, 12, 0.9);
1141 + border: 1px solid #1f3044;
1142 + color: #00f0ff;
1143 + box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.8);
1144 +}
1145 +
1146 +html[data-estilo="cyber"] .chat-search-box input:focus {
1147 + border-color: #00f0ff;
1148 + box-shadow: 0 0 20px rgba(0, 240, 255, 0.3);
1149 +}
1150 +
1151 +html[data-estilo="cyber"] .chat-item {
1152 + border-bottom: 1px solid #161f2e;
1153 + transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.25s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), color 0.25s cubic-bezier(0.16, 1, 0.3, 1);
1154 +}
1155 +
1156 +html[data-estilo="cyber"] .chat-item:hover {
1157 + background: rgba(0, 240, 255, 0.08);
1158 +}
1159 +
1160 +html[data-estilo="cyber"] .chat-item.active {
1161 + background: rgba(0, 240, 255, 0.16);
1162 + border-left: 3px solid #00f0ff;
1163 + box-shadow: inset 0 0 12px rgba(0, 240, 255, 0.2);
1164 +}
1165 +
1166 +html[data-estilo="cyber"] .chat-item-header strong { color: #ffffff; }
1167 +html[data-estilo="cyber"] .chat-item-header span,
1168 +html[data-estilo="cyber"] .chat-item-preview { color: #8b949e; }
1169 +
1170 +html[data-estilo="cyber"] .chat-window {
1171 + background: rgba(3, 5, 9, 0.55);
1172 +}
1173 +
1174 +html[data-estilo="cyber"] .chat-header {
1175 + background: rgba(7, 10, 20, 0.95);
1176 + border-bottom: 1px solid rgba(0, 240, 255, 0.3);
1177 +}
1178 +
1179 +html[data-estilo="cyber"] .chat-header-user h3 { color: #ffffff; }
1180 +html[data-estilo="cyber"] .chat-header-user span { color: #8b949e; }
1181 +
1182 +/* Burbujas de mensaje */
1183 +html[data-estilo="cyber"] .message {
1184 + background: rgba(10, 16, 26, 0.92);
1185 + border: 1px solid #1f3044;
1186 + border-left: 4px solid #00f0ff;
1187 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
1188 + backdrop-filter: none;
1189 + transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1);
1190 +}
1191 +
1192 +html[data-estilo="cyber"] .message.received:hover,
1193 +html[data-estilo="cyber"] .message.sent:hover {
1194 + border-color: #00f0ff;
1195 + box-shadow: 0 10px 28px rgba(0, 240, 255, 0.22);
1196 +}
1197 +
1198 +html[data-estilo="cyber"] .message.sent {
1199 + background: linear-gradient(135deg, rgba(26, 0, 51, 0.95) 0%, rgba(10, 16, 26, 0.96) 100%);
1200 + border: 1px solid rgba(255, 0, 127, 0.45);
1201 + border-left: 4px solid #ff007f;
1202 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.65);
1203 +}
1204 +
1205 +html[data-estilo="cyber"] .message-time { color: #8b949e; }
1206 +
1207 +html[data-estilo="cyber"] .message-texto {
1208 + color: #e6edf3;
1209 + line-height: 1.5;
1210 +}
1211 +
1212 +/* Adjuntos */
1213 +html[data-estilo="cyber"] .message-attachment {
1214 + background: rgba(4, 7, 12, 0.9);
1215 + border: 1px solid #1f3044;
1216 +}
1217 +
1218 +html[data-estilo="cyber"] .message.sent .message-attachment {
1219 + border-color: rgba(255, 0, 127, 0.35);
1220 +}
1221 +
1222 +html[data-estilo="cyber"] .attachment-icon {
1223 + background: rgba(0, 240, 255, 0.12);
1224 + border: 1px solid #00f0ff;
1225 + color: #00f0ff;
1226 +}
1227 +
1228 +html[data-estilo="cyber"] .message.sent .attachment-icon {
1229 + background: rgba(255, 0, 127, 0.12);
1230 + border-color: #ff007f;
1231 + color: #ff8ac2;
1232 +}
1233 +
1234 +html[data-estilo="cyber"] .attachment-nombre { color: #ffffff; }
1235 +html[data-estilo="cyber"] .attachment-peso { color: #8b949e; }
1236 +
1237 +html[data-estilo="cyber"] .adjunto-descargar {
1238 + background: transparent;
1239 + border: 1px solid #00f0ff;
1240 + color: #00f0ff;
1241 + transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.25s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), color 0.25s cubic-bezier(0.16, 1, 0.3, 1);
1242 + text-transform: uppercase;
1243 +}
1244 +
1245 +html[data-estilo="cyber"] .adjunto-descargar:hover {
1246 + background: #00f0ff;
1247 + color: #010204;
1248 + box-shadow: 0 0 15px rgba(0, 240, 255, 0.8);
1249 +}
1250 +
1251 +html[data-estilo="cyber"] .message.sent .adjunto-descargar {
1252 + border-color: #ff007f;
1253 + color: #ff8ac2;
1254 +}
1255 +
1256 +html[data-estilo="cyber"] .message.sent .adjunto-descargar:hover {
1257 + background: #ff007f;
1258 + color: #ffffff;
1259 + box-shadow: 0 0 15px rgba(255, 0, 127, 0.8);
1260 +}
1261 +
1262 +/* Barra de escritura */
1263 +html[data-estilo="cyber"] .chat-input-bar {
1264 + background: rgba(7, 10, 20, 0.95);
1265 + border-top: 1px solid rgba(0, 240, 255, 0.3);
1266 +}
1267 +
1268 +html[data-estilo="cyber"] .chat-input-bar input {
1269 + background: rgba(4, 7, 12, 0.9);
1270 + border: 1px solid #1f3044;
1271 + color: #00f0ff;
1272 + box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.8);
1273 + outline: none;
1274 +}
1275 +
1276 +html[data-estilo="cyber"] .chat-input-bar input:focus {
1277 + border-color: #00f0ff;
1278 + box-shadow: 0 0 20px rgba(0, 240, 255, 0.3);
1279 +}
1280 +
1281 +html[data-estilo="cyber"] .chat-empty p,
1282 +html[data-estilo="cyber"] .chat-empty span { color: #8b949e; }
1283 +
1284 +/* ============================================================
1285 + PERFIL: STATS, CERTIFICADOS, ACTIVIDAD, DISCOVERY
1286 + ============================================================ */
1287 +html[data-estilo="cyber"] .profile-name {
1288 + color: #ffffff;
1289 + text-shadow: 0 0 12px rgba(0, 240, 255, 0.5);
1290 + letter-spacing: 0.5px;
1291 +}
1292 +
1293 +html[data-estilo="cyber"] .profile-sub,
1294 +html[data-estilo="cyber"] .discover-empty { color: #8b949e; }
1295 +
1296 +html[data-estilo="cyber"] .red-stat .number,
1297 +html[data-estilo="cyber"] .stat-box .number {
1298 + color: #00f0ff;
1299 + text-shadow: 0 0 12px rgba(0, 240, 255, 0.5);
1300 +}
1301 +
1302 +html[data-estilo="cyber"] .red-stat .label,
1303 +html[data-estilo="cyber"] .stat-box .label { color: #8b949e; }
1304 +
1305 +html[data-estilo="cyber"] .discover-person {
1306 + background: linear-gradient(135deg, rgba(10, 16, 26, 0.92) 0%, rgba(4, 7, 12, 0.96) 100%);
1307 + border: 1px solid #1f3044;
1308 + border-left: 4px solid #00f0ff;
1309 + backdrop-filter: none;
1310 + transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.35s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.35s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.35s cubic-bezier(0.16, 1, 0.3, 1), color 0.35s cubic-bezier(0.16, 1, 0.3, 1);
1311 +}
1312 +
1313 +html[data-estilo="cyber"] .discover-person:hover {
1314 + transform: translateY(-3px);
1315 + border-color: #00f0ff;
1316 + box-shadow: 0 20px 50px rgba(0, 240, 255, 0.25);
1317 +}
1318 +
1319 +html[data-estilo="cyber"] .discover-info strong { color: #ffffff; }
1320 +
1321 +/* Certificados y actividad */
1322 +html[data-estilo="cyber"] .certificate-item {
1323 + background: rgba(10, 16, 26, 0.92);
1324 + border: 1px solid #1f3044;
1325 + border-left: 4px solid #00f0ff;
1326 + backdrop-filter: none;
1327 + transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.3s cubic-bezier(0.16, 1, 0.3, 1), color 0.3s cubic-bezier(0.16, 1, 0.3, 1);
1328 +}
1329 +
1330 +html[data-estilo="cyber"] .certificate-item:hover {
1331 + border-color: #00f0ff;
1332 + box-shadow: 0 12px 30px rgba(0, 240, 255, 0.2);
1333 +}
1334 +
1335 +html[data-estilo="cyber"] .certificate-item.active {
1336 + border-left-color: #ff007f;
1337 + box-shadow: inset 0 0 15px rgba(255, 0, 127, 0.15);
1338 +}
1339 +
1340 +html[data-estilo="cyber"] .cert-title,
1341 +html[data-estilo="cyber"] .activity-item strong { color: #ffffff; }
1342 +html[data-estilo="cyber"] .cert-desc,
1343 +html[data-estilo="cyber"] .activity-item { color: #8b949e; }
1344 +
1345 +html[data-estilo="cyber"] .badge-green {
1346 + background: rgba(0, 240, 255, 0.12);
1347 + color: #00f0ff;
1348 + border: 1px solid #00f0ff;
1349 +}
1350 +
1351 +html[data-estilo="cyber"] .badge-gray {
1352 + background: rgba(10, 14, 22, 0.9);
1353 + color: #8b949e;
1354 + border: 1px solid #1f3044;
1355 +}
1356 +
1357 +/* Modales de propósito / semestre */
1358 +html[data-estilo="cyber"] .proposito-modal {
1359 + background: linear-gradient(135deg, #070b14 0%, #020305 100%);
1360 + border: 2px solid #00f0ff;
1361 + box-shadow: 0 0 80px rgba(0, 240, 255, 0.35), inset 0 0 30px rgba(0, 240, 255, 0.15);
1362 +}
1363 +
1364 +html[data-estilo="cyber"] .proposito-modal-sub { color: #8b949e; }
1365 +
1366 +/* ============================================================
1367 + CORRECCION DE FONDOS BLANCOS (modo claro): contenedores chat/foro
1368 + ============================================================ */
1369 +
1370 +/* Contenedor principal del chat: fondo oscuro completo (antes #fff) */
1371 +html[data-estilo="cyber"] .messages-container {
1372 + --hub-border: #1f3044;
1373 + --hub-accent: #00f0ff;
1374 + --hub-accent-dark: #ff007f;
1375 + --hub-accent-soft: rgba(0, 240, 255, 0.15);
1376 + --hub-muted: #8b949e;
1377 + background: rgba(3, 5, 9, 0.92);
1378 + border: 1px solid #1f3044;
1379 + box-shadow: 0 0 40px rgba(0, 240, 255, 0.12);
1380 +}
1381 +
1382 +/* Ítems de la lista de conversaciones (por defecto blancos) */
1383 +html[data-estilo="cyber"] .chat-item {
1384 + background: rgba(7, 10, 20, 0.6);
1385 +}
1386 +
1387 +html[data-estilo="cyber"] .chat-item-header strong { color: #ffffff; }
1388 +html[data-estilo="cyber"] .chat-item-header span { color: #8b949e; }
1389 +html[data-estilo="cyber"] .chat-item-preview { color: #8b949e; }
1390 +
1391 +/* Cuerpo de conversación: retícula sutil tipo terminal */
1392 +html[data-estilo="cyber"] .chat-body {
1393 + background-color: rgba(4, 7, 12, 0.55);
1394 + background-image: radial-gradient(rgba(0, 240, 255, 0.07) 1px, transparent 1px);
1395 + background-size: 26px 26px;
1396 +}
1397 +
1398 +html[data-estilo="cyber"] .chat-empty {
1399 + background-color: rgba(4, 7, 12, 0.55);
1400 +}
1401 +
1402 +/* Chip de adjunto pendiente de envío (antes verde claro) */
1403 +html[data-estilo="cyber"] .adjunto-pendiente {
1404 + background: rgba(0, 240, 255, 0.1);
1405 + border: 1px solid #00f0ff;
1406 + color: #00f0ff;
1407 +}
1408 +
1409 +html[data-estilo="cyber"] .adjunto-quitar { color: #ff2d55; }
1410 +
1411 +/* Tarjetas de tips y apuntes: fondo oscuro por defecto (antes blanco) */
1412 +html[data-estilo="cyber"] .tip-card,
1413 +html[data-estilo="cyber"] .apunte-card {
1414 + background: rgba(10, 16, 26, 0.92);
1415 + border: 1px solid #1f3044;
1416 +}
1417 +
1418 +html[data-estilo="cyber"] .apunte-card:hover,
1419 +html[data-estilo="cyber"] .tip-card:hover {
1420 + background: rgba(12, 20, 32, 0.95);
1421 +}
1422 +
1423 +/* ============================================================
1424 + ESTILO: HOGWARTS MEDIEVAL
1425 + Oro y pergamino sobre la fortaleza mágica (mockup 3D).
1426 + ============================================================ */
1427 +
1428 +/* --- Paleta global del skin --- */
1429 +
1430 +/* ============================================================
1431 + ESTILO: EL CASTILLO (reemplaza al medieval / Hogwarts)
1432 + Noche de piedra, latón y pergamino: un reino académico de
1433 + torres, bóvedas y vitrales. Paleta vino/latón/pergamino,
1434 + tipografía Cinzel (títulos) + EB Garamond (cuerpo).
1435 + ============================================================ */
1436 +
1437 +/* --- Paleta global del skin --- */
1438 +html[data-estilo="medieval"] {
1439 + --void: #120A08;
1440 + --panel: #2A1512;
1441 + --panel-light: #3B211C;
1442 + --wine: #5C2323;
1443 + --wine-deep: #3E1717;
1444 + --wine-bright: #7A2E2E;
1445 + --brass: #A9834B;
1446 + --brass-dim: #7C6440;
1447 + --brass-bright: #C9A15E;
1448 + --parchment: #EEE2C4;
1449 + --parchment-dim: #C9BB98;
1450 + --umber: #1C120E;
1451 +
1452 + --accent: #A9834B;
1453 + --accent-dark: #7C6440;
1454 + --accent-mid: #C9A15E;
1455 + --accent-bright: #EEE2C4;
1456 + --accent-deep: #120A08;
1457 + --accent-soft: #2A1512;
1458 + --accent-soft-border: #7C6440;
1459 + --accent-contrast: #EEE2C4;
1460 +
1461 + --primary-color: #A9834B;
1462 + --secondary-color: #5C2323;
1463 + --link-color: #C9BB98;
1464 + --heading-color: #EEE2C4;
1465 + --text-color: #C9BB98;
1466 +
1467 + --sidebar-bg: #1C120E;
1468 + --sidebar-border: #7C6440;
1469 + --sidebar-text: #C9BB98;
1470 + --sidebar-text-hover: #EEE2C4;
1471 + --sidebar-item-hover-bg: rgba(169, 131, 75, 0.08);
1472 + --sidebar-item-active-bg: linear-gradient(90deg, rgba(92, 35, 35, 0.55), transparent);
1473 + --sidebar-accent: #A9834B;
1474 +
1475 + --topbar-bg: rgba(18, 10, 8, 0.82);
1476 +
1477 + --btn-primary-bg: linear-gradient(180deg, #6B2828 0%, #3E1717 100%);
1478 + --btn-primary-text: #EEE2C4;
1479 + --btn-outline-border: #A9834B;
1480 + --btn-outline-text: #A9834B;
1481 +}
1482 +
1483 +/* --- Tipografía y base (sin bordes redondeados: piedra) --- */
1484 +html[data-estilo="medieval"] * {
1485 + font-family: "EB Garamond", Georgia, 'Times New Roman', serif;
1486 + border-radius: 0 !important;
1487 +}
1488 +
1489 +html[data-estilo="medieval"] body {
1490 + background-color: #120A08;
1491 + color: #C9BB98;
1492 + position: relative;
1493 +}
1494 +
1495 +html[data-estilo="medieval"] h1,
1496 +html[data-estilo="medieval"] h2,
1497 +html[data-estilo="medieval"] h3,
1498 +html[data-estilo="medieval"] h4,
1499 +html[data-estilo="medieval"] .brand-title,
1500 +html[data-estilo="medieval"] .banner-title,
1501 +html[data-estilo="medieval"] .modal-title,
1502 +html[data-estilo="medieval"] .widget-title,
1503 +html[data-estilo="medieval"] .post-title {
1504 + font-family: "Cinzel", Georgia, serif;
1505 +}
1506 +
1507 +html[data-estilo="medieval"] h1,
1508 +html[data-estilo="medieval"] h2,
1509 +html[data-estilo="medieval"] h3,
1510 +html[data-estilo="medieval"] h4 {
1511 + color: #EEE2C4;
1512 + letter-spacing: 0.5px;
1513 +}
1514 +
1515 +html[data-estilo="medieval"] a { color: #C9BB98; }
1516 +html[data-estilo="medieval"] a:hover { color: #EEE2C4; }
1517 +
1518 +html[data-estilo="medieval"] .page-title {
1519 + color: #EEE2C4;
1520 + font-family: "Cinzel", serif;
1521 +}
1522 +html[data-estilo="medieval"] .page-description { color: #C9BB98; }
1523 +
1524 +html[data-estilo="medieval"] .app-layout {
1525 + position: relative;
1526 + z-index: 2;
1527 +}
1528 +
1529 +/* ============================================================
1530 + SKYLINE DEL CASTILLO — CAPA FIJA CON PAN/ZOOM POR SECCIÓN
1531 + ============================================================ */
1532 +html[data-estilo="medieval"] .medieval-bg-viewport {
1533 + position: fixed;
1534 + top: 0; left: 0;
1535 + width: 100vw;
1536 + height: 100vh;
1537 + pointer-events: none;
1538 + z-index: 0;
1539 + overflow: hidden;
1540 +}
1541 +
1542 +html[data-estilo="medieval"] .castle-layer {
1543 + position: absolute;
1544 + top: 50%;
1545 + left: 50%;
1546 + width: 2600px;
1547 + height: 1100px;
1548 + margin-left: -1300px;
1549 + margin-top: -550px;
1550 + transform: translate(0px, 40px) scale(0.62);
1551 + transition: transform 1.5s cubic-bezier(0.16, 1, 0.3, 1);
1552 +}
1553 +
1554 +html[data-estilo="medieval"] .castle-vignette {
1555 + position: fixed;
1556 + inset: 0;
1557 + z-index: 1;
1558 + pointer-events: none;
1559 + background:
1560 + radial-gradient(ellipse 1000px 700px at 50% 40%, transparent 40%, rgba(10, 6, 5, 0.55) 100%),
1561 + linear-gradient(to bottom, rgba(10, 6, 5, 0.5) 0%, rgba(10, 6, 5, 0.1) 22%, rgba(10, 6, 5, 0.1) 70%, rgba(10, 6, 5, 0.65) 100%);
1562 +}
1563 +
1564 +/* ============================================================
1565 + TRANSICIÓN: SELLO HERÁLDICO (clip de círculo desde un vértice)
1566 + ============================================================ */
1567 +html[data-estilo="medieval"] .heraldic-transition {
1568 + position: fixed;
1569 + inset: 0;
1570 + background: #1C120E;
1571 + z-index: 9999;
1572 + clip-path: polygon(50% -10%, 50% -10%, 50% -10%, 50% -10%);
1573 + transition: clip-path 0.5s cubic-bezier(0.77, 0, 0.175, 1);
1574 + pointer-events: none;
1575 + visibility: hidden;
1576 +}
1577 +
1578 +html[data-estilo="medieval"] .heraldic-transition.active {
1579 + clip-path: polygon(-20% -20%, 120% -20%, 120% 120%, -20% 120%);
1580 + visibility: visible;
1581 +}
1582 +
1583 +/* ============================================================
1584 + SIDEBAR — PASAJE DEL GRAN SALÓN
1585 + ============================================================ */
1586 +html[data-estilo="medieval"] .sidebar {
1587 + background: linear-gradient(180deg, #2A1512 0%, #3B211C 100%);
1588 + border-right: 2px solid #7C6440;
1589 + box-shadow: 6px 0 30px rgba(0, 0, 0, 0.5);
1590 +}
1591 +
1592 +html[data-estilo="medieval"] .sidebar-header {
1593 + border: 1px solid #7C6440;
1594 + padding: 16px 14px;
1595 + margin-bottom: 18px;
1596 + text-align: center;
1597 + position: relative;
1598 + background: rgba(0, 0, 0, 0.18);
1599 + border-bottom-width: 1px;
1600 +}
1601 +
1602 +html[data-estilo="medieval"] .sidebar-header::before,
1603 +html[data-estilo="medieval"] .sidebar-header::after {
1604 + content: "❖";
1605 + color: #7C6440;
1606 + font-size: 0.7rem;
1607 + position: absolute;
1608 + top: 4px;
1609 +}
1610 +html[data-estilo="medieval"] .sidebar-header::before { left: 6px; }
1611 +html[data-estilo="medieval"] .sidebar-header::after { right: 6px; }
1612 +
1613 +html[data-estilo="medieval"] .sidebar-header h1 {
1614 + color: #A9834B;
1615 + font-size: 1.05rem;
1616 + font-weight: 600;
1617 + letter-spacing: 1px;
1618 + margin: 0 0 10px 0;
1619 + text-transform: none;
1620 + text-shadow: none;
1621 +}
1622 +
1623 +html[data-estilo="medieval"] .sidebar-user {
1624 + display: flex;
1625 + align-items: center;
1626 + gap: 10px;
1627 + justify-content: center;
1628 + background: transparent;
1629 + border: none;
1630 + padding: 0;
1631 +}
1632 +
1633 +html[data-estilo="medieval"] .sidebar-user small {
1634 + color: #C9BB98;
1635 + font-size: 0.72rem;
1636 + letter-spacing: 0.5px;
1637 +}
1638 +
1639 +html[data-estilo="medieval"] .sidebar-avatar {
1640 + width: 32px;
1641 + height: 32px;
1642 + background: #5C2323;
1643 + border: 1px solid #7C6440;
1644 + color: #EEE2C4;
1645 + font-family: "Cinzel", serif;
1646 + font-weight: 600;
1647 + font-size: 0.75rem;
1648 +}
1649 +
1650 +html[data-estilo="medieval"] .sidebar-avatar.avatar-foto {
1651 + width: 32px;
1652 + height: 32px;
1653 + border: 1px solid #7C6440;
1654 +}
1655 +
1656 +html[data-estilo="medieval"] .sidebar-nav {
1657 + display: flex;
1658 + flex-direction: column;
1659 + gap: 4px;
1660 +}
1661 +
1662 +html[data-estilo="medieval"] .sidebar-nav li { list-style: none; }
1663 +
1664 +html[data-estilo="medieval"] .sidebar-nav a {
1665 + display: flex;
1666 + align-items: center;
1667 + gap: 10px;
1668 + padding: 11px 14px;
1669 + color: #C9BB98;
1670 + text-decoration: none;
1671 + font-size: 0.82rem;
1672 + font-weight: 500;
1673 + letter-spacing: 0.5px;
1674 + border-left: 3px solid transparent;
1675 + transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, padding 0.2s ease;
1676 +}
1677 +
1678 +html[data-estilo="medieval"] .sidebar-nav a::before {
1679 + content: "❖";
1680 + color: #7C6440;
1681 + font-size: 0.65rem;
1682 +}
1683 +
1684 +html[data-estilo="medieval"] .sidebar-nav a:hover {
1685 + background: rgba(169, 131, 75, 0.08);
1686 + border-left-color: #7C6440;
1687 + color: #EEE2C4;
1688 + padding-left: 18px;
1689 +}
1690 +
1691 +html[data-estilo="medieval"] .sidebar-nav a.active {
1692 + background: linear-gradient(90deg, rgba(92, 35, 35, 0.5), transparent);
1693 + border-left-color: #A9834B;
1694 + color: #EEE2C4;
1695 + font-weight: 600;
1696 +}
1697 +
1698 +html[data-estilo="medieval"] .sidebar-nav a.active::before { color: #A9834B; }
1699 +
1700 +html[data-estilo="medieval"] .sidebar-footer {
1701 + border-top: 1px solid rgba(169, 131, 75, 0.35);
1702 + padding-top: 14px;
1703 +}
1704 +
1705 +html[data-estilo="medieval"] .sidebar-footer .btn {
1706 + background: transparent;
1707 + border: 1px solid #7C6440;
1708 + color: #A9834B;
1709 + text-align: center;
1710 + font-size: 0.72rem;
1711 + font-weight: 600;
1712 + letter-spacing: 1px;
1713 + text-transform: uppercase;
1714 + border-radius: 0;
1715 +}
1716 +
1717 +html[data-estilo="medieval"] .sidebar-footer .btn:hover {
1718 + background: #5C2323;
1719 + border-color: #5C2323;
1720 + color: #EEE2C4;
1721 +}
1722 +
1723 +/* ============================================================
1724 + TOPBAR
1725 + ============================================================ */
1726 +html[data-estilo="medieval"] .topbar {
1727 + background: var(--topbar-bg);
1728 + border-bottom: 1px solid #3B211C;
1729 + backdrop-filter: blur(3px);
1730 +}
1731 +
1732 +html[data-estilo="medieval"] .menu-toggle {
1733 + color: #A9834B;
1734 + background: rgba(28, 18, 14, 0.85);
1735 + border: 1px solid #7C6440;
1736 +}
1737 +
1738 +html[data-estilo="medieval"] .menu-toggle:hover {
1739 + border-color: #A9834B;
1740 + background: #3B211C;
1741 + color: #EEE2C4;
1742 +}
1743 +
1744 +html[data-estilo="medieval"] .menu-backdrop { background: rgba(10, 6, 5, 0.6); }
1745 +
1746 +/* Global search */
1747 +html[data-estilo="medieval"] .global-search-input {
1748 + background: rgba(28, 18, 14, 0.85);
1749 + border: 1px solid #7C6440;
1750 + color: #EEE2C4;
1751 + box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.6);
1752 +}
1753 +
1754 +html[data-estilo="medieval"] .global-search-input::placeholder { color: #8a7755; }
1755 +
1756 +html[data-estilo="medieval"] .global-search-input:focus {
1757 + border-color: #A9834B;
1758 + box-shadow: 0 0 12px rgba(169, 131, 75, 0.25), inset 0 0 8px rgba(0, 0, 0, 0.6);
1759 +}
1760 +
1761 +html[data-estilo="medieval"] .global-search-dropdown {
1762 + background: #1C120E;
1763 + border: 1px solid #7C6440;
1764 + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.8);
1765 +}
1766 +
1767 +html[data-estilo="medieval"] .global-search-item { border-bottom: 1px solid #3B211C; }
1768 +html[data-estilo="medieval"] .global-search-item:hover { background: #2A1512; }
1769 +
1770 +html[data-estilo="medieval"] .global-search-info strong { color: #EEE2C4; }
1771 +html[data-estilo="medieval"] .global-search-info span { color: #C9BB98; }
1772 +
1773 +html[data-estilo="medieval"] .global-search-chat {
1774 + border: 1px solid #A9834B;
1775 + color: #A9834B;
1776 + background: rgba(169, 131, 75, 0.08);
1777 +}
1778 +
1779 +html[data-estilo="medieval"] .global-search-chat:hover {
1780 + background: #A9834B;
1781 + color: #120A08;
1782 +}
1783 +
1784 +html[data-estilo="medieval"] .global-search-empty { color: #C9BB98; }
1785 +
1786 +/* Notificaciones */
1787 +html[data-estilo="medieval"] .notif-toggle {
1788 + background: rgba(28, 18, 14, 0.85);
1789 + border: 1px solid #7C6440;
1790 + color: #A9834B;
1791 +}
1792 +
1793 +html[data-estilo="medieval"] .notif-toggle:hover {
1794 + border-color: #A9834B;
1795 + background: #3B211C;
1796 + color: #EEE2C4;
1797 +}
1798 +
1799 +html[data-estilo="medieval"] .notif-badge {
1800 + background: #7A2E2E;
1801 + color: #EEE2C4;
1802 +}
1803 +
1804 +html[data-estilo="medieval"] .notif-panel {
1805 + background: #1C120E;
1806 + border: 1px solid #7C6440;
1807 + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.8);
1808 +}
1809 +
1810 +html[data-estilo="medieval"] .notif-panel-head { border-bottom: 1px solid #3B211C; }
1811 +html[data-estilo="medieval"] .notif-marcar-todas { color: #A9834B; }
1812 +html[data-estilo="medieval"] .notif-marcar-todas:hover { color: #EEE2C4; }
1813 +html[data-estilo="medieval"] .notif-item:hover { background: #2A1512; }
1814 +html[data-estilo="medieval"] .notif-item-info strong { color: #EEE2C4; }
1815 +html[data-estilo="medieval"] .notif-item-info span { color: #C9BB98; }
1816 +
1817 +html[data-estilo="medieval"] .notif-item-badge {
1818 + background: rgba(122, 46, 46, 0.3);
1819 + color: #C98B8B;
1820 +}
1821 +
1822 +html[data-estilo="medieval"] .notif-vacio { color: #C9BB98; }
1823 +
1824 +/* ============================================================
1825 + CONTENIDO Y COMPONENTES
1826 + ============================================================ */
1827 +html[data-estilo="medieval"] .main-content { background: transparent; }
1828 +
1829 +/* Entrada escalonada de las secciones */
1830 +html[data-estilo="medieval"] .main-content > * {
1831 + opacity: 0;
1832 + transform: translateY(14px);
1833 + animation: castleViewIn 0.55s cubic-bezier(0.16, 1, 0.3, 1) forwards;
1834 +}
1835 +
1836 +html[data-estilo="medieval"] .main-content > *:nth-child(1) { animation-delay: 0.04s; }
1837 +html[data-estilo="medieval"] .main-content > *:nth-child(2) { animation-delay: 0.1s; }
1838 +html[data-estilo="medieval"] .main-content > *:nth-child(3) { animation-delay: 0.16s; }
1839 +html[data-estilo="medieval"] .main-content > *:nth-child(4) { animation-delay: 0.22s; }
1840 +
1841 +@keyframes castleViewIn {
1842 + to { opacity: 1; transform: translateY(0); }
1843 +}
1844 +
1845 +/* Tarjetas con marco de doble borde y adorno */
1846 +html[data-estilo="medieval"] .card {
1847 + background: rgba(28, 16, 12, 0.82);
1848 + border: 1px solid #7C6440;
1849 + border-left: 4px solid #A9834B;
1850 + padding: 24px;
1851 + position: relative;
1852 + box-shadow: 0 15px 50px rgba(0, 0, 0, 0.6);
1853 + backdrop-filter: none;
1854 + transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s ease, box-shadow 0.3s ease;
1855 +}
1856 +
1857 +html[data-estilo="medieval"] .card::before {
1858 + content: "";
1859 + position: absolute;
1860 + inset: 6px;
1861 + border: 1px solid rgba(169, 131, 75, 0.25);
1862 + pointer-events: none;
1863 +}
1864 +
1865 +html[data-estilo="medieval"] .card::after {
1866 + content: "❖";
1867 + position: absolute;
1868 + top: 8px;
1869 + right: 12px;
1870 + font-size: 0.75rem;
1871 + color: #7C6440;
1872 + pointer-events: none;
1873 +}
1874 +
1875 +html[data-estilo="medieval"] .card:hover {
1876 + transform: translateY(-3px);
1877 + border-color: #A9834B;
1878 + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.7);
1879 +}
1880 +
1881 +html[data-estilo="medieval"] .card-title {
1882 + color: #EEE2C4;
1883 + font-weight: 700;
1884 +}
1885 +
1886 +html[data-estilo="medieval"] .card p,
1887 +html[data-estilo="medieval"] .card li { color: #C9BB98; }
1888 +html[data-estilo="medieval"] .card strong { color: #EEE2C4; }
1889 +
1890 +/* Botones */
1891 +html[data-estilo="medieval"] .btn {
1892 + border: 1px solid #7C6440;
1893 + color: #C9BB98;
1894 + background: rgba(28, 18, 14, 0.9);
1895 + text-transform: uppercase;
1896 + letter-spacing: 0.5px;
1897 + font-weight: 600;
1898 + transition: background-color 0.25s ease, color 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
1899 +}
1900 +
1901 +html[data-estilo="medieval"] .btn:hover {
1902 + border-color: #A9834B;
1903 + color: #EEE2C4;
1904 +}
1905 +
1906 +html[data-estilo="medieval"] .btn-primary {
1907 + background: linear-gradient(180deg, #6B2828 0%, #3E1717 100%);
1908 + border: 1px solid #A9834B;
1909 + color: #EEE2C4;
1910 +}
1911 +
1912 +html[data-estilo="medieval"] .btn-primary:hover {
1913 + background: linear-gradient(180deg, #7A2E2E 0%, #4A1C1C 100%);
1914 + color: #EEE2C4;
1915 + border-color: #A9834B;
1916 +}
1917 +
1918 +html[data-estilo="medieval"] .btn-secondary {
1919 + background: linear-gradient(180deg, #4A2B1C 0%, #2A1810 100%);
1920 + border: 1px solid #7C6440;
1921 + color: #EEE2C4;
1922 +}
1923 +
1924 +html[data-estilo="medieval"] .btn-secondary:hover {
1925 + background: #5C3A24;
1926 + color: #EEE2C4;
1927 +}
1928 +
1929 +html[data-estilo="medieval"] .btn-outline {
1930 + background: transparent;
1931 + border: 1px solid #A9834B;
1932 + color: #A9834B;
1933 +}
1934 +
1935 +html[data-estilo="medieval"] .btn-outline:hover {
1936 + background: #A9834B;
1937 + color: #120A08;
1938 +}
1939 +
1940 +html[data-estilo="medieval"] .btn-danger {
1941 + border: 1px solid #7A2E2E;
1942 + color: #C98B8B;
1943 + background: transparent;
1944 +}
1945 +
1946 +html[data-estilo="medieval"] .btn-danger:hover {
1947 + background: #7A2E2E;
1948 + color: #EEE2C4;
1949 +}
1950 +
1951 +html[data-estilo="medieval"] .btn-success {
1952 + background: #4A5C2A;
1953 + color: #EEE2C4;
1954 +}
1955 +html[data-estilo="medieval"] .btn-success:hover { background: #5C6E38; }
1956 +
1957 +html[data-estilo="medieval"] .btn-solid {
1958 + background: #5C2323;
1959 + color: #EEE2C4;
1960 + border: 1px solid #A9834B;
1961 + font-weight: 700;
1962 + text-transform: uppercase;
1963 + letter-spacing: 1px;
1964 + transition: background-color 0.25s ease, color 0.25s ease;
1965 +}
1966 +
1967 +html[data-estilo="medieval"] .btn-solid:hover {
1968 + background: #7C6440;
1969 + color: #342A1C;
1970 +}
1971 +
1972 +html[data-estilo="medieval"] .btn-danger-clean {
1973 + border: 1px solid #7A2E2E;
1974 + color: #C98B8B;
1975 + background: transparent;
1976 +}
1977 +html[data-estilo="medieval"] .btn-danger-clean:hover { background: #7A2E2E; color: #EEE2C4; }
1978 +
1979 +html[data-estilo="medieval"] .btn-success-clean {
1980 + border: 1px solid #A9834B;
1981 + color: #A9834B;
1982 + background: transparent;
1983 +}
1984 +html[data-estilo="medieval"] .btn-success-clean:hover { background: #A9834B; color: #120A08; }
1985 +
1986 +/* Formularios */
1987 +html[data-estilo="medieval"] input,
1988 +html[data-estilo="medieval"] select,
1989 +html[data-estilo="medieval"] textarea,
1990 +html[data-estilo="medieval"] .form-control,
1991 +html[data-estilo="medieval"] .select-materia {
1992 + background: rgba(12, 8, 6, 0.85);
1993 + border: 1px solid #7C6440;
1994 + color: #EEE2C4;
1995 + box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.6);
1996 + outline: none;
1997 + transition: border-color 0.25s ease, box-shadow 0.25s ease;
1998 +}
1999 +
2000 +html[data-estilo="medieval"] input:focus,
2001 +html[data-estilo="medieval"] select:focus,
2002 +html[data-estilo="medieval"] textarea:focus,
2003 +html[data-estilo="medieval"] .form-control:focus,
2004 +html[data-estilo="medieval"] .select-materia:focus {
2005 + border-color: #A9834B;
2006 + box-shadow: 0 0 12px rgba(169, 131, 75, 0.3), inset 0 0 8px rgba(0, 0, 0, 0.6);
2007 +}
2008 +
2009 +html[data-estilo="medieval"] select option { background: #1C120E; color: #EEE2C4; }
2010 +html[data-estilo="medieval"] .select-materia { color: #C9BB98; }
2011 +
2012 +html[data-estilo="medieval"] input::placeholder,
2013 +html[data-estilo="medieval"] textarea::placeholder { color: #8a7755; }
2014 +
2015 +html[data-estilo="medieval"] label,
2016 +html[data-estilo="medieval"] legend,
2017 +html[data-estilo="medieval"] th { color: #EEE2C4; }
2018 +
2019 +html[data-estilo="medieval"] td { color: #C9BB98; }
2020 +html[data-estilo="medieval"] hr { border-color: #3B211C; }
2021 +
2022 +/* Alerts, badges, avatares */
2023 +html[data-estilo="medieval"] .alert {
2024 + border: 1px solid #7C6440;
2025 + background: rgba(28, 16, 12, 0.9);
2026 + color: #C9BB98;
2027 +}
2028 +
2029 +html[data-estilo="medieval"] .alert-error {
2030 + background: #2A1210;
2031 + color: #ECB4A8;
2032 + border-color: #6B2828;
2033 +}
2034 +
2035 +html[data-estilo="medieval"] .alert-success {
2036 + background: #1F2012;
2037 + color: #C5CB9A;
2038 + border-color: #4A5224;
2039 +}
2040 +
2041 +html[data-estilo="medieval"] .badge-primary,
2042 +html[data-estilo="medieval"] .badge-materia,
2043 +html[data-estilo="medieval"] .badge-verified,
2044 +html[data-estilo="medieval"] .badge-solved {
2045 + background: rgba(169, 131, 75, 0.12);
2046 + color: #A9834B;
2047 + border: 1px solid #7C6440;
2048 + text-transform: uppercase;
2049 + letter-spacing: 0.5px;
2050 +}
2051 +
2052 +html[data-estilo="medieval"] .badge-success {
2053 + background: rgba(169, 131, 75, 0.1);
2054 + color: #EEE2C4;
2055 + border: 1px solid #7C6440;
2056 +}
2057 +
2058 +html[data-estilo="medieval"] .badge-interacted,
2059 +html[data-estilo="medieval"] .hub-badge.badge-solucion,
2060 +html[data-estilo="medieval"] .hub-badge.badge-solved {
2061 + background: rgba(122, 46, 46, 0.25);
2062 + color: #C98B8B;
2063 + border: 1px solid #6B2828;
2064 + text-transform: uppercase;
2065 +}
2066 +
2067 +html[data-estilo="medieval"] .badge-green,
2068 +html[data-estilo="medieval"] .badge-warning,
2069 +html[data-estilo="medieval"] .badge-info,
2070 +html[data-estilo="medieval"] .badge-blue {
2071 + background: rgba(169, 131, 75, 0.1);
2072 + color: #EEE2C4;
2073 + border: 1px solid #7C6440;
2074 +}
2075 +
2076 +html[data-estilo="medieval"] .badge-gray {
2077 + background: rgba(28, 18, 14, 0.9);
2078 + color: #C9BB98;
2079 + border: 1px solid #3B211C;
2080 +}
2081 +
2082 +html[data-estilo="medieval"] .badge-danger {
2083 + background: rgba(122, 46, 46, 0.25);
2084 + color: #C98B8B;
2085 + border: 1px solid #6B2828;
2086 +}
2087 +
2088 +html[data-estilo="medieval"] .avatar,
2089 +html[data-estilo="medieval"] .hub-avatar {
2090 + background-color: #5C2323;
2091 + color: #EEE2C4;
2092 + border: 1px solid #A9834B;
2093 + font-family: "Cinzel", serif;
2094 + border-radius: 0 !important;
2095 +}
2096 +
2097 +html[data-estilo="medieval"] .avatar.prof {
2098 + background-color: #7C6440;
2099 + color: #342A1C;
2100 +}
2101 +
2102 +/* Toast */
2103 +html[data-estilo="medieval"] .toast {
2104 + background: #1C120E;
2105 + color: #EEE2C4;
2106 + border: 1px solid #7C6440;
2107 + box-shadow: 0 15px 40px rgba(0, 0, 0, 0.8);
2108 +}
2109 +
2110 +html[data-estilo="medieval"] .toast-info { border-left: 4px solid #A9834B; }
2111 +html[data-estilo="medieval"] .toast-success { border-left: 4px solid #5C6E38; }
2112 +html[data-estilo="medieval"] .toast-error { border-left: 4px solid #7A2E2E; }
2113 +
2114 +/* Follow button */
2115 +html[data-estilo="medieval"] .follow-btn {
2116 + border: 1px solid #A9834B;
2117 + color: #A9834B;
2118 + background: transparent;
2119 +}
2120 +
2121 +html[data-estilo="medieval"] .follow-btn:hover {
2122 + background: rgba(169, 131, 75, 0.12);
2123 +}
2124 +
2125 +html[data-estilo="medieval"] .follow-btn.siguiendo {
2126 + background: #A9834B;
2127 + color: #120A08;
2128 +}
2129 +
2130 +/* ============================================================
2131 + POP-UP DE BIENVENIDA: EL GRAN SALÓN (inicio)
2132 + ============================================================ */
2133 +html[data-estilo="medieval"] .welcome-overlay {
2134 + background-color: rgba(8, 5, 4, 0.88);
2135 + backdrop-filter: blur(4px);
2136 +}
2137 +
2138 +html[data-estilo="medieval"] .welcome-popup {
2139 + position: relative;
2140 + background: linear-gradient(180deg, #2A1512 0%, #1C120E 100%);
2141 + border: 1px solid #7C6440;
2142 + padding: 46px 50px 40px;
2143 + max-width: 440px;
2144 + width: 90%;
2145 + text-align: center;
2146 + box-shadow: 0 30px 70px rgba(0, 0, 0, 0.55), inset 0 0 0 1px rgba(169, 131, 75, 0.3);
2147 + animation: castleHallRise 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards;
2148 +}
2149 +
2150 +@keyframes castleHallRise {
2151 + 0% { transform: translateY(22px) scale(0.96); opacity: 0; }
2152 + 100% { transform: translateY(0) scale(1); opacity: 1; }
2153 +}
2154 +
2155 +html[data-estilo="medieval"] .welcome-popup::before {
2156 + content: "❖";
2157 + position: absolute;
2158 + top: 12px;
2159 + left: 12px;
2160 + color: #7C6440;
2161 + font-size: 0.85rem;
2162 + background: none;
2163 + box-shadow: none;
2164 + animation: none;
2165 + opacity: 1;
2166 +}
2167 +
2168 +html[data-estilo="medieval"] .welcome-popup::after {
2169 + content: "❖";
2170 + position: absolute;
2171 + top: 12px;
2172 + right: 12px;
2173 + color: #7C6440;
2174 + font-size: 0.85rem;
2175 + background: none;
2176 + box-shadow: none;
2177 + animation: none;
2178 + opacity: 1;
2179 +}
2180 +
2181 +html[data-estilo="medieval"] .popup-tag {
2182 + color: #7C6440;
2183 + font-size: 0.7rem;
2184 + font-family: "Cinzel", serif;
2185 + letter-spacing: 2.5px;
2186 + text-transform: uppercase;
2187 + margin-bottom: 10px;
2188 + text-align: center;
2189 +}
2190 +
2191 +html[data-estilo="medieval"] .popup-title {
2192 + color: #EEE2C4;
2193 + font-size: 1.4rem;
2194 + font-weight: 600;
2195 + letter-spacing: 0.5px;
2196 + margin-bottom: 22px;
2197 + text-shadow: none;
2198 + text-transform: none;
2199 +}
2200 +
2201 +html[data-estilo="medieval"] .popup-username {
2202 + font-family: "Cinzel", serif;
2203 + font-size: 0.95rem;
2204 + font-weight: 600;
2205 + color: #EEE2C4;
2206 + letter-spacing: 0.5px;
2207 + margin: 0 0 8px 0;
2208 + padding: 0 0 9px 0;
2209 + background: transparent;
2210 + border: none;
2211 + border-bottom: 1px solid rgba(169, 131, 75, 0.3);
2212 + display: block;
2213 +}
2214 +
2215 +html[data-estilo="medieval"] .popup-status {
2216 + color: #C9BB98;
2217 + font-size: 0.8rem;
2218 + font-style: italic;
2219 + text-align: center;
2220 +}
2221 +
2222 +html[data-estilo="medieval"] .welcome-popup,
2223 +html[data-estilo="medieval"] .popup-tag,
2224 +html[data-estilo="medieval"] .popup-title,
2225 +html[data-estilo="medieval"] .popup-username,
2226 +html[data-estilo="medieval"] .popup-status { text-align: center; }
2227 +
2228 +/* ============================================================
2229 + COMPONENTES DEL PROTOTIPO ADAPTADOS (inicio real)
2230 + ============================================================ */
2231 +
2232 +html[data-estilo="medieval"] .btn-clean,
2233 +html[data-estilo="medieval"] .side-link {
2234 + color: #A9834B;
2235 + border: 1px solid #A9834B;
2236 + background: transparent;
2237 + text-transform: uppercase;
2238 + letter-spacing: 1px;
2239 + font-size: 0.78rem;
2240 + font-weight: 600;
2241 + transition: background-color 0.25s ease, color 0.25s ease;
2242 +}
2243 +
2244 +html[data-estilo="medieval"] .btn-clean:hover,
2245 +html[data-estilo="medieval"] .side-link:hover {
2246 + background: #A9834B;
2247 + color: #120A08;
2248 +}
2249 +
2250 +html[data-estilo="medieval"] .btn-action {
2251 + border: 1px solid transparent;
2252 + color: #C9BB98;
2253 + background: transparent;
2254 + text-transform: uppercase;
2255 + font-size: 0.75rem;
2256 + transition: color 0.25s ease, border-color 0.25s ease;
2257 +}
2258 +
2259 +html[data-estilo="medieval"] .btn-action:hover {
2260 + color: #A9834B;
2261 + border-color: #A9834B;
2262 +}
2263 +
2264 +html[data-estilo="medieval"] .btn-action.voted {
2265 + color: #C98B8B;
2266 + border-color: #6B2828;
2267 +}
2268 +
2269 +/* Publicaciones y widgets con elevación de pergamino */
2270 +html[data-estilo="medieval"] .post-card,
2271 +html[data-estilo="medieval"] .side-card,
2272 +html[data-estilo="medieval"] .widget-card {
2273 + background: rgba(28, 16, 12, 0.7);
2274 + border: 1px solid #7C6440;
2275 + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
2276 + backdrop-filter: none;
2277 + transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s ease, box-shadow 0.3s ease;
2278 +}
2279 +
2280 +html[data-estilo="medieval"] .post-card:hover,
2281 +html[data-estilo="medieval"] .side-card:hover,
2282 +html[data-estilo="medieval"] .widget-card:hover {
2283 + transform: translateY(-3px);
2284 + border-color: #A9834B;
2285 + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6);
2286 +}
2287 +
2288 +html[data-estilo="medieval"] .widget-time {
2289 + font-size: 0.68rem;
2290 + color: #A9834B;
2291 + font-weight: 600;
2292 + text-transform: uppercase;
2293 + letter-spacing: 1px;
2294 + font-family: "Cinzel", serif;
2295 +}
2296 +
2297 +html[data-estilo="medieval"] .widget-title { color: #EEE2C4; font-weight: 600; }
2298 +html[data-estilo="medieval"] .widget-desc { color: #C9BB98; }
2299 +
2300 +html[data-estilo="medieval"] .btn-widget {
2301 + display: block;
2302 + width: 100%;
2303 + text-align: center;
2304 + background: #5C2323;
2305 + color: #EEE2C4;
2306 + text-decoration: none;
2307 + border: 1px solid #7C6440;
2308 + padding: 10px;
2309 + font-size: 0.72rem;
2310 + font-weight: 700;
2311 + letter-spacing: 1px;
2312 + font-family: "Cinzel", serif;
2313 + text-transform: uppercase;
2314 + transition: background-color 0.25s ease, color 0.25s ease;
2315 +}
2316 +
2317 +html[data-estilo="medieval"] .btn-widget:hover {
2318 + background: #7C6440;
2319 + color: #342A1C;
2320 +}
2321 +
2322 +/* Progreso de carrera en latón */
2323 +html[data-estilo="medieval"] .progress-bar {
2324 + height: 6px;
2325 + background: rgba(0, 0, 0, 0.4);
2326 + border: 1px solid #7C6440;
2327 + margin-top: 14px;
2328 + overflow: hidden;
2329 +}
2330 +
2331 +html[data-estilo="medieval"] .progress-fill {
2332 + background: linear-gradient(90deg, #5C2323, #A9834B);
2333 + transition: width 1.4s cubic-bezier(0.16, 1, 0.3, 1) 0.3s;
2334 +}
2335 +
2336 +html[data-estilo="medieval"] .progress-labels {
2337 + font-size: 0.78rem;
2338 + color: #C9BB98;
2339 +}
2340 +
2341 +/* Banner, vacíos y notas de filtro */
2342 +html[data-estilo="medieval"] .maps-banner .banner-content,
2343 +html[data-estilo="medieval"] .banner-title {
2344 + color: #EEE2C4;
2345 +}
2346 +
2347 +html[data-estilo="medieval"] .empty-state h2 { color: #EEE2C4; }
2348 +html[data-estilo="medieval"] .empty-state p,
2349 +html[data-estilo="medieval"] .filter-note { color: #C9BB98; }
2350 +
2351 +/* ============================================================
2352 + COMUNIDAD: FORO, RESPUESTAS, APUNTES, TIPS, MODALES
2353 + ============================================================ */
2354 +html[data-estilo="medieval"] .hub-filter-bar {
2355 + background: rgba(28, 16, 12, 0.9);
2356 + border: 1px solid #7C6440;
2357 +}
2358 +
2359 +html[data-estilo="medieval"] .hub-tabs button {
2360 + background: transparent;
2361 + color: #C9BB98;
2362 + border: 1px solid transparent;
2363 + text-transform: uppercase;
2364 + letter-spacing: 0.5px;
2365 + transition: color 0.25s ease, border-color 0.25s ease;
2366 +}
2367 +
2368 +html[data-estilo="medieval"] .hub-tabs button:hover {
2369 + color: #EEE2C4;
2370 + border-color: #A9834B;
2371 +}
2372 +
2373 +html[data-estilo="medieval"] .hub-tabs button.active {
2374 + background: rgba(169, 131, 75, 0.12);
2375 + color: #EEE2C4;
2376 + border: 1px solid #A9834B;
2377 +}
2378 +
2379 +html[data-estilo="medieval"] .input-search {
2380 + background: rgba(12, 8, 6, 0.85);
2381 + border: 1px solid #7C6440;
2382 + color: #EEE2C4;
2383 + box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.6);
2384 +}
2385 +
2386 +html[data-estilo="medieval"] .input-search:focus {
2387 + border-color: #A9834B;
2388 + box-shadow: 0 0 12px rgba(169, 131, 75, 0.3), inset 0 0 8px rgba(0, 0, 0, 0.6);
2389 +}
2390 +
2391 +html[data-estilo="medieval"] .hub-badge {
2392 + background: rgba(169, 131, 75, 0.12);
2393 + color: #A9834B;
2394 + border: 1px solid #7C6440;
2395 + text-transform: uppercase;
2396 + letter-spacing: 0.5px;
2397 +}
2398 +
2399 +html[data-estilo="medieval"] .foro-autor-line strong,
2400 +html[data-estilo="medieval"] .user-data-line strong { color: #EEE2C4; }
2401 +
2402 +html[data-estilo="medieval"] .post-title { color: #EEE2C4; font-weight: 600; }
2403 +
2404 +html[data-estilo="medieval"] .post-desc {
2405 + color: #C9BB98;
2406 + line-height: 1.55;
2407 +}
2408 +
2409 +html[data-estilo="medieval"] .post-desc code {
2410 + background: #1C120E;
2411 + border: 1px solid #3B211C;
2412 + color: #A9834B;
2413 +}
2414 +
2415 +html[data-estilo="medieval"] .solved-box {
2416 + background: rgba(122, 46, 46, 0.15);
2417 + border: 1px solid rgba(169, 131, 75, 0.4);
2418 + color: #EEE2C4;
2419 +}
2420 +
2421 +html[data-estilo="medieval"] .btn-link,
2422 +html[data-estilo="medieval"] .foro-action {
2423 + color: #A9834B;
2424 + background: transparent;
2425 + text-transform: uppercase;
2426 + letter-spacing: 0.5px;
2427 + transition: color 0.25s ease;
2428 +}
2429 +
2430 +html[data-estilo="medieval"] .btn-link:hover,
2431 +html[data-estilo="medieval"] .foro-action:hover {
2432 + color: #EEE2C4;
2433 +}
2434 +
2435 +html[data-estilo="medieval"] .apunte-card,
2436 +html[data-estilo="medieval"] .tip-card {
2437 + background: rgba(28, 16, 12, 0.9);
2438 + border: 1px solid #7C6440;
2439 + border-left: 4px solid #A9834B;
2440 + backdrop-filter: none;
2441 + transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s ease, box-shadow 0.3s ease;
2442 +}
2443 +
2444 +html[data-estilo="medieval"] .apunte-card:hover,
2445 +html[data-estilo="medieval"] .tip-card:hover {
2446 + transform: translateY(-3px);
2447 + border-color: #A9834B;
2448 + box-shadow: 0 15px 40px rgba(0, 0, 0, 0.6);
2449 +}
2450 +
2451 +html[data-estilo="medieval"] .file-icon {
2452 + background: rgba(169, 131, 75, 0.12);
2453 + border: 1px solid #A9834B;
2454 + color: #A9834B;
2455 +}
2456 +
2457 +html[data-estilo="medieval"] .apunte-details h4 { color: #EEE2C4; }
2458 +html[data-estilo="medieval"] .apunte-details p { color: #C9BB98; }
2459 +
2460 +html[data-estilo="medieval"] .tip-title,
2461 +html[data-estilo="medieval"] .tip-card h4 { color: #EEE2C4; }
2462 +html[data-estilo="medieval"] .tip-text { color: #C9BB98; }
2463 +html[data-estilo="medieval"] .tip-text code {
2464 + background: #1C120E;
2465 + border: 1px solid #3B211C;
2466 + color: #A9834B;
2467 +}
2468 +
2469 +html[data-estilo="medieval"] .vote-btn {
2470 + background: rgba(28, 18, 14, 0.9);
2471 + border: 1px solid #3B211C;
2472 + color: #C9BB98;
2473 + transition: color 0.25s ease, border-color 0.25s ease;
2474 +}
2475 +
2476 +html[data-estilo="medieval"] .vote-btn:hover {
2477 + border-color: #A9834B;
2478 + color: #A9834B;
2479 +}
2480 +
2481 +html[data-estilo="medieval"] .vote-btn.votado {
2482 + border-color: #6B2828;
2483 + color: #C98B8B;
2484 +}
2485 +
2486 +/* Modales del hub */
2487 +html[data-estilo="medieval"] .hub-modal-overlay {
2488 + background: rgba(8, 5, 4, 0.88);
2489 + backdrop-filter: blur(4px);
2490 +}
2491 +
2492 +html[data-estilo="medieval"] .hub-modal {
2493 + background: linear-gradient(180deg, #2A1512 0%, #1C120E 100%);
2494 + border: 1px solid #7C6440;
2495 + box-shadow: 0 30px 70px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(169, 131, 75, 0.2);
2496 +}
2497 +
2498 +html[data-estilo="medieval"] .hub-modal h3,
2499 +html[data-estilo="medieval"] .hub-modal h4 { color: #EEE2C4; }
2500 +
2501 +html[data-estilo="medieval"] .hub-modal .modal-sub { color: #C9BB98; }
2502 +html[data-estilo="medieval"] .hub-modal label { color: #EEE2C4; }
2503 +html[data-estilo="medieval"] .modal-msg { color: #C9BB98; }
2504 +
2505 +/* Modal de duplicado / respuestas */
2506 +html[data-estilo="medieval"] .dup-modal { border: 1px solid #7C6440; }
2507 +
2508 +html[data-estilo="medieval"] .dup-notice {
2509 + background: rgba(122, 46, 46, 0.15);
2510 + border: 1px solid rgba(169, 131, 75, 0.4);
2511 +}
2512 +
2513 +html[data-estilo="medieval"] .dup-titulo { color: #EEE2C4; }
2514 +html[data-estilo="medieval"] .dup-notice p,
2515 +html[data-estilo="medieval"] .dup-por { color: #C9BB98; }
2516 +
2517 +html[data-estilo="medieval"] .dup-question {
2518 + background: rgba(28, 16, 12, 0.9);
2519 + border-left: 4px solid #A9834B;
2520 +}
2521 +
2522 +html[data-estilo="medieval"] .dup-section-title {
2523 + color: #EEE2C4;
2524 + text-transform: uppercase;
2525 + letter-spacing: 0.5px;
2526 + border-bottom-color: #3B211C;
2527 +}
2528 +
2529 +html[data-estilo="medieval"] .dup-respuestas,
2530 +html[data-estilo="medieval"] .dup-tips,
2531 +html[data-estilo="medieval"] .dup-recursos {
2532 + border-color: #3B211C;
2533 +}
2534 +
2535 +html[data-estilo="medieval"] .dup-vacio { color: #C9BB98; }
2536 +
2537 +/* Respuestas a un post */
2538 +html[data-estilo="medieval"] .hub-respuesta {
2539 + background: rgba(28, 16, 12, 0.9);
2540 + border: 1px solid #7C6440;
2541 + border-left: 4px solid #A9834B;
2542 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
2543 + backdrop-filter: none;
2544 + transition: border-color 0.25s ease, box-shadow 0.25s ease;
2545 +}
2546 +
2547 +html[data-estilo="medieval"] .hub-respuesta:hover {
2548 + border-color: #A9834B;
2549 + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.6);
2550 +}
2551 +
2552 +html[data-estilo="medieval"] .respuesta-autor {
2553 + color: #A9834B;
2554 + font-weight: 700;
2555 + text-transform: uppercase;
2556 + letter-spacing: 0.5px;
2557 +}
2558 +
2559 +html[data-estilo="medieval"] .respuesta-texto {
2560 + color: #C9BB98;
2561 + line-height: 1.55;
2562 +}
2563 +
2564 +/* ============================================================
2565 + MENSAJES: SALA DE CORRESPONDENCIA
2566 + ============================================================ */
2567 +html[data-estilo="medieval"] .messages-container {
2568 + --hub-border: #7C6440;
2569 + --hub-accent: #A9834B;
2570 + --hub-accent-dark: #7C6440;
2571 + --hub-accent-soft: rgba(169, 131, 75, 0.15);
2572 + --hub-muted: #C9BB98;
2573 + background: rgba(18, 12, 9, 0.92);
2574 + border: 1px solid #7C6440;
2575 +}
2576 +
2577 +html[data-estilo="medieval"] .chat-sidebar {
2578 + background: linear-gradient(180deg, #2A1512 0%, #1C120E 100%);
2579 + border-right: 1px solid #7C6440;
2580 +}
2581 +
2582 +html[data-estilo="medieval"] .chat-search-box { border-bottom-color: #3B211C; }
2583 +html[data-estilo="medieval"] .chat-search-box input {
2584 + background: rgba(12, 8, 6, 0.85);
2585 + border: 1px solid #7C6440;
2586 + color: #EEE2C4;
2587 + box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.6);
2588 +}
2589 +
2590 +html[data-estilo="medieval"] .chat-search-box input:focus {
2591 + border-color: #A9834B;
2592 + box-shadow: 0 0 12px rgba(169, 131, 75, 0.3), inset 0 0 8px rgba(0, 0, 0, 0.6);
2593 +}
2594 +
2595 +html[data-estilo="medieval"] .chat-item {
2596 + background: rgba(18, 12, 9, 0.4);
2597 + border-bottom: 1px solid #3B211C;
2598 + transition: background-color 0.2s ease, border-color 0.2s ease;
2599 +}
2600 +
2601 +html[data-estilo="medieval"] .chat-item:hover {
2602 + background: rgba(169, 131, 75, 0.08);
2603 +}
2604 +
2605 +html[data-estilo="medieval"] .chat-item.active {
2606 + background: rgba(169, 131, 75, 0.14);
2607 + border-left: 3px solid #A9834B;
2608 +}
2609 +
2610 +html[data-estilo="medieval"] .chat-item-header strong { color: #EEE2C4; }
2611 +html[data-estilo="medieval"] .chat-item-header span,
2612 +html[data-estilo="medieval"] .chat-item-preview { color: #C9BB98; }
2613 +
2614 +html[data-estilo="medieval"] .chat-window { background: rgba(18, 12, 9, 0.4); }
2615 +
2616 +html[data-estilo="medieval"] .chat-header {
2617 + background: rgba(28, 18, 14, 0.95);
2618 + border-bottom: 1px solid #3B211C;
2619 +}
2620 +
2621 +html[data-estilo="medieval"] .chat-header-user h3 { color: #EEE2C4; font-family: "Cinzel", serif; }
2622 +html[data-estilo="medieval"] .chat-header-user span { color: #C9BB98; }
2623 +
2624 +html[data-estilo="medieval"] .chat-body {
2625 + background-color: rgba(18, 12, 9, 0.5);
2626 + background-image: radial-gradient(rgba(169, 131, 75, 0.05) 1px, transparent 1px);
2627 + background-size: 26px 26px;
2628 +}
2629 +
2630 +html[data-estilo="medieval"] .message {
2631 + background: rgba(28, 16, 12, 0.9);
2632 + border: 1px solid #3B211C;
2633 + border-left: 4px solid #A9834B;
2634 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
2635 + backdrop-filter: none;
2636 + transition: border-color 0.25s ease, box-shadow 0.25s ease;
2637 +}
2638 +
2639 +html[data-estilo="medieval"] .message.received:hover,
2640 +html[data-estilo="medieval"] .message.sent:hover {
2641 + border-color: #A9834B;
2642 + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.55);
2643 +}
2644 +
2645 +html[data-estilo="medieval"] .message.sent {
2646 + background: linear-gradient(135deg, rgba(92, 35, 35, 0.92) 0%, rgba(28, 16, 12, 0.95) 100%);
2647 + border: 1px solid rgba(169, 131, 75, 0.4);
2648 + border-left: 4px solid #7A2E2E;
2649 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.55);
2650 +}
2651 +
2652 +html[data-estilo="medieval"] .message-time { color: #C9BB98; }
2653 +
2654 +html[data-estilo="medieval"] .message-texto {
2655 + color: #EEE2C4;
2656 + line-height: 1.5;
2657 +}
2658 +
2659 +/* Adjuntos */
2660 +html[data-estilo="medieval"] .message-attachment {
2661 + background: rgba(12, 8, 6, 0.85);
2662 + border: 1px solid #3B211C;
2663 +}
2664 +
2665 +html[data-estilo="medieval"] .message.sent .message-attachment {
2666 + border-color: rgba(169, 131, 75, 0.35);
2667 +}
2668 +
2669 +html[data-estilo="medieval"] .attachment-icon {
2670 + background: rgba(169, 131, 75, 0.12);
2671 + border: 1px solid #A9834B;
2672 + color: #A9834B;
2673 +}
2674 +
2675 +html[data-estilo="medieval"] .message.sent .attachment-icon {
2676 + background: rgba(122, 46, 46, 0.25);
2677 + border-color: #7A2E2E;
2678 + color: #C98B8B;
2679 +}
2680 +
2681 +html[data-estilo="medieval"] .attachment-nombre { color: #EEE2C4; }
2682 +html[data-estilo="medieval"] .attachment-peso { color: #C9BB98; }
2683 +
2684 +html[data-estilo="medieval"] .adjunto-descargar {
2685 + background: transparent;
2686 + border: 1px solid #A9834B;
2687 + color: #A9834B;
2688 + text-transform: uppercase;
2689 + transition: background-color 0.25s ease, color 0.25s ease;
2690 +}
2691 +
2692 +html[data-estilo="medieval"] .adjunto-descargar:hover {
2693 + background: #A9834B;
2694 + color: #120A08;
2695 +}
2696 +
2697 +html[data-estilo="medieval"] .message.sent .adjunto-descargar {
2698 + border-color: #C98B8B;
2699 + color: #C98B8B;
2700 +}
2701 +
2702 +html[data-estilo="medieval"] .message.sent .adjunto-descargar:hover {
2703 + background: #7A2E2E;
2704 + color: #EEE2C4;
2705 +}
2706 +
2707 +/* Barra de escritura y vacío */
2708 +html[data-estilo="medieval"] .chat-input-bar {
2709 + background: rgba(28, 18, 14, 0.95);
2710 + border-top: 1px solid #3B211C;
2711 +}
2712 +
2713 +html[data-estilo="medieval"] .chat-input-bar input {
2714 + background: rgba(12, 8, 6, 0.85);
2715 + border: 1px solid #7C6440;
2716 + color: #EEE2C4;
2717 + box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.6);
2718 + outline: none;
2719 +}
2720 +
2721 +html[data-estilo="medieval"] .chat-input-bar input:focus {
2722 + border-color: #A9834B;
2723 + box-shadow: 0 0 12px rgba(169, 131, 75, 0.3), inset 0 0 8px rgba(0, 0, 0, 0.6);
2724 +}
2725 +
2726 +html[data-estilo="medieval"] .chat-empty {
2727 + background-color: rgba(18, 12, 9, 0.5);
2728 +}
2729 +
2730 +html[data-estilo="medieval"] .chat-empty p,
2731 +html[data-estilo="medieval"] .chat-empty span { color: #C9BB98; }
2732 +
2733 +html[data-estilo="medieval"] .adjunto-pendiente {
2734 + background: rgba(169, 131, 75, 0.1);
2735 + border: 1px solid #A9834B;
2736 + color: #EEE2C4;
2737 +}
2738 +
2739 +html[data-estilo="medieval"] .adjunto-quitar { color: #C98B8B; }
2740 +
2741 +/* ============================================================
2742 + PERFIL: STATS, CERTIFICADOS, ACTIVIDAD, DISCOVERY
2743 + ============================================================ */
2744 +html[data-estilo="medieval"] .profile-name {
2745 + color: #EEE2C4;
2746 + letter-spacing: 0.5px;
2747 +}
2748 +
2749 +html[data-estilo="medieval"] .profile-sub,
2750 +html[data-estilo="medieval"] .discover-empty { color: #C9BB98; }
2751 +
2752 +html[data-estilo="medieval"] .red-stat .number,
2753 +html[data-estilo="medieval"] .stat-box .number {
2754 + color: #A9834B;
2755 + font-family: "Cinzel", serif;
2756 +}
2757 +
2758 +html[data-estilo="medieval"] .red-stat .label,
2759 +html[data-estilo="medieval"] .stat-box .label { color: #C9BB98; }
2760 +
2761 +html[data-estilo="medieval"] .red-stat,
2762 +html[data-estilo="medieval"] .stat-box {
2763 + background: rgba(28, 16, 12, 0.85);
2764 + border: 1px solid #7C6440;
2765 + border-left: 4px solid #A9834B;
2766 +}
2767 +
2768 +html[data-estilo="medieval"] .discover-person {
2769 + background: rgba(28, 16, 12, 0.9);
2770 + border: 1px solid #7C6440;
2771 + border-left: 4px solid #A9834B;
2772 + backdrop-filter: none;
2773 + transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.3s ease, box-shadow 0.3s ease;
2774 +}
2775 +
2776 +html[data-estilo="medieval"] .discover-person:hover {
2777 + transform: translateY(-3px);
2778 + border-color: #A9834B;
2779 + box-shadow: 0 15px 40px rgba(0, 0, 0, 0.6);
2780 +}
2781 +
2782 +html[data-estilo="medieval"] .discover-info strong { color: #EEE2C4; }
2783 +
2784 +html[data-estilo="medieval"] .certificate-item {
2785 + background: rgba(28, 16, 12, 0.9);
2786 + border: 1px solid #7C6440;
2787 + border-left: 4px solid #A9834B;
2788 + backdrop-filter: none;
2789 + transition: border-color 0.25s ease, box-shadow 0.25s ease;
2790 +}
2791 +
2792 +html[data-estilo="medieval"] .certificate-item:hover {
2793 + border-color: #A9834B;
2794 + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.55);
2795 +}
2796 +
2797 +html[data-estilo="medieval"] .certificate-item.active {
2798 + border-left-color: #7A2E2E;
2799 + box-shadow: inset 0 0 15px rgba(122, 46, 46, 0.15);
2800 +}
2801 +
2802 +html[data-estilo="medieval"] .cert-title,
2803 +html[data-estilo="medieval"] .activity-item strong { color: #EEE2C4; }
2804 +html[data-estilo="medieval"] .cert-desc,
2805 +html[data-estilo="medieval"] .activity-item { color: #C9BB98; }
2806 +
2807 +html[data-estilo="medieval"] .proposito-modal {
2808 + background: linear-gradient(180deg, #2A1512 0%, #1C120E 100%);
2809 + border: 1px solid #7C6440;
2810 + box-shadow: 0 30px 70px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(169, 131, 75, 0.2);
2811 +}
2812 +
2813 +html[data-estilo="medieval"] .proposito-modal h3 { color: #EEE2C4; font-family: "Cinzel", serif; }
2814 +html[data-estilo="medieval"] .proposito-modal-sub { color: #C9BB98; }
2815 +
2816 +/* ============================================================
2817 + MODO OSCURO (en realidad ya es nocturno; se refuerzan contrastes)
2818 + ============================================================ */
2819 +html[data-estilo="medieval"].dark-mode {
2820 + --void: #0A0504;
2821 + --panel: #1E100D;
2822 + --panel-light: #2A1813;
2823 + --wine: #4A1C1C;
2824 + --wine-deep: #301111;
2825 + --brass: #A9834B;
2826 + --brass-dim: #7C6440;
2827 + --parchment: #E8D9B6;
2828 + --parchment-dim: #B8A981;
2829 + --umber: #120A08;
2830 +
2831 + --accent: #A9834B;
2832 + --accent-dark: #7C6440;
2833 + --accent-soft: #1E100D;
2834 + --accent-soft-border: #5a4930;
2835 + --accent-contrast: #E8D9B6;
2836 +
2837 + --sidebar-bg: #120A08;
2838 + --topbar-bg: rgba(10, 5, 4, 0.85);
2839 +}
2840 +
2841 +html[data-estilo="medieval"].dark-mode body {
2842 + background-color: #0A0504;
2843 + color: #C9BB98;
2844 +}
2845 +
2846 +html[data-estilo="medieval"].dark-mode .card,
2847 +html[data-estilo="medieval"].dark-mode .post-card,
2848 +html[data-estilo="medieval"].dark-mode .widget-card,
2849 +html[data-estilo="medieval"].dark-mode .side-card,
2850 +html[data-estilo="medieval"].dark-mode .apunte-card,
2851 +html[data-estilo="medieval"].dark-mode .tip-card,
2852 +html[data-estilo="medieval"].dark-mode .discover-person,
2853 +html[data-estilo="medieval"].dark-mode .certificate-item,
2854 +html[data-estilo="medieval"].dark-mode .hub-respuesta {
2855 + background: rgba(18, 10, 8, 0.9);
2856 +}
2857 +
2858 +html[data-estilo="medieval"].dark-mode .sidebar {
2859 + background: linear-gradient(180deg, #1E100D 0%, #120A08 100%);
2860 +}
2861 +
2862 +html[data-estilo="medieval"].dark-mode .heraldic-transition { background: #0A0504; }
2863 +
2864 +html[data-estilo="medieval"].dark-mode .welcome-overlay { background-color: rgba(4, 2, 2, 0.92); }
2865 +
2866 +/* ============================================================
2867 + ESTILO: SAIYAN POWER EDITION
2868 + Naranja intenso, azul navy y aura dorada del dojo Z-Warriors.
2869 + ============================================================ */
2870 +
2871 +/* --- Paleta global del skin --- */
2872 +html[data-estilo="saiyan"] {
2873 + --accent: #facc15;
2874 + --accent-dark: #eab308;
2875 + --accent-mid: #fde047;
2876 + --accent-bright: #fef9c3;
2877 + --accent-deep: #1e3a8a;
2878 + --accent-soft: #0f172a;
2879 + --accent-soft-border: #facc15;
2880 + --accent-contrast: #f97316;
2881 +
2882 + --primary-color: #facc15;
2883 + --secondary-color: #1e3a8a;
2884 + --link-color: #facc15;
2885 + --heading-color: #ffffff;
2886 + --text-color: #f8fafc;
2887 +
2888 + --sidebar-bg: #1e3a8a;
2889 + --sidebar-border: #facc15;
2890 + --sidebar-text: #ffffff;
2891 + --sidebar-text-hover: #ffffff;
2892 + --sidebar-item-hover-bg: #f97316;
2893 + --sidebar-item-active-bg: #facc15;
2894 + --sidebar-accent: #facc15;
2895 +
2896 + --topbar-bg: rgba(30, 58, 138, 0.85);
2897 +
2898 + --btn-primary-bg: linear-gradient(135deg, #facc15 0%, #fde047 100%);
2899 + --btn-primary-text: #1e3a8a;
2900 + --btn-outline-border: #facc15;
2901 + --btn-outline-text: #facc15;
2902 +}
2903 +
2904 +/* --- Tipografía y geometría --- */
2905 +html[data-estilo="saiyan"] * {
2906 + font-family: 'Comic Sans MS', 'Comic Sans', 'Chalkboard SE', 'Comic Neue', 'Bangers', 'Impact', sans-serif;
2907 + border-radius: 0 !important;
2908 +}
2909 +
2910 +html[data-estilo="saiyan"] .saiyan-txt,
2911 +html[data-estilo="saiyan"] .page-title,
2912 +html[data-estilo="saiyan"] .banner-title,
2913 +html[data-estilo="saiyan"] .popup-title,
2914 +html[data-estilo="saiyan"] .post-title,
2915 +html[data-estilo="saiyan"] .widget-title,
2916 +html[data-estilo="saiyan"] .cert-title,
2917 +html[data-estilo="saiyan"] .sidebar-header h1 {
2918 + font-family: 'Comic Sans MS', 'Comic Sans', 'Chalkboard SE', 'Bangers', 'Impact', 'Arial Black', sans-serif;
2919 + font-weight: 900;
2920 +}
2921 +
2922 +html[data-estilo="saiyan"] body {
2923 + background-color: #f97316;
2924 + color: #ffffff;
2925 + position: relative;
2926 +}
2927 +
2928 +html[data-estilo="saiyan"] a { color: #facc15; }
2929 +
2930 +html[data-estilo="saiyan"] h1,
2931 +html[data-estilo="saiyan"] h2,
2932 +html[data-estilo="saiyan"] h3,
2933 +html[data-estilo="saiyan"] h4 { color: #ffffff; }
2934 +
2935 +html[data-estilo="saiyan"] .page-title {
2936 + color: #ffffff;
2937 + letter-spacing: 1.5px;
2938 + text-shadow: 2px 2px 0px #f97316;
2939 +}
2940 +
2941 +html[data-estilo="saiyan"] .page-description { color: #bfdbfe; }
2942 +
2943 +html[data-estilo="saiyan"] .app-layout {
2944 + position: relative;
2945 + z-index: 2;
2946 +}
2947 +
2948 +/* ============================================================
2949 + ESCENARIO: NUBE KINTO Y ESTELAS DE KI (SVG 3D)
2950 + ============================================================ */
2951 +html[data-estilo="saiyan"] .saiyan-bg-viewport {
2952 + position: fixed;
2953 + top: 0; left: 0;
2954 + width: 100vw;
2955 + height: 100vh;
2956 + pointer-events: none;
2957 + z-index: 0;
2958 + overflow: hidden;
2959 + transform-style: preserve-3d;
2960 +}
2961 +
2962 +html[data-estilo="saiyan"] .saiyan-svg-layer {
2963 + position: absolute;
2964 + top: -20%;
2965 + left: -10%;
2966 + width: 120vw;
2967 + height: 130vh;
2968 + transform: translate3d(0px, 0px, 0px) scale(1.08);
2969 + transition: transform 1.5s cubic-bezier(0.17, 0.84, 0.44, 1);
2970 +}
2971 +
2972 +html[data-estilo="saiyan"] .kintoun-cloud {
2973 + fill: #ffffff;
2974 + opacity: 0.15;
2975 +}
2976 +
2977 +html[data-estilo="saiyan"] .ki-energy-trail {
2978 + stroke: #facc15;
2979 + stroke-width: 2;
2980 + fill: none;
2981 + stroke-dasharray: 10, 15;
2982 + opacity: 0.4;
2983 + animation: kiPulse 3s infinite alternate;
2984 +}
2985 +
2986 +@keyframes kiPulse {
2987 + 0% { opacity: 0.2; stroke-width: 1; }
2988 + 100% { opacity: 0.5; stroke-width: 3; }
2989 +}
2990 +
2991 +html[data-estilo="saiyan"] .atmosphere-overlay {
2992 + background: radial-gradient(circle at 50% 50%, rgba(250, 204, 21, 0.1) 0%, transparent 70%);
2993 +}
2994 +
2995 +/* ============================================================
2996 + CORTINA DE TRANSICIÓN: AURA DE KI
2997 + ============================================================ */
2998 +html[data-estilo="saiyan"] .saiyan-aura-transition {
2999 + position: fixed;
3000 + top: 0;
3001 + left: 0;
3002 + width: 100vw;
3003 + height: 100vh;
3004 + background: radial-gradient(circle, #facc15 0%, #f97316 40%, transparent 70%);
3005 + z-index: 9999;
3006 + clip-path: polygon(50% 0%, 50% 0%, 50% 100%, 50% 100%);
3007 + transition: clip-path 0.7s cubic-bezier(0.86, 0, 0.07, 1);
3008 + pointer-events: none;
3009 + mix-blend-mode: screen;
3010 +}
3011 +
3012 +html[data-estilo="saiyan"] .saiyan-aura-transition.active {
3013 + clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%);
3014 +}
3015 +
3016 +/* ============================================================
3017 + SIDEBAR: UNIFORME SAIYAN
3018 + ============================================================ */
3019 +html[data-estilo="saiyan"] .sidebar {
3020 + background: #1e3a8a;
3021 + border-right: 4px solid #facc15;
3022 + box-shadow: 10px 0 30px rgba(0, 0, 0, 0.3);
3023 +}
3024 +
3025 +html[data-estilo="saiyan"] .sidebar-header {
3026 + border-bottom: 2px solid #facc15;
3027 + text-align: center;
3028 +}
3029 +
3030 +html[data-estilo="saiyan"] .sidebar-header h1 {
3031 + color: #ffffff;
3032 + font-size: 2rem;
3033 + letter-spacing: 3px;
3034 + text-shadow: 2px 2px 0px #f97316;
3035 + text-transform: uppercase;
3036 +}
3037 +
3038 +html[data-estilo="saiyan"] .sidebar-header small { color: #bfdbfe; }
3039 +
3040 +html[data-estilo="saiyan"] .sidebar-user {
3041 + background: #ffffff;
3042 + border: 2px solid #facc15;
3043 + text-align: left;
3044 +}
3045 +
3046 +html[data-estilo="saiyan"] .sidebar-user strong { color: #1e3a8a; }
3047 +html[data-estilo="saiyan"] .sidebar-user small { color: #f97316; }
3048 +
3049 +html[data-estilo="saiyan"] .sidebar-avatar {
3050 + border: 2px solid #1e3a8a;
3051 + border-radius: 50% !important;
3052 +}
3053 +
3054 +html[data-estilo="saiyan"] .sidebar-nav a {
3055 + color: #ffffff;
3056 + border-left: 5px solid transparent;
3057 + letter-spacing: 1.5px;
3058 + text-transform: uppercase;
3059 + font-weight: 700;
3060 + transition: transform 0.2s ease, opacity 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease;
3061 +}
3062 +
3063 +html[data-estilo="saiyan"] .sidebar-nav a:hover {
3064 + background: #f97316;
3065 + color: #ffffff;
3066 + border-left-color: #facc15;
3067 + padding-left: 18px;
3068 +}
3069 +
3070 +html[data-estilo="saiyan"] .sidebar-nav a.active {
3071 + background: #facc15;
3072 + color: #1e3a8a;
3073 + font-weight: 900;
3074 + border-left-color: #ffffff;
3075 + text-shadow: none;
3076 +}
3077 +
3078 +html[data-estilo="saiyan"] .sidebar-footer { border-top: 2px solid #facc15; }
3079 +
3080 +html[data-estilo="saiyan"] .sidebar-footer .btn {
3081 + background: #f97316;
3082 + border: 2px solid #facc15;
3083 + color: #ffffff;
3084 + text-transform: uppercase;
3085 + letter-spacing: 2px;
3086 + font-weight: 900;
3087 +}
3088 +
3089 +html[data-estilo="saiyan"] .sidebar-footer .btn:hover {
3090 + background: #ffffff;
3091 + color: #1e3a8a;
3092 + border-color: #ffffff;
3093 + box-shadow: 0 0 15px #facc15;
3094 +}
3095 +
3096 +/* ============================================================
3097 + TOPBAR SAIYAN
3098 + ============================================================ */
3099 +html[data-estilo="saiyan"] .topbar {
3100 + background: var(--topbar-bg);
3101 + border-bottom: 2px solid #facc15;
3102 + backdrop-filter: none;
3103 +}
3104 +
3105 +html[data-estilo="saiyan"] .menu-toggle {
3106 + background: #ffffff;
3107 + border: 2px solid #facc15;
3108 + color: #1e3a8a;
3109 +}
3110 +
3111 +html[data-estilo="saiyan"] .menu-toggle:hover {
3112 + background: #facc15;
3113 + transform: scale(1.1);
3114 + box-shadow: 0 0 15px #facc15;
3115 +}
3116 +
3117 +html[data-estilo="saiyan"] .menu-backdrop { background: rgba(2, 6, 23, 0.6); }
3118 +
3119 +html[data-estilo="saiyan"] .global-search-input {
3120 + background: #ffffff;
3121 + border: 2px solid #facc15;
3122 + color: #1e3a8a;
3123 + font-weight: 600;
3124 +}
3125 +
3126 +html[data-estilo="saiyan"] .global-search-input::placeholder { color: #64748b; }
3127 +
3128 +html[data-estilo="saiyan"] .global-search-input:focus {
3129 + border-color: #facc15;
3130 + box-shadow: 0 0 15px #facc15;
3131 +}
3132 +
3133 +html[data-estilo="saiyan"] .global-search-dropdown {
3134 + background: #ffffff;
3135 + border: 2px solid #facc15;
3136 + box-shadow: 0 20px 50px rgba(2, 6, 23, 0.5);
3137 +}
3138 +
3139 +html[data-estilo="saiyan"] .global-search-item { border-bottom: 1px solid #e2e8f0; }
3140 +html[data-estilo="saiyan"] .global-search-item:hover { background: #fef3c7; }
3141 +html[data-estilo="saiyan"] .global-search-info strong { color: #1e3a8a; }
3142 +html[data-estilo="saiyan"] .global-search-info span { color: #64748b; }
3143 +
3144 +html[data-estilo="saiyan"] .global-search-chat {
3145 + border: 2px solid #facc15;
3146 + color: #1e3a8a;
3147 + background: #facc15;
3148 +}
3149 +
3150 +html[data-estilo="saiyan"] .global-search-chat:hover {
3151 + background: #1e3a8a;
3152 + color: #ffffff;
3153 + border-color: #1e3a8a;
3154 +}
3155 +
3156 +html[data-estilo="saiyan"] .global-search-empty { color: #64748b; }
3157 +
3158 +/* Notificaciones */
3159 +html[data-estilo="saiyan"] .notif-toggle {
3160 + background: #ffffff;
3161 + border: 2px solid #facc15;
3162 + color: #1e3a8a;
3163 +}
3164 +
3165 +html[data-estilo="saiyan"] .notif-toggle:hover {
3166 + background: #facc15;
3167 + transform: scale(1.1);
3168 + box-shadow: 0 0 15px #facc15;
3169 +}
3170 +
3171 +html[data-estilo="saiyan"] .notif-badge {
3172 + background: #f97316;
3173 + color: #ffffff;
3174 + box-shadow: 0 0 8px #f97316;
3175 +}
3176 +
3177 +html[data-estilo="saiyan"] .notif-panel {
3178 + background: #ffffff;
3179 + border: 2px solid #facc15;
3180 + box-shadow: 0 20px 50px rgba(2, 6, 23, 0.5);
3181 +}
3182 +
3183 +html[data-estilo="saiyan"] .notif-panel-head { border-bottom: 2px solid #facc15; }
3184 +html[data-estilo="saiyan"] .notif-panel-head h3 { color: #1e3a8a; }
3185 +html[data-estilo="saiyan"] .notif-marcar-todas { color: #f97316; }
3186 +html[data-estilo="saiyan"] .notif-marcar-todas:hover { color: #1e3a8a; }
3187 +html[data-estilo="saiyan"] .notif-item:hover { background: #fef3c7; }
3188 +html[data-estilo="saiyan"] .notif-item-info strong { color: #1e3a8a; }
3189 +html[data-estilo="saiyan"] .notif-item-info span { color: #64748b; }
3190 +html[data-estilo="saiyan"] .notif-item-badge {
3191 + background: rgba(249, 115, 22, 0.15);
3192 + color: #f97316;
3193 +}
3194 +html[data-estilo="saiyan"] .notif-vacio { color: #64748b; }
3195 +
3196 +/* ============================================================
3197 + CONTENIDO Y COMPONENTES SAIYAN
3198 + ============================================================ */
3199 +html[data-estilo="saiyan"] .main-content { background: transparent; }
3200 +
3201 +/* Entrada escalonada de las secciones (viewPopIn del mockup) */
3202 +html[data-estilo="saiyan"] .main-content > * {
3203 + opacity: 0;
3204 + transform: scale(1.03) translateY(8px);
3205 + animation: viewPopIn 0.45s cubic-bezier(0.17, 0.84, 0.44, 1) forwards;
3206 +}
3207 +
3208 +html[data-estilo="saiyan"] .main-content > *:nth-child(1) { animation-delay: 0.05s; }
3209 +html[data-estilo="saiyan"] .main-content > *:nth-child(2) { animation-delay: 0.10s; }
3210 +html[data-estilo="saiyan"] .main-content > *:nth-child(3) { animation-delay: 0.15s; }
3211 +html[data-estilo="saiyan"] .main-content > *:nth-child(4) { animation-delay: 0.20s; }
3212 +
3213 +@keyframes viewPopIn {
3214 + to { opacity: 1; transform: scale(1) translateY(0); }
3215 +}
3216 +
3217 +@keyframes capsuleExplode {
3218 + 0% { transform: scale(0) rotate(-360deg); opacity: 0; }
3219 + 80% { transform: scale(1.1) rotate(10deg); opacity: 1; }
3220 + 100% { transform: scale(1) rotate(0deg); opacity: 1; }
3221 +}
3222 +
3223 +/* Tarjetas */
3224 +html[data-estilo="saiyan"] .card {
3225 + background: #1e3a8a;
3226 + border: 3px solid #facc15;
3227 + border-left: 8px solid #ffffff;
3228 + box-shadow: 0 15px 40px rgba(0, 0, 0, 0.4);
3229 + position: relative;
3230 + overflow: hidden;
3231 + transition: transform 0.3s ease, opacity 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease, background-color 0.3s ease, color 0.3s ease;
3232 +}
3233 +
3234 +html[data-estilo="saiyan"] .card:hover {
3235 + box-shadow: 0 20px 50px rgba(250, 204, 21, 0.3);
3236 + transform: translateY(-3px);
3237 +}
3238 +
3239 +html[data-estilo="saiyan"] .card-title {
3240 + color: #ffffff;
3241 + font-weight: 900;
3242 +}
3243 +
3244 +/* Botones */
3245 +html[data-estilo="saiyan"] .btn {
3246 + border: 2px solid #facc15;
3247 + color: #ffffff;
3248 + background: #1e3a8a;
3249 + letter-spacing: 1px;
3250 + text-transform: uppercase;
3251 + font-weight: 800;
3252 + transition: transform 0.3s ease, opacity 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease, background-color 0.3s ease, color 0.3s ease;
3253 +}
3254 +
3255 +html[data-estilo="saiyan"] .btn:hover {
3256 + background: #f97316;
3257 + box-shadow: 0 0 15px rgba(250, 204, 21, 0.5);
3258 + color: #ffffff;
3259 +}
3260 +
3261 +html[data-estilo="saiyan"] .btn-primary,
3262 +html[data-estilo="saiyan"] .btn-solid {
3263 + background: #facc15;
3264 + color: #1e3a8a;
3265 + border: 2px solid #ffffff;
3266 + box-shadow: 0 0 15px rgba(250, 204, 21, 0.6);
3267 + font-weight: 900;
3268 +}
3269 +
3270 +html[data-estilo="saiyan"] .btn-primary:hover,
3271 +html[data-estilo="saiyan"] .btn-solid:hover {
3272 + transform: scale(1.04);
3273 + background: #ffffff;
3274 + color: #1e3a8a;
3275 + box-shadow: 0 0 25px #ffffff;
3276 +}
3277 +
3278 +html[data-estilo="saiyan"] .btn-secondary {
3279 + background: linear-gradient(135deg, #1e3a8a 0%, #172554 100%);
3280 + border: 2px solid #facc15;
3281 + color: #ffffff;
3282 +}
3283 +
3284 +html[data-estilo="saiyan"] .btn-outline {
3285 + background: transparent;
3286 + border: 2px solid #facc15;
3287 + color: #facc15;
3288 +}
3289 +
3290 +html[data-estilo="saiyan"] .btn-outline:hover {
3291 + background: #facc15;
3292 + color: #1e3a8a;
3293 + box-shadow: 0 0 15px #facc15;
3294 +}
3295 +
3296 +html[data-estilo="saiyan"] .btn-danger {
3297 + background: #dc2626;
3298 + border: 2px solid #fca5a5;
3299 + color: #ffffff;
3300 +}
3301 +
3302 +html[data-estilo="saiyan"] .btn-clean,
3303 +html[data-estilo="saiyan"] .side-link {
3304 + background: #f97316;
3305 + color: #ffffff;
3306 + border: 2px solid #facc15;
3307 + letter-spacing: 1px;
3308 + text-transform: uppercase;
3309 + font-weight: 900;
3310 + font-size: 0.78rem;
3311 +}
3312 +
3313 +html[data-estilo="saiyan"] .btn-clean:hover,
3314 +html[data-estilo="saiyan"] .side-link:hover {
3315 + background: #facc15;
3316 + color: #1e3a8a;
3317 + box-shadow: 0 0 15px #facc15;
3318 +}
3319 +
3320 +html[data-estilo="saiyan"] .btn-action {
3321 + border: 1px solid transparent;
3322 + color: #bfdbfe;
3323 + background: transparent;
3324 + transition: transform 0.25s ease, opacity 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease, background-color 0.25s ease, color 0.25s ease;
3325 + text-transform: uppercase;
3326 + font-size: 0.75rem;
3327 +}
3328 +
3329 +html[data-estilo="saiyan"] .btn-action:hover {
3330 + color: #facc15;
3331 + border-color: #facc15;
3332 + box-shadow: 0 0 12px rgba(250, 204, 21, 0.4);
3333 +}
3334 +
3335 +html[data-estilo="saiyan"] .btn-action.voted {
3336 + color: #f97316;
3337 + border-color: #f97316;
3338 + box-shadow: 0 0 12px rgba(249, 115, 22, 0.5);
3339 +}
3340 +
3341 +/* Formularios */
3342 +html[data-estilo="saiyan"] input,
3343 +html[data-estilo="saiyan"] select,
3344 +html[data-estilo="saiyan"] textarea,
3345 +html[data-estilo="saiyan"] .form-control,
3346 +html[data-estilo="saiyan"] .select-materia,
3347 +html[data-estilo="saiyan"] .input-search {
3348 + background: #0f172a;
3349 + border: 2px solid #facc15;
3350 + color: #ffffff;
3351 + outline: none;
3352 + transition: transform 0.3s, opacity 0.3s, box-shadow 0.3s, border-color 0.3s, background-color 0.3s, color 0.3s;
3353 +}
3354 +
3355 +html[data-estilo="saiyan"] input:focus,
3356 +html[data-estilo="saiyan"] select:focus,
3357 +html[data-estilo="saiyan"] textarea:focus,
3358 +html[data-estilo="saiyan"] .form-control:focus,
3359 +html[data-estilo="saiyan"] .select-materia:focus,
3360 +html[data-estilo="saiyan"] .input-search:focus {
3361 + border-color: #facc15;
3362 + box-shadow: 0 0 15px #facc15;
3363 +}
3364 +
3365 +html[data-estilo="saiyan"] select option { background: #0f172a; color: #ffffff; }
3366 +
3367 +html[data-estilo="saiyan"] input::placeholder,
3368 +html[data-estilo="saiyan"] textarea::placeholder { color: #64748b; }
3369 +
3370 +html[data-estilo="saiyan"] label,
3371 +html[data-estilo="saiyan"] legend,
3372 +html[data-estilo="saiyan"] th { color: #ffffff; }
3373 +
3374 +html[data-estilo="saiyan"] td { color: #f8fafc; }
3375 +html[data-estilo="saiyan"] hr { border-color: #facc15; }
3376 +
3377 +/* Alerts, badges, avatares */
3378 +html[data-estilo="saiyan"] .alert {
3379 + border: 2px solid #facc15;
3380 + background: #1e3a8a;
3381 + color: #ffffff;
3382 +}
3383 +
3384 +html[data-estilo="saiyan"] .alert-error {
3385 + background: #7f1d1d;
3386 + color: #fecaca;
3387 + border-color: #fca5a5;
3388 +}
3389 +
3390 +html[data-estilo="saiyan"] .alert-success {
3391 + background: #14532d;
3392 + color: #bbf7d0;
3393 + border-color: #86efac;
3394 +}
3395 +
3396 +html[data-estilo="saiyan"] .badge-primary,
3397 +html[data-estilo="saiyan"] .badge-materia,
3398 +html[data-estilo="saiyan"] .badge-verified,
3399 +html[data-estilo="saiyan"] .badge-solved,
3400 +html[data-estilo="saiyan"] .hub-badge.badge-solucion,
3401 +html[data-estilo="saiyan"] .hub-badge.badge-solved {
3402 + background: #f97316;
3403 + color: #ffffff;
3404 + border: 1px solid #facc15;
3405 + font-weight: 700;
3406 + letter-spacing: 1px;
3407 + text-transform: uppercase;
3408 +}
3409 +
3410 +html[data-estilo="saiyan"] .badge-success {
3411 + background: rgba(74, 222, 128, 0.15);
3412 + color: #bbf7d0;
3413 + border: 1px solid #4ade80;
3414 +}
3415 +
3416 +html[data-estilo="saiyan"] .badge-interacted {
3417 + background: rgba(250, 204, 21, 0.2);
3418 + color: #facc15;
3419 + border: 1px solid #facc15;
3420 +}
3421 +
3422 +html[data-estilo="saiyan"] .hub-badge {
3423 + background: #f97316;
3424 + color: #ffffff;
3425 + border: 1px solid #facc15;
3426 + letter-spacing: 0.5px;
3427 + text-transform: uppercase;
3428 +}
3429 +
3430 +html[data-estilo="saiyan"] .avatar,
3431 +html[data-estilo="saiyan"] .hub-avatar {
3432 + border: 2px solid #facc15;
3433 +}
3434 +
3435 +html[data-estilo="saiyan"] .follow-btn {
3436 + background: #f97316;
3437 + border: 2px solid #facc15;
3438 + color: #ffffff;
3439 + font-weight: 900;
3440 + text-transform: uppercase;
3441 +}
3442 +
3443 +html[data-estilo="saiyan"] .follow-btn:hover {
3444 + background: #facc15;
3445 + color: #1e3a8a;
3446 + box-shadow: 0 0 15px #facc15;
3447 +}
3448 +
3449 +html[data-estilo="saiyan"] .follow-btn.siguiendo {
3450 + background: #ffffff;
3451 + color: #1e3a8a;
3452 + border-color: #ffffff;
3453 +}
3454 +
3455 +/* Toast */
3456 +html[data-estilo="saiyan"] .toast {
3457 + background: #1e3a8a;
3458 + color: #ffffff;
3459 + border: 2px solid #facc15;
3460 + box-shadow: 0 15px 40px rgba(2, 6, 23, 0.5);
3461 +}
3462 +
3463 +html[data-estilo="saiyan"] .toast-info { border-left-width: 4px; }
3464 +html[data-estilo="saiyan"] .toast-success { border-color: #4ade80; }
3465 +html[data-estilo="saiyan"] .toast-error { border-color: #f87171; }
3466 +
3467 +/* ============================================================
3468 + POP-UP DE BIENVENIDA: CÁPSULA HOI-POI
3469 + ============================================================ */
3470 +html[data-estilo="saiyan"] .welcome-overlay {
3471 + background-color: rgba(1, 5, 10, 0.85);
3472 + backdrop-filter: none;
3473 +}
3474 +
3475 +html[data-estilo="saiyan"] .welcome-popup {
3476 + background: #ffffff;
3477 + border: 10px solid #1e3a8a;
3478 + box-shadow: 0 0 100px rgba(250, 204, 21, 0.8), 0 0 30px rgba(249, 115, 22, 0.5);
3479 + padding: 40px 34px;
3480 + max-width: 460px;
3481 + animation: capsuleExplode 0.6s ease-out forwards;
3482 +}
3483 +
3484 +html[data-estilo="saiyan"] .welcome-popup::before,
3485 +html[data-estilo="saiyan"] .welcome-popup::after {
3486 + content: '';
3487 + position: absolute;
3488 + left: 0;
3489 + width: 100%;
3490 + height: 10px;
3491 + background: none;
3492 + box-shadow: none;
3493 + animation: none;
3494 +}
3495 +
3496 +html[data-estilo="saiyan"] .welcome-popup::after {
3497 + background-image: repeating-linear-gradient(45deg, #facc15 0, #facc15 12px, transparent 12px, transparent 24px);
3498 +}
3499 +
3500 +html[data-estilo="saiyan"] .welcome-popup::before {
3501 + background: #f97316;
3502 +}
3503 +
3504 +html[data-estilo="saiyan"] .popup-tag {
3505 + color: #f97316;
3506 + font-size: 0;
3507 + letter-spacing: 3px;
3508 + text-transform: uppercase;
3509 +}
3510 +
3511 +html[data-estilo="saiyan"] .popup-tag::after {
3512 + content: '// Z-FIGHTERS DOJO // NIVEL SUPERIOR //';
3513 + font-size: 0.8rem;
3514 + letter-spacing: 3px;
3515 + color: #f97316;
3516 +}
3517 +
3518 +html[data-estilo="saiyan"] .pulse-mini {
3519 + background-color: #facc15;
3520 + box-shadow: 0 0 6px #facc15;
3521 +}
3522 +
3523 +html[data-estilo="saiyan"] .popup-title {
3524 + color: #1e3a8a;
3525 + font-size: 3.2rem;
3526 + letter-spacing: 2px;
3527 + line-height: 0.9;
3528 + text-transform: uppercase;
3529 + text-shadow: 3px 3px 0px #facc15;
3530 +}
3531 +
3532 +html[data-estilo="saiyan"] .popup-username {
3533 + background: transparent;
3534 + border: none;
3535 + box-shadow: none;
3536 + padding: 0;
3537 + color: #1e3a8a;
3538 + font-weight: 900;
3539 + letter-spacing: 1px;
3540 + text-transform: uppercase;
3541 +}
3542 +
3543 +html[data-estilo="saiyan"] .popup-status {
3544 + font-size: 0;
3545 + font-style: normal;
3546 +}
3547 +
3548 +html[data-estilo="saiyan"] .popup-status::after {
3549 + content: 'NIVEL DE PODER: MÁXIMO • SESIÓN INICIADA';
3550 + font-size: 0.85rem;
3551 + font-style: normal;
3552 + font-weight: bold;
3553 + font-family: Consolas, monospace;
3554 + color: #f97316;
3555 + letter-spacing: 1px;
3556 +}
3557 +
3558 +html[data-estilo="saiyan"] .welcome-popup,
3559 +html[data-estilo="saiyan"] .popup-tag,
3560 +html[data-estilo="saiyan"] .popup-title,
3561 +html[data-estilo="saiyan"] .popup-username,
3562 +html[data-estilo="saiyan"] .popup-status { text-align: center; }
3563 +
3564 +/* ============================================================
3565 + COMPONENTES DEL PROTOTIPO ADAPTADOS (inicio real)
3566 + ============================================================ */
3567 +
3568 +/* Botón de lanzar duda (ataque del dojo) */
3569 +html[data-estilo="saiyan"] .btn-publicar {
3570 + background: #facc15;
3571 + color: #1e3a8a;
3572 + border: 2px solid #ffffff;
3573 + box-shadow: 0 0 15px rgba(250, 204, 21, 0.6);
3574 + font-weight: 900;
3575 + letter-spacing: 1px;
3576 + text-transform: uppercase;
3577 + transition: transform 0.3s ease, opacity 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease, background-color 0.3s ease, color 0.3s ease;
3578 +}
3579 +
3580 +html[data-estilo="saiyan"] .btn-publicar:hover {
3581 + transform: scale(1.04);
3582 + background: #ffffff;
3583 + box-shadow: 0 0 25px #ffffff;
3584 +}
3585 +
3586 +html[data-estilo="saiyan"] .banner-title {
3587 + color: #ffffff;
3588 + letter-spacing: 1.5px;
3589 + text-shadow: 2px 2px 0px #f97316;
3590 +}
3591 +
3592 +html[data-estilo="saiyan"] .banner-sub { color: #bfdbfe; }
3593 +
3594 +/* Progreso de carrera dorado */
3595 +html[data-estilo="saiyan"] .progress-bar {
3596 + height: 10px;
3597 + background: #0f172a;
3598 + border: 2px solid #facc15;
3599 + overflow: hidden;
3600 + box-shadow: 0 0 10px rgba(250, 204, 21, 0.2);
3601 +}
3602 +
3603 +html[data-estilo="saiyan"] .progress-fill {
3604 + background: #facc15;
3605 + box-shadow: 0 0 10px #facc15;
3606 + transition: width 1.6s cubic-bezier(0.17, 0.84, 0.44, 1) 0.4s;
3607 +}
3608 +
3609 +html[data-estilo="saiyan"] .progress-labels {
3610 + color: #bfdbfe;
3611 + font-size: 0.78rem;
3612 + font-weight: 600;
3613 +}
3614 +
3615 +/* Publicaciones y widgets */
3616 +html[data-estilo="saiyan"] .post-card,
3617 +html[data-estilo="saiyan"] .side-card,
3618 +html[data-estilo="saiyan"] .widget-card {
3619 + background: #1e3a8a;
3620 + border: 3px solid #facc15;
3621 + box-shadow: 0 15px 40px rgba(0, 0, 0, 0.4);
3622 + transition: transform 0.3s ease, opacity 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease, background-color 0.3s ease, color 0.3s ease;
3623 +}
3624 +
3625 +html[data-estilo="saiyan"] .post-card:hover,
3626 +html[data-estilo="saiyan"] .side-card:hover,
3627 +html[data-estilo="saiyan"] .widget-card:hover {
3628 + transform: translateY(-3px);
3629 + box-shadow: 0 20px 50px rgba(250, 204, 21, 0.25);
3630 +}
3631 +
3632 +html[data-estilo="saiyan"] .widget-time {
3633 + color: #facc15;
3634 + font-weight: 900;
3635 + text-transform: uppercase;
3636 + letter-spacing: 1px;
3637 +}
3638 +
3639 +html[data-estilo="saiyan"] .widget-title {
3640 + color: #ffffff;
3641 + letter-spacing: 1px;
3642 +}
3643 +
3644 +html[data-estilo="saiyan"] .widget-desc { color: #bfdbfe; }
3645 +
3646 +html[data-estilo="saiyan"] .btn-widget {
3647 + display: block;
3648 + width: 100%;
3649 + text-align: center;
3650 + background: #f97316;
3651 + color: #ffffff;
3652 + border: 2px solid #facc15;
3653 + font-weight: 900;
3654 + letter-spacing: 1px;
3655 + text-transform: uppercase;
3656 + transition: transform 0.3s, opacity 0.3s, box-shadow 0.3s, border-color 0.3s, background-color 0.3s, color 0.3s;
3657 +}
3658 +
3659 +html[data-estilo="saiyan"] .btn-widget:hover {
3660 + background: #facc15;
3661 + color: #1e3a8a;
3662 + box-shadow: 0 0 15px #facc15;
3663 +}
3664 +
3665 +/* Zona de combate / caja de dudas */
3666 +html[data-estilo="saiyan"] .maps-banner .banner-content { color: #ffffff; }
3667 +
3668 +html[data-estilo="saiyan"] .empty-state h2 { color: #ffffff; }
3669 +html[data-estilo="saiyan"] .empty-state p,
3670 +html[data-estilo="saiyan"] .filter-note { color: #bfdbfe; }
3671 +
3672 +/* ============================================================
3673 + COMUNIDAD: FORO, RESPUESTAS, APUNTES, TIPS, MODALES
3674 + ============================================================ */
3675 +html[data-estilo="saiyan"] .hub-filter-bar {
3676 + background: #1e3a8a;
3677 + border: 2px solid #facc15;
3678 +}
3679 +
3680 +html[data-estilo="saiyan"] .hub-tabs button {
3681 + background: transparent;
3682 + color: #bfdbfe;
3683 + border: 2px solid transparent;
3684 + text-transform: uppercase;
3685 + letter-spacing: 0.5px;
3686 + transition: transform 0.25s ease, opacity 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease, background-color 0.25s ease, color 0.25s ease;
3687 +}
3688 +
3689 +html[data-estilo="saiyan"] .hub-tabs button:hover {
3690 + color: #ffffff;
3691 + border-color: #facc15;
3692 + box-shadow: 0 0 12px rgba(250, 204, 21, 0.4);
3693 +}
3694 +
3695 +html[data-estilo="saiyan"] .hub-tabs button.active {
3696 + background: #facc15;
3697 + color: #1e3a8a;
3698 + border: 2px solid #facc15;
3699 + font-weight: 900;
3700 +}
3701 +
3702 +html[data-estilo="saiyan"] .foro-autor-line strong,
3703 +html[data-estilo="saiyan"] .user-data-line strong { color: #ffffff; }
3704 +html[data-estilo="saiyan"] .foro-autor-line span,
3705 +html[data-estilo="saiyan"] .user-data-line span { color: #bfdbfe; }
3706 +
3707 +html[data-estilo="saiyan"] .post-title {
3708 + color: #ffffff;
3709 + letter-spacing: 1px;
3710 +}
3711 +
3712 +html[data-estilo="saiyan"] .post-desc {
3713 + color: #f8fafc;
3714 + line-height: 1.55;
3715 +}
3716 +
3717 +html[data-estilo="saiyan"] .post-desc code {
3718 + background: #0f172a;
3719 + border: 1px solid #facc15;
3720 + color: #facc15;
3721 +}
3722 +
3723 +html[data-estilo="saiyan"] .solved-box {
3724 + background: rgba(250, 204, 21, 0.12);
3725 + border: 1px solid #facc15;
3726 + color: #ffffff;
3727 + box-shadow: 0 0 20px rgba(250, 204, 21, 0.3);
3728 +}
3729 +
3730 +html[data-estilo="saiyan"] .btn-link,
3731 +html[data-estilo="saiyan"] .foro-action {
3732 + color: #facc15;
3733 + background: transparent;
3734 + transition: transform 0.25s ease, opacity 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease, background-color 0.25s ease, color 0.25s ease;
3735 + text-transform: uppercase;
3736 + letter-spacing: 0.5px;
3737 +}
3738 +
3739 +html[data-estilo="saiyan"] .btn-link:hover,
3740 +html[data-estilo="saiyan"] .foro-action:hover {
3741 + color: #ffffff;
3742 + background: #f97316;
3743 +}
3744 +
3745 +html[data-estilo="saiyan"] .apunte-card,
3746 +html[data-estilo="saiyan"] .tip-card {
3747 + background: #1e3a8a;
3748 + border: 3px solid #facc15;
3749 + transition: transform 0.3s ease, opacity 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease, background-color 0.3s ease, color 0.3s ease;
3750 +}
3751 +
3752 +html[data-estilo="saiyan"] .apunte-card:hover,
3753 +html[data-estilo="saiyan"] .tip-card:hover {
3754 + transform: translateY(-3px);
3755 + box-shadow: 0 20px 50px rgba(250, 204, 21, 0.25);
3756 +}
3757 +
3758 +html[data-estilo="saiyan"] .file-icon {
3759 + background: #f97316;
3760 + border: 1px solid #facc15;
3761 + color: #ffffff;
3762 +}
3763 +
3764 +html[data-estilo="saiyan"] .apunte-details h4 { color: #ffffff; }
3765 +html[data-estilo="saiyan"] .apunte-details p { color: #bfdbfe; }
3766 +
3767 +html[data-estilo="saiyan"] .tip-title,
3768 +html[data-estilo="saiyan"] .tip-card h4 { color: #ffffff; }
3769 +html[data-estilo="saiyan"] .tip-text { color: #f8fafc; }
3770 +html[data-estilo="saiyan"] .tip-text code {
3771 + background: #0f172a;
3772 + border: 1px solid #facc15;
3773 + color: #facc15;
3774 +}
3775 +
3776 +html[data-estilo="saiyan"] .vote-btn {
3777 + background: #0f172a;
3778 + border: 2px solid #facc15;
3779 + color: #bfdbfe;
3780 + transition: transform 0.25s ease, opacity 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease, background-color 0.25s ease, color 0.25s ease;
3781 +}
3782 +
3783 +html[data-estilo="saiyan"] .vote-btn:hover {
3784 + border-color: #facc15;
3785 + color: #facc15;
3786 + box-shadow: 0 0 12px rgba(250, 204, 21, 0.4);
3787 +}
3788 +
3789 +html[data-estilo="saiyan"] .vote-btn.votado {
3790 + border-color: #f97316;
3791 + color: #f97316;
3792 + box-shadow: 0 0 12px rgba(249, 115, 22, 0.5);
3793 +}
3794 +
3795 +/* Modales del hub */
3796 +html[data-estilo="saiyan"] .hub-modal-overlay {
3797 + background-color: rgba(1, 5, 10, 0.7);
3798 + backdrop-filter: none;
3799 +}
3800 +
3801 +html[data-estilo="saiyan"] .hub-modal {
3802 + background: #1e3a8a;
3803 + border: 3px solid #facc15;
3804 + box-shadow: 0 0 80px rgba(250, 204, 21, 0.5);
3805 +}
3806 +
3807 +html[data-estilo="saiyan"] .hub-modal h3,
3808 +html[data-estilo="saiyan"] .hub-modal h4 { color: #ffffff; }
3809 +html[data-estilo="saiyan"] .hub-modal .modal-sub { color: #bfdbfe; }
3810 +html[data-estilo="saiyan"] .hub-modal label { color: #ffffff; }
3811 +html[data-estilo="saiyan"] .modal-msg { color: #f8fafc; }
3812 +
3813 +/* Modal de duplicado / respuestas */
3814 +html[data-estilo="saiyan"] .dup-modal { border: 3px solid #facc15; }
3815 +
3816 +html[data-estilo="saiyan"] .dup-notice {
3817 + background: rgba(250, 204, 21, 0.12);
3818 + border: 1px solid #facc15;
3819 + box-shadow: 0 0 20px rgba(250, 204, 21, 0.3);
3820 +}
3821 +
3822 +html[data-estilo="saiyan"] .dup-titulo { color: #ffffff; }
3823 +html[data-estilo="saiyan"] .dup-notice p,
3824 +html[data-estilo="saiyan"] .dup-por { color: #bfdbfe; }
3825 +
3826 +html[data-estilo="saiyan"] .dup-question {
3827 + background: #1e3a8a;
3828 + border-left: 8px solid #facc15;
3829 +}
3830 +
3831 +html[data-estilo="saiyan"] .dup-section-title {
3832 + color: #ffffff;
3833 + text-transform: uppercase;
3834 + letter-spacing: 0.5px;
3835 + border-bottom-color: #facc15;
3836 +}
3837 +
3838 +html[data-estilo="saiyan"] .dup-respuestas,
3839 +html[data-estilo="saiyan"] .dup-tips,
3840 +html[data-estilo="saiyan"] .dup-recursos { border-color: #facc15; }
3841 +
3842 +html[data-estilo="saiyan"] .dup-vacio { color: #bfdbfe; }
3843 +
3844 +/* Respuestas a un post */
3845 +html[data-estilo="saiyan"] .hub-respuesta {
3846 + background: #1e3a8a;
3847 + border: 2px solid #facc15;
3848 + border-left: 8px solid #ffffff;
3849 + box-shadow: 0 15px 40px rgba(0, 0, 0, 0.4);
3850 + transition: transform 0.3s ease, opacity 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease, background-color 0.3s ease, color 0.3s ease;
3851 +}
3852 +
3853 +html[data-estilo="saiyan"] .hub-respuesta:hover {
3854 + transform: translateY(-3px);
3855 + box-shadow: 0 20px 50px rgba(250, 204, 21, 0.25);
3856 +}
3857 +
3858 +html[data-estilo="saiyan"] .respuesta-autor {
3859 + color: #facc15;
3860 + font-weight: 900;
3861 + text-transform: uppercase;
3862 + letter-spacing: 0.5px;
3863 + text-shadow: 1px 1px 0px #f97316;
3864 +}
3865 +
3866 +html[data-estilo="saiyan"] .respuesta-texto {
3867 + color: #f8fafc;
3868 + line-height: 1.55;
3869 +}
3870 +
3871 +/* ============================================================
3872 + MENSAJES: CHAT SAIYAN
3873 + ============================================================ */
3874 +html[data-estilo="saiyan"] .messages-container {
3875 + --hub-border: #facc15;
3876 + --hub-accent: #f97316;
3877 + --hub-accent-dark: #c2410c;
3878 + --hub-accent-soft: rgba(250, 204, 21, 0.12);
3879 + --hub-muted: #bfdbfe;
3880 + background: rgba(30, 58, 138, 0.92);
3881 + border: 2px solid #facc15;
3882 + box-shadow: 0 0 40px rgba(250, 204, 21, 0.2);
3883 +}
3884 +
3885 +html[data-estilo="saiyan"] .chat-sidebar {
3886 + background: linear-gradient(180deg, rgba(23, 37, 84, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%);
3887 + border-right: 2px solid #facc15;
3888 +}
3889 +
3890 +html[data-estilo="saiyan"] .chat-search-box { border-bottom-color: #facc15; }
3891 +html[data-estilo="saiyan"] .chat-search-box input {
3892 + background: #0f172a;
3893 + border: 2px solid #facc15;
3894 + color: #ffffff;
3895 +}
3896 +
3897 +html[data-estilo="saiyan"] .chat-search-box input:focus {
3898 + border-color: #facc15;
3899 + box-shadow: 0 0 15px #facc15;
3900 +}
3901 +
3902 +html[data-estilo="saiyan"] .chat-item {
3903 + background: rgba(15, 23, 42, 0.6);
3904 + border-bottom: 1px solid #facc15;
3905 + transition: transform 0.25s ease, opacity 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease, background-color 0.25s ease, color 0.25s ease;
3906 +}
3907 +
3908 +html[data-estilo="saiyan"] .chat-item:hover {
3909 + background: rgba(249, 115, 22, 0.15);
3910 +}
3911 +
3912 +html[data-estilo="saiyan"] .chat-item.active {
3913 + background: rgba(250, 204, 21, 0.2);
3914 + border-left: 4px solid #facc15;
3915 + box-shadow: inset 0 0 12px rgba(250, 204, 21, 0.2);
3916 +}
3917 +
3918 +html[data-estilo="saiyan"] .chat-item-header strong { color: #ffffff; }
3919 +html[data-estilo="saiyan"] .chat-item-header span,
3920 +html[data-estilo="saiyan"] .chat-item-preview { color: #bfdbfe; }
3921 +
3922 +html[data-estilo="saiyan"] .chat-window { background: rgba(15, 23, 42, 0.55); }
3923 +
3924 +html[data-estilo="saiyan"] .chat-header {
3925 + background: #1e3a8a;
3926 + border-bottom: 2px solid #facc15;
3927 +}
3928 +
3929 +html[data-estilo="saiyan"] .chat-header-user h3 { color: #ffffff; }
3930 +html[data-estilo="saiyan"] .chat-header-user span { color: #bfdbfe; }
3931 +
3932 +html[data-estilo="saiyan"] .chat-body {
3933 + background-color: rgba(15, 23, 42, 0.55);
3934 + background-image: radial-gradient(rgba(250, 204, 21, 0.07) 1px, transparent 1px);
3935 + background-size: 26px 26px;
3936 +}
3937 +
3938 +html[data-estilo="saiyan"] .message {
3939 + background: #1e3a8a;
3940 + border: 2px solid #facc15;
3941 + border-left: 8px solid #ffffff;
3942 + box-shadow: 0 15px 40px rgba(0, 0, 0, 0.4);
3943 + transition: transform 0.3s ease, opacity 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease, background-color 0.3s ease, color 0.3s ease;
3944 +}
3945 +
3946 +html[data-estilo="saiyan"] .message.received:hover,
3947 +html[data-estilo="saiyan"] .message.sent:hover {
3948 + transform: translateY(-2px);
3949 + box-shadow: 0 20px 50px rgba(250, 204, 21, 0.25);
3950 +}
3951 +
3952 +html[data-estilo="saiyan"] .message.sent {
3953 + background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
3954 + border: 2px solid #facc15;
3955 + border-left: 8px solid #facc15;
3956 + box-shadow: 0 15px 40px rgba(0, 0, 0, 0.4);
3957 +}
3958 +
3959 +html[data-estilo="saiyan"] .message-time { color: #bfdbfe; }
3960 +
3961 +html[data-estilo="saiyan"] .message-texto {
3962 + color: #ffffff;
3963 + line-height: 1.5;
3964 +}
3965 +
3966 +html[data-estilo="saiyan"] .message-attachment {
3967 + background: #0f172a;
3968 + border: 2px solid #facc15;
3969 +}
3970 +
3971 +html[data-estilo="saiyan"] .message.sent .message-attachment {
3972 + background: rgba(15, 23, 42, 0.25);
3973 + border-color: #ffffff;
3974 +}
3975 +
3976 +html[data-estilo="saiyan"] .attachment-icon {
3977 + background: #f97316;
3978 + border: 1px solid #facc15;
3979 + color: #ffffff;
3980 +}
3981 +
3982 +html[data-estilo="saiyan"] .message.sent .attachment-icon {
3983 + background: rgba(255, 255, 255, 0.2);
3984 + border-color: #ffffff;
3985 + color: #ffffff;
3986 +}
3987 +
3988 +html[data-estilo="saiyan"] .attachment-nombre { color: #ffffff; }
3989 +html[data-estilo="saiyan"] .attachment-peso { color: #bfdbfe; }
3990 +
3991 +html[data-estilo="saiyan"] .adjunto-descargar {
3992 + background: transparent;
3993 + border: 2px solid #facc15;
3994 + color: #facc15;
3995 + transition: transform 0.25s ease, opacity 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease, background-color 0.25s ease, color 0.25s ease;
3996 + text-transform: uppercase;
3997 + font-weight: 900;
3998 +}
3999 +
4000 +html[data-estilo="saiyan"] .adjunto-descargar:hover {
4001 + background: #facc15;
4002 + color: #1e3a8a;
4003 + box-shadow: 0 0 15px #facc15;
4004 +}
4005 +
4006 +html[data-estilo="saiyan"] .message.sent .adjunto-descargar {
4007 + border-color: #ffffff;
4008 + color: #ffffff;
4009 +}
4010 +
4011 +html[data-estilo="saiyan"] .message.sent .adjunto-descargar:hover {
4012 + background: #ffffff;
4013 + color: #1e3a8a;
4014 + box-shadow: 0 0 15px #ffffff;
4015 +}
4016 +
4017 +html[data-estilo="saiyan"] .chat-input-bar {
4018 + background: #1e3a8a;
4019 + border-top: 2px solid #facc15;
4020 +}
4021 +
4022 +html[data-estilo="saiyan"] .chat-input-bar input {
4023 + background: #0f172a;
4024 + border: 2px solid #facc15;
4025 + color: #ffffff;
4026 + outline: none;
4027 +}
4028 +
4029 +html[data-estilo="saiyan"] .chat-input-bar input:focus {
4030 + border-color: #facc15;
4031 + box-shadow: 0 0 15px #facc15;
4032 +}
4033 +
4034 +html[data-estilo="saiyan"] .chat-empty { background-color: rgba(15, 23, 42, 0.55); }
4035 +html[data-estilo="saiyan"] .chat-empty p,
4036 +html[data-estilo="saiyan"] .chat-empty span { color: #bfdbfe; }
4037 +
4038 +html[data-estilo="saiyan"] .adjunto-pendiente {
4039 + background: rgba(250, 204, 21, 0.12);
4040 + border: 1px solid #facc15;
4041 + color: #ffffff;
4042 +}
4043 +
4044 +html[data-estilo="saiyan"] .adjunto-quitar { color: #f97316; }
4045 +
4046 +/* ============================================================
4047 + PERFIL: STATS, CERTIFICADOS, ACTIVIDAD, DISCOVERY
4048 + ============================================================ */
4049 +html[data-estilo="saiyan"] .profile-name {
4050 + color: #ffffff;
4051 + letter-spacing: 1px;
4052 + text-shadow: 2px 2px 0px #f97316;
4053 +}
4054 +
4055 +html[data-estilo="saiyan"] .profile-sub,
4056 +html[data-estilo="saiyan"] .discover-empty { color: #bfdbfe; }
4057 +
4058 +html[data-estilo="saiyan"] .red-stat .number,
4059 +html[data-estilo="saiyan"] .stat-box .number {
4060 + color: #facc15;
4061 + font-weight: 900;
4062 + text-shadow: 2px 2px 0px #f97316;
4063 +}
4064 +
4065 +html[data-estilo="saiyan"] .red-stat .label,
4066 +html[data-estilo="saiyan"] .stat-box .label { color: #bfdbfe; }
4067 +
4068 +html[data-estilo="saiyan"] .discover-person,
4069 +html[data-estilo="saiyan"] .certificate-item {
4070 + background: #1e3a8a;
4071 + border: 2px solid #facc15;
4072 + border-left: 8px solid #ffffff;
4073 + transition: transform 0.3s ease, opacity 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease, background-color 0.3s ease, color 0.3s ease;
4074 +}
4075 +
4076 +html[data-estilo="saiyan"] .discover-person:hover,
4077 +html[data-estilo="saiyan"] .certificate-item:hover {
4078 + transform: translateY(-3px);
4079 + box-shadow: 0 20px 50px rgba(250, 204, 21, 0.25);
4080 +}
4081 +
4082 +html[data-estilo="saiyan"] .certificate-item.active {
4083 + border-left-color: #f97316;
4084 + box-shadow: inset 0 0 15px rgba(249, 115, 22, 0.2);
4085 +}
4086 +
4087 +html[data-estilo="saiyan"] .discover-info strong,
4088 +html[data-estilo="saiyan"] .cert-title,
4089 +html[data-estilo="saiyan"] .activity-item strong { color: #ffffff; }
4090 +
4091 +html[data-estilo="saiyan"] .cert-desc,
4092 +html[data-estilo="saiyan"] .activity-item { color: #bfdbfe; }
4093 +
4094 +html[data-estilo="saiyan"] .badge-green {
4095 + background: rgba(74, 222, 128, 0.15);
4096 + color: #bbf7d0;
4097 + border: 1px solid #4ade80;
4098 +}
4099 +
4100 +html[data-estilo="saiyan"] .badge-gray {
4101 + background: rgba(15, 23, 42, 0.6);
4102 + color: #94a3b8;
4103 + border: 1px solid #334155;
4104 +}
4105 +
4106 +html[data-estilo="saiyan"] .proposito-modal {
4107 + background: #1e3a8a;
4108 + border: 3px solid #facc15;
4109 + box-shadow: 0 0 80px rgba(250, 204, 21, 0.5);
4110 +}
4111 +
4112 +html[data-estilo="saiyan"] .proposito-modal-sub { color: #bfdbfe; }
4113 +
4114 +/* ============================================================================
4115 + CORRECCIÓN DE FONDOS BLANCOS RESIDUALES
4116 + Contenedores de perfil.css / usuario.css que aún usan #fff o #f9fafb y que
4117 + los skins deben re-tematizar (propósito de vida, aportes, respuestas, etc.)
4118 + ============================================================================ */
4119 +
4120 +/* --- Cyber --- */
4121 +html[data-estilo="cyber"] .perfil-container .card {
4122 + background: #161b22;
4123 + border: 1px solid #30363d;
4124 +}
4125 +html[data-estilo="cyber"] .purpose-box {
4126 + background: #0d1117;
4127 + border: 1px solid #21262d;
4128 + border-left: 3px solid #3fb950;
4129 +}
4130 +html[data-estilo="cyber"] .purpose-box strong { color: #3fb950; }
4131 +html[data-estilo="cyber"] .purpose-box p { color: #c9d1d9; }
4132 +html[data-estilo="cyber"] .section-heading,
4133 +html[data-estilo="cyber"] .subsection-heading,
4134 +html[data-estilo="cyber"] .sub-heading {
4135 + color: #e6edf3;
4136 + border-bottom-color: #21262d;
4137 +}
4138 +html[data-estilo="cyber"] .public-item {
4139 + background: #161b22;
4140 + border-color: #30363d;
4141 +}
4142 +html[data-estilo="cyber"] .public-item strong { color: #e6edf3; }
4143 +html[data-estilo="cyber"] .public-item p,
4144 +html[data-estilo="cyber"] .public-item .item-meta { color: #8b949e; }
4145 +html[data-estilo="cyber"] .avatar-modal-content {
4146 + background: #161b22;
4147 + border: 1px solid #30363d;
4148 +}
4149 +html[data-estilo="cyber"] .materia-tag {
4150 + background: rgba(46, 160, 67, 0.12);
4151 + color: #3fb950;
4152 + border-color: rgba(46, 160, 67, 0.35);
4153 +}
4154 +html[data-estilo="cyber"] .prof-detail-list { color: #8b949e; }
4155 +html[data-estilo="cyber"] .prof-detail-list strong { color: #e6edf3; }
4156 +
4157 +/* --- Medieval --- */
4158 +html[data-estilo="medieval"] .perfil-container .card {
4159 + background: linear-gradient(135deg, rgba(21, 15, 10, 0.92) 0%, rgba(8, 6, 4, 0.96) 100%);
4160 + border: 2px solid #3a2e1d;
4161 + border-left: 6px solid #d4af37;
4162 +}
4163 +html[data-estilo="medieval"] .purpose-box {
4164 + background: rgba(21, 15, 10, 0.6);
4165 + border: 1px solid #3a2e1d;
4166 + border-left: 3px solid #d4af37;
4167 +}
4168 +html[data-estilo="medieval"] .purpose-box strong { color: #d4af37; }
4169 +html[data-estilo="medieval"] .purpose-box p { color: #cbb89b; }
4170 +html[data-estilo="medieval"] .section-heading,
4171 +html[data-estilo="medieval"] .subsection-heading,
4172 +html[data-estilo="medieval"] .sub-heading {
4173 + color: #f3e5ab;
4174 + border-bottom-color: #3a2e1d;
4175 +}
4176 +html[data-estilo="medieval"] .public-item {
4177 + background: rgba(21, 15, 10, 0.6);
4178 + border-color: #3a2e1d;
4179 +}
4180 +html[data-estilo="medieval"] .public-item strong { color: #f3e5ab; }
4181 +html[data-estilo="medieval"] .public-item p,
4182 +html[data-estilo="medieval"] .public-item .item-meta { color: #a39e93; }
4183 +html[data-estilo="medieval"] .avatar-modal-content {
4184 + background: #1a130c;
4185 + border: 1px solid #3a2e1d;
4186 +}
4187 +html[data-estilo="medieval"] .materia-tag {
4188 + background: rgba(212, 175, 55, 0.12);
4189 + color: #f3e5ab;
4190 + border-color: #8a6d1f;
4191 +}
4192 +html[data-estilo="medieval"] .prof-detail-list { color: #a39e93; }
4193 +html[data-estilo="medieval"] .prof-detail-list strong { color: #f3e5ab; }
4194 +
4195 +/* --- Saiyan --- */
4196 +html[data-estilo="saiyan"] .perfil-container .card {
4197 + background: #1e3a8a;
4198 + border: 3px solid #facc15;
4199 + border-left: 8px solid #ffffff;
4200 +}
4201 +html[data-estilo="saiyan"] .purpose-box {
4202 + background: #0f172a;
4203 + border: 1px solid #facc15;
4204 + border-left: 3px solid #f97316;
4205 +}
4206 +html[data-estilo="saiyan"] .purpose-box strong { color: #facc15; }
4207 +html[data-estilo="saiyan"] .purpose-box p { color: #ffffff; }
4208 +html[data-estilo="saiyan"] .section-heading,
4209 +html[data-estilo="saiyan"] .subsection-heading,
4210 +html[data-estilo="saiyan"] .sub-heading {
4211 + color: #ffffff;
4212 + border-bottom-color: #facc15;
4213 +}
4214 +html[data-estilo="saiyan"] .public-item {
4215 + background: #1e3a8a;
4216 + border-color: #facc15;
4217 +}
4218 +html[data-estilo="saiyan"] .public-item strong { color: #facc15; }
4219 +html[data-estilo="saiyan"] .public-item p,
4220 +html[data-estilo="saiyan"] .public-item .item-meta { color: #ffffff; }
4221 +html[data-estilo="saiyan"] .avatar-modal-content {
4222 + background: #1e3a8a;
4223 + border: 1px solid #facc15;
4224 +}
4225 +html[data-estilo="saiyan"] .materia-tag {
4226 + background: rgba(249, 115, 22, 0.18);
4227 + color: #facc15;
4228 + border-color: #f97316;
4229 +}
4230 +html[data-estilo="saiyan"] .prof-detail-list { color: #bfdbfe; }
4231 +html[data-estilo="saiyan"] .prof-detail-list strong { color: #facc15; }
4232 +
4233 +/* --- Recuadros de respuestas/archivos incrustados de Inicio (.embed-box) --- */
4234 +html[data-estilo="cyber"] .inicio-container .embed-box {
4235 + background-color: #0d1117;
4236 + border-left-color: #3fb950;
4237 +}
4238 +html[data-estilo="cyber"] .inicio-container .embed-box strong { color: #3fb950; }
4239 +html[data-estilo="cyber"] .inicio-container .embed-box p { color: #c9d1d9; }
4240 +html[data-estilo="cyber"] .inicio-container .embed-box.prof-box {
4241 + background-color: #0b1520;
4242 + border-left-color: #38bdf8;
4243 +}
4244 +html[data-estilo="cyber"] .inicio-container .embed-box code { background: #21262d; }
4245 +
4246 +html[data-estilo="medieval"] .inicio-container .embed-box {
4247 + background-color: rgba(21, 15, 10, 0.6);
4248 + border-left-color: #d4af37;
4249 +}
4250 +html[data-estilo="medieval"] .inicio-container .embed-box strong { color: #f3e5ab; }
4251 +html[data-estilo="medieval"] .inicio-container .embed-box p { color: #cbb89b; }
4252 +html[data-estilo="medieval"] .inicio-container .embed-box.prof-box {
4253 + background-color: #14202e;
4254 + border-left-color: #8ab4f8;
4255 +}
4256 +html[data-estilo="medieval"] .inicio-container .embed-box code { background: #3a2e1d; }
4257 +
4258 +html[data-estilo="saiyan"] .inicio-container .embed-box {
4259 + background-color: #0f172a;
4260 + border-left-color: #f97316;
4261 +}
4262 +html[data-estilo="saiyan"] .inicio-container .embed-box strong { color: #facc15; }
4263 +html[data-estilo="saiyan"] .inicio-container .embed-box p { color: #ffffff; }
4264 +html[data-estilo="saiyan"] .inicio-container .embed-box.prof-box {
4265 + background-color: #16233f;
4266 + border-left-color: #93c5fd;
4267 +}
4268 +html[data-estilo="saiyan"] .inicio-container .embed-box code { background: #334155; }
4269 +
4270 +/* --- Recuadros de estadísticas (.stat-box / .red-stat) de Perfil y Usuario --- */
4271 +html[data-estilo="cyber"] .stat-box {
4272 + background: #0d1117;
4273 + border: 1px solid #21262d;
4274 +}
4275 +html[data-estilo="cyber"] .red-stat {
4276 + background: #0d1117;
4277 + border: 1px solid #21262d;
4278 +}
4279 +
4280 +html[data-estilo="medieval"] .stat-box {
4281 + background: rgba(21, 15, 10, 0.6);
4282 + border: 1px solid #3a2e1d;
4283 +}
4284 +html[data-estilo="medieval"] .red-stat {
4285 + background: rgba(21, 15, 10, 0.6);
4286 + border: 1px solid #3a2e1d;
4287 +}
4288 +
4289 +html[data-estilo="saiyan"] .stat-box {
4290 + background: #0f172a;
4291 + border: 1px solid #facc15;
4292 +}
4293 +html[data-estilo="saiyan"] .red-stat {
4294 + background: #0f172a;
4295 + border: 1px solid #facc15;
4296 +}
4297 +
4298 +/* ============================================================
4299 + PANEL ADMINISTRATIVO : TARJETAS KPI, TABLAS Y CABECERAS
4300 + (corrección de fondos blancos residuales en admin.css)
4301 + ============================================================ */
4302 +/* --- Cyber --- */
4303 +html[data-estilo="cyber"] .admin-container .kpi-card {
4304 + background: #161b22;
4305 + border: 1px solid #30363d;
4306 + border-top: 3px solid #3fb950;
4307 +}
4308 +html[data-estilo="cyber"] .admin-container .kpi-card.alert { border-top-color: #dc2626; }
4309 +html[data-estilo="cyber"] .admin-container .kpi-value { color: #e6edf3; }
4310 +html[data-estilo="cyber"] .admin-container .kpi-label { color: #8b949e; }
4311 +html[data-estilo="cyber"] .admin-container .kpi-sub { color: #8b949e; }
4312 +html[data-estilo="cyber"] .admin-container .admin-title h2,
4313 +html[data-estilo="cyber"] .admin-container .section-header { color: #e6edf3; }
4314 +html[data-estilo="cyber"] .admin-container .admin-title p { color: #8b949e; }
4315 +html[data-estilo="cyber"] .admin-container .admin-table th {
4316 + background: #0d1117;
4317 + color: #c9d1d9;
4318 + border-bottom-color: #30363d;
4319 +}
4320 +html[data-estilo="cyber"] .admin-container .admin-table td {
4321 + border-bottom-color: #21262d;
4322 + color: #c9d1d9;
4323 +}
4324 +html[data-estilo="cyber"] .admin-container .admin-table tr:hover { background: #161b22; }
4325 +html[data-estilo="cyber"] .admin-container .form-group label { color: #c9d1d9; }
4326 +
4327 +/* --- Medieval --- */
4328 +html[data-estilo="medieval"] .admin-container .kpi-card {
4329 + background: linear-gradient(135deg, rgba(21, 15, 10, 0.92) 0%, rgba(8, 6, 4, 0.96) 100%);
4330 + border: 1px solid #3a2e1d;
4331 + border-top: 3px solid #d4af37;
4332 +}
4333 +html[data-estilo="medieval"] .admin-container .kpi-card.alert { border-top-color: #b91c1c; }
4334 +html[data-estilo="medieval"] .admin-container .kpi-value { color: #f3e5ab; }
4335 +html[data-estilo="medieval"] .admin-container .kpi-label { color: #a39e93; }
4336 +html[data-estilo="medieval"] .admin-container .kpi-sub { color: #a39e93; }
4337 +html[data-estilo="medieval"] .admin-container .admin-title h2,
4338 +html[data-estilo="medieval"] .admin-container .section-header { color: #f3e5ab; }
4339 +html[data-estilo="medieval"] .admin-container .admin-title p { color: #a39e93; }
4340 +html[data-estilo="medieval"] .admin-container .admin-table th {
4341 + background: #1a130c;
4342 + color: #f3e5ab;
4343 + border-bottom-color: #3a2e1d;
4344 +}
4345 +html[data-estilo="medieval"] .admin-container .admin-table td {
4346 + border-bottom-color: #3a2e1d;
4347 + color: #cbb89b;
4348 +}
4349 +html[data-estilo="medieval"] .admin-container .admin-table tr:hover { background: rgba(21, 15, 10, 0.6); }
4350 +html[data-estilo="medieval"] .admin-container .form-group label { color: #cbb89b; }
4351 +
4352 +/* --- Saiyan --- */
4353 +html[data-estilo="saiyan"] .admin-container .kpi-card {
4354 + background: #1e3a8a;
4355 + border: 2px solid #facc15;
4356 + border-top: 4px solid #f97316;
4357 +}
4358 +html[data-estilo="saiyan"] .admin-container .kpi-card.alert { border-top-color: #f97316; }
4359 +html[data-estilo="saiyan"] .admin-container .kpi-value {
4360 + color: #facc15;
4361 + font-weight: 900;
4362 + text-shadow: 2px 2px 0px #f97316;
4363 +}
4364 +html[data-estilo="saiyan"] .admin-container .kpi-label { color: #ffffff; }
4365 +html[data-estilo="saiyan"] .admin-container .kpi-sub { color: #bfdbfe; }
4366 +html[data-estilo="saiyan"] .admin-container .admin-title h2,
4367 +html[data-estilo="saiyan"] .admin-container .section-header { color: #ffffff; }
4368 +html[data-estilo="saiyan"] .admin-container .admin-title p { color: #bfdbfe; }
4369 +html[data-estilo="saiyan"] .admin-container .admin-table th {
4370 + background: #0f172a;
4371 + color: #facc15;
4372 + border-bottom-color: #facc15;
4373 +}
4374 +html[data-estilo="saiyan"] .admin-container .admin-table td {
4375 + border-bottom-color: #facc15;
4376 + color: #ffffff;
4377 +}
4378 +html[data-estilo="saiyan"] .admin-container .admin-table tr:hover { background: #16233f; }
4379 +html[data-estilo="saiyan"] .admin-container .form-group label { color: #bfdbfe; }
4380 +
4381 +/* ============================================================
4382 + PANEL DOCENTE : TARJETAS KPI, TABLAS Y MODAL DE ASESORIAS
4383 + ============================================================ */
4384 +/* --- Cyber --- */
4385 +html[data-estilo="cyber"] .docente-kpi {
4386 + background: #161b22;
4387 + border: 1px solid #30363d;
4388 + border-top: 3px solid #38bdf8;
4389 +}
4390 +html[data-estilo="cyber"] .docente-kpi .val { color: #e6edf3; }
4391 +html[data-estilo="cyber"] .docente-kpi .lbl { color: #8b949e; }
4392 +html[data-estilo="cyber"] .docente-header .header-info p { color: #8b949e; }
4393 +html[data-estilo="cyber"] .docente-section-header h3 { border-bottom-color: #21262d; color: #e6edf3; }
4394 +html[data-estilo="cyber"] .docente-table th { background: #0d1117; color: #c9d1d9; border-bottom-color: #30363d; }
4395 +html[data-estilo="cyber"] .docente-table td { border-bottom-color: #21262d; color: #c9d1d9; }
4396 +html[data-estilo="cyber"] .docente-meta { color: #8b949e; }
4397 +html[data-estilo="cyber"] .docente-asistencia-modal { background: #161b22; border: 1px solid #30363d; }
4398 +html[data-estilo="cyber"] .docente-asistencia-modal h3 { color: #e6edf3; }
4399 +html[data-estilo="cyber"] .docente-asistencia-modal label { color: #c9d1d9; }
4400 +html[data-estilo="cyber"] .docente-asistencia-modal input,
4401 +html[data-estilo="cyber"] .docente-asistencia-modal select,
4402 +html[data-estilo="cyber"] .docente-asistencia-modal textarea { background: #0d1117; color: #e6edf3; border-color: #30363d; }
4403 +
4404 +/* --- Medieval --- */
4405 +html[data-estilo="medieval"] .docente-kpi {
4406 + background: linear-gradient(135deg, rgba(21, 15, 10, 0.92) 0%, rgba(8, 6, 4, 0.96) 100%);
4407 + border: 1px solid #3a2e1d;
4408 + border-top: 3px solid #d4af37;
4409 +}
4410 +html[data-estilo="medieval"] .docente-kpi .val { color: #f3e5ab; }
4411 +html[data-estilo="medieval"] .docente-kpi .lbl { color: #a39e93; }
4412 +html[data-estilo="medieval"] .docente-header .header-info p { color: #a39e93; }
4413 +html[data-estilo="medieval"] .docente-section-header h3 { border-bottom-color: #3a2e1d; color: #f3e5ab; }
4414 +html[data-estilo="medieval"] .docente-table th { background: #1a130c; color: #f3e5ab; border-bottom-color: #3a2e1d; }
4415 +html[data-estilo="medieval"] .docente-table td { border-bottom-color: #3a2e1d; color: #cbb89b; }
4416 +html[data-estilo="medieval"] .docente-meta { color: #a39e93; }
4417 +html[data-estilo="medieval"] .docente-asistencia-modal { background: #1a130c; border: 1px solid #3a2e1d; }
4418 +html[data-estilo="medieval"] .docente-asistencia-modal h3 { color: #f3e5ab; }
4419 +html[data-estilo="medieval"] .docente-asistencia-modal label { color: #cbb89b; }
4420 +html[data-estilo="medieval"] .docente-asistencia-modal input,
4421 +html[data-estilo="medieval"] .docente-asistencia-modal select,
4422 +html[data-estilo="medieval"] .docente-asistencia-modal textarea { background: #120d07; color: #f3e5ab; border-color: #3a2e1d; }
4423 +
4424 +/* --- Saiyan --- */
4425 +html[data-estilo="saiyan"] .docente-kpi {
4426 + background: #1e3a8a;
4427 + border: 2px solid #facc15;
4428 + border-top: 4px solid #f97316;
4429 +}
4430 +html[data-estilo="saiyan"] .docente-kpi .val {
4431 + color: #facc15;
4432 + font-weight: 900;
4433 + text-shadow: 2px 2px 0px #f97316;
4434 +}
4435 +html[data-estilo="saiyan"] .docente-kpi .lbl { color: #ffffff; }
4436 +html[data-estilo="saiyan"] .docente-header .header-info p { color: #bfdbfe; }
4437 +html[data-estilo="saiyan"] .docente-section-header h3 { border-bottom-color: #facc15; color: #ffffff; }
4438 +html[data-estilo="saiyan"] .docente-table th { background: #0f172a; color: #facc15; border-bottom-color: #facc15; }
4439 +html[data-estilo="saiyan"] .docente-table td { border-bottom-color: #facc15; color: #ffffff; }
4440 +html[data-estilo="saiyan"] .docente-meta { color: #bfdbfe; }
4441 +html[data-estilo="saiyan"] .docente-asistencia-modal { background: #1e3a8a; border: 2px solid #facc15; }
4442 +html[data-estilo="saiyan"] .docente-asistencia-modal h3 { color: #ffffff; }
4443 +html[data-estilo="saiyan"] .docente-asistencia-modal label { color: #bfdbfe; }
4444 +html[data-estilo="saiyan"] .docente-asistencia-modal input,
4445 +html[data-estilo="saiyan"] .docente-asistencia-modal select,
4446 +html[data-estilo="saiyan"] .docente-asistencia-modal textarea { background: #0f172a; color: #ffffff; border-color: #facc15; }
4447 +/* ============================================================
4448 + ESTILO: CYBER NOIR EDITION
4449 + Callejon nocturno con neon rojo, lluvia digital y expedientes.
4450 + Mockup: "Cyber Noir Edition". Inyectado por estilos.js.
4451 + ============================================================ */
4452 +
4453 +/* --- Paleta global del skin --- */
4454 +html[data-estilo="noir"] {
4455 + --accent: #e11d48;
4456 + --accent-dark: #9f1239;
4457 + --accent-mid: #fb7185;
4458 + --accent-bright: #fda4af;
4459 + --accent-deep: #3a0011;
4460 + --accent-soft: #14050a;
4461 + --accent-soft-border: #3a0011;
4462 + --accent-contrast: #080305;
4463 +
4464 + --primary-color: #e11d48;
4465 + --secondary-color: #fb7185;
4466 + --link-color: #fb7185;
4467 + --heading-color: #ffffff;
4468 + --text-color: #e2e8f0;
4469 +
4470 + --sidebar-bg: #14050a;
4471 + --sidebar-border: #e11d48;
4472 + --sidebar-text: #94a3b8;
4473 + --sidebar-text-hover: #ffffff;
4474 + --sidebar-item-hover-bg: rgba(225, 29, 72, 0.2);
4475 + --sidebar-item-active-bg: #e11d48;
4476 + --sidebar-accent: #fb7185;
4477 +
4478 + --topbar-bg: rgba(17, 6, 9, 0.9);
4479 +
4480 + --btn-primary-bg: #e11d48;
4481 + --btn-primary-text: #ffffff;
4482 + --btn-outline-border: #e11d48;
4483 + --btn-outline-text: #fb7185;
4484 +}
4485 +
4486 +/* --- Tipografia y geometria --- */
4487 +html[data-estilo="noir"] * {
4488 + font-family: 'Share Tech Mono', 'Special Elite', 'Courier New', monospace;
4489 + border-radius: 0 !important;
4490 +}
4491 +
4492 +html[data-estilo="noir"] body {
4493 + background-color: #080305;
4494 + color: #f1f5f9;
4495 + position: relative;
4496 +}
4497 +
4498 +html[data-estilo="noir"] a { color: #fb7185; }
4499 +
4500 +html[data-estilo="noir"] h1,
4501 +html[data-estilo="noir"] h2,
4502 +html[data-estilo="noir"] h3,
4503 +html[data-estilo="noir"] h4 {
4504 + color: #ffffff;
4505 + letter-spacing: 0.5px;
4506 +}
4507 +
4508 +html[data-estilo="noir"] .page-title { text-shadow: 0 0 12px rgba(225, 29, 72, 0.5); }
4509 +html[data-estilo="noir"] .page-description { color: #94a3b8; }
4510 +
4511 +html[data-estilo="noir"] .app-layout {
4512 + position: relative;
4513 + z-index: 2;
4514 +}
4515 +
4516 +/* ============================================================
4517 + FONDO: CALLEJON NOIR Y LLUVIA DIGITAL OBLICUA
4518 + ============================================================ */
4519 +html[data-estilo="noir"] .noir-bg-viewport {
4520 + position: fixed;
4521 + top: 0; left: 0;
4522 + width: 100vw;
4523 + height: 100vh;
4524 + pointer-events: none;
4525 + z-index: 0;
4526 + overflow: hidden;
4527 + transform-style: preserve-3d;
4528 +}
4529 +
4530 +html[data-estilo="noir"] .noir-svg-layer {
4531 + position: absolute;
4532 + top: -15%;
4533 + left: -15%;
4534 + width: 130vw;
4535 + height: 130vh;
4536 + transform: translate3d(0px, 0px, 0px) scale(1) rotateX(12deg);
4537 + transition: transform 1.6s cubic-bezier(0.16, 1, 0.3, 1);
4538 +}
4539 +
4540 +html[data-estilo="noir"] .rain-streak {
4541 + stroke: #e11d48;
4542 + stroke-width: 1.5;
4543 + opacity: 0.6;
4544 + stroke-dasharray: 20, 40;
4545 + animation: noirRain 1.2s infinite linear;
4546 +}
4547 +
4548 +@keyframes noirRain {
4549 + 0% { transform: translateY(-100px); }
4550 + 100% { transform: translateY(300px); }
4551 +}
4552 +
4553 +html[data-estilo="noir"] .neon-sign {
4554 + fill: none;
4555 + stroke: #ff0055;
4556 + stroke-width: 3;
4557 + filter: drop-shadow(0 0 12px #ff0055);
4558 + animation: noirNeon 3s infinite;
4559 +}
4560 +
4561 +@keyframes noirNeon {
4562 + 0%, 19%, 21%, 23%, 25%, 54%, 56%, 100% { opacity: 1; filter: drop-shadow(0 0 12px #ff0055); }
4563 + 20%, 24%, 55% { opacity: 0.3; filter: drop-shadow(0 0 2px #ff0055); }
4564 +}
4565 +
4566 +html[data-estilo="noir"] .atmosphere-overlay {
4567 + position: fixed;
4568 + top: 0; left: 0;
4569 + width: 100vw;
4570 + height: 100vh;
4571 +background: radial-gradient(circle at 30% 30%, rgba(225, 29, 72, 0.12) 0%, rgba(8, 3, 5, 0.55) 85%);
4572 + pointer-events: none;
4573 + z-index: 1;
4574 +}
4575 +
4576 +/* --- Cortina de transicion (barrido noir) --- */
4577 +html[data-estilo="noir"] .noir-blade-transition {
4578 + position: fixed;
4579 + top: 0; left: 0;
4580 + width: 100vw;
4581 + height: 100vh;
4582 + background: linear-gradient(135deg, #e11d48 0%, #3a0011 50%, #080305 100%);
4583 + z-index: 9999;
4584 + clip-path: polygon(0 0, 0 0, 0 100%, 0 100%);
4585 + transition: clip-path 0.55s cubic-bezier(0.77, 0, 0.175, 1);
4586 + pointer-events: none;
4587 + border-right: 5px solid #ffffff;
4588 +}
4589 +
4590 +html[data-estilo="noir"] .noir-blade-transition.active {
4591 + clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
4592 +}
4593 +
4594 +/* ============================================================
4595 + SIDEBAR: COMANDANCIA NOIR
4596 + ============================================================ */
4597 +html[data-estilo="noir"] .sidebar {
4598 + background: linear-gradient(180deg, #14050a 0%, #080305 100%);
4599 + border-right: 3px solid #e11d48;
4600 + box-shadow: 20px 0 60px rgba(0, 0, 0, 0.95);
4601 +}
4602 +
4603 +html[data-estilo="noir"] .sidebar-header {
4604 + border-bottom: 2px solid #e11d48;
4605 +}
4606 +
4607 +html[data-estilo="noir"] .sidebar-header h1 {
4608 + color: #ffffff;
4609 + font-family: 'Special Elite', 'Share Tech Mono', monospace;
4610 + font-size: 1.3rem;
4611 + letter-spacing: 2px;
4612 + text-transform: uppercase;
4613 + text-shadow: 2px 2px 0px #e11d48;
4614 +}
4615 +
4616 +html[data-estilo="noir"] .sidebar-header small { color: #94a3b8; }
4617 +
4618 +html[data-estilo="noir"] .sidebar-user {
4619 + background: rgba(225, 29, 72, 0.1);
4620 + border: 2px solid #e11d48;
4621 +}
4622 +
4623 +html[data-estilo="noir"] .sidebar-user small {
4624 + color: #ffffff;
4625 + font-weight: bold;
4626 + letter-spacing: 0.5px;
4627 +}
4628 +
4629 +html[data-estilo="noir"] .sidebar-avatar {
4630 + border: 2px solid #ffffff;
4631 + box-shadow: 0 0 10px rgba(225, 29, 72, 0.6);
4632 +}
4633 +
4634 +html[data-estilo="noir"] .sidebar-nav a {
4635 + color: #94a3b8;
4636 + border-left: 4px solid transparent;
4637 + text-transform: uppercase;
4638 + letter-spacing: 1px;
4639 + font-weight: bold;
4640 + transition: transform 0.25s ease, opacity 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease, background-color 0.25s ease, color 0.25s ease;
4641 +}
4642 +
4643 +html[data-estilo="noir"] .sidebar-nav a:hover {
4644 + background: rgba(225, 29, 72, 0.2);
4645 + color: #ffffff;
4646 + border-left-color: #e11d48;
4647 + padding-left: 18px;
4648 +}
4649 +
4650 +html[data-estilo="noir"] .sidebar-nav a.active {
4651 + background: #e11d48;
4652 + color: #ffffff;
4653 + border-left-color: #ffffff;
4654 + box-shadow: inset 0 0 15px rgba(255, 255, 255, 0.4);
4655 +}
4656 +
4657 +html[data-estilo="noir"] .sidebar-footer {
4658 + border-top: 2px solid rgba(225, 29, 72, 0.5);
4659 +}
4660 +
4661 +html[data-estilo="noir"] .sidebar-footer .btn {
4662 + background: #2b000d;
4663 + border: 2px solid #e11d48;
4664 + color: #fb7185;
4665 + text-transform: uppercase;
4666 + font-weight: bold;
4667 + letter-spacing: 1.5px;
4668 +}
4669 +
4670 +html[data-estilo="noir"] .sidebar-footer .btn:hover {
4671 + background: #e11d48;
4672 + color: #ffffff;
4673 + box-shadow: 0 0 25px rgba(225, 29, 72, 0.9);
4674 +}
4675 +
4676 +/* ============================================================
4677 + TOPBAR
4678 + ============================================================ */
4679 +html[data-estilo="noir"] .topbar {
4680 + background: var(--topbar-bg);
4681 + border-bottom: 2px solid rgba(225, 29, 72, 0.5);
4682 + backdrop-filter: none;
4683 +}
4684 +
4685 +html[data-estilo="noir"] .menu-toggle {
4686 + color: #e11d48;
4687 + background: rgba(17, 6, 9, 0.9);
4688 + border: 2px solid #e11d48;
4689 + box-shadow: 0 0 10px rgba(0, 0, 0, 0.8);
4690 +}
4691 +
4692 +html[data-estilo="noir"] .menu-toggle:hover {
4693 + transform: scale(1.1);
4694 + border-color: #ffffff;
4695 + box-shadow: 0 0 20px rgba(225, 29, 72, 0.8);
4696 +}
4697 +
4698 +html[data-estilo="noir"] .menu-backdrop { background: rgba(0, 0, 0, 0.6); }
4699 +
4700 +/* --- Global search --- */
4701 +html[data-estilo="noir"] .global-search-input {
4702 + background: rgba(17, 6, 9, 0.9);
4703 + border: 2px solid #e11d48;
4704 + color: #ffffff;
4705 + box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.8);
4706 +}
4707 +
4708 +html[data-estilo="noir"] .global-search-input::placeholder { color: #9f5463; }
4709 +
4710 +html[data-estilo="noir"] .global-search-input:focus {
4711 + border-color: #ffffff;
4712 + box-shadow: 0 0 25px rgba(225, 29, 72, 0.5), inset 0 0 10px rgba(0, 0, 0, 0.8);
4713 +}
4714 +
4715 +html[data-estilo="noir"] .global-search-dropdown {
4716 + background: #110609;
4717 + border: 2px solid #e11d48;
4718 + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.9);
4719 +}
4720 +
4721 +html[data-estilo="noir"] .global-search-item { border-bottom: 1px solid #3a0011; }
4722 +html[data-estilo="noir"] .global-search-item:hover { background: #1c0a10; }
4723 +
4724 +html[data-estilo="noir"] .global-search-info strong { color: #ffffff; }
4725 +html[data-estilo="noir"] .global-search-info span { color: #94a3b8; }
4726 +
4727 +html[data-estilo="noir"] .global-search-chat {
4728 + border: 1px solid #e11d48;
4729 + color: #fb7185;
4730 + background: rgba(225, 29, 72, 0.08);
4731 +}
4732 +
4733 +html[data-estilo="noir"] .global-search-chat:hover {
4734 + background: #e11d48;
4735 + color: #ffffff;
4736 +}
4737 +
4738 +html[data-estilo="noir"] .global-search-empty { color: #94a3b8; }
4739 +
4740 +/* --- Notificaciones --- */
4741 +html[data-estilo="noir"] .notif-toggle {
4742 + background: rgba(17, 6, 9, 0.9);
4743 + border: 2px solid #e11d48;
4744 + color: #e11d48;
4745 + box-shadow: 0 0 10px rgba(0, 0, 0, 0.8);
4746 +}
4747 +
4748 +html[data-estilo="noir"] .notif-toggle:hover {
4749 + transform: scale(1.1);
4750 + background: #e11d48;
4751 + color: #ffffff;
4752 + box-shadow: 0 0 25px rgba(225, 29, 72, 0.8);
4753 +}
4754 +
4755 +html[data-estilo="noir"] .notif-badge {
4756 + background: #e11d48;
4757 + color: #ffffff;
4758 + box-shadow: 0 0 8px #e11d48;
4759 +}
4760 +
4761 +html[data-estilo="noir"] .notif-panel {
4762 + background: #14050a;
4763 + border: 2px solid #e11d48;
4764 + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.9);
4765 +}
4766 +
4767 +html[data-estilo="noir"] .notif-panel-head { border-bottom: 1px solid #3a0011; }
4768 +html[data-estilo="noir"] .notif-marcar-todas { color: #fb7185; }
4769 +html[data-estilo="noir"] .notif-marcar-todas:hover { color: #ffffff; }
4770 +html[data-estilo="noir"] .notif-item:hover { background: #1c0a10; }
4771 +html[data-estilo="noir"] .notif-item-info strong { color: #ffffff; }
4772 +html[data-estilo="noir"] .notif-item-info span { color: #94a3b8; }
4773 +html[data-estilo="noir"] .notif-item-badge {
4774 + background: rgba(225, 29, 72, 0.2);
4775 + color: #fda4af;
4776 +}
4777 +
4778 +html[data-estilo="noir"] .notif-vacio { color: #94a3b8; }
4779 +
4780 +/* ============================================================
4781 + CONTENIDO Y COMPONENTES
4782 + ============================================================ */
4783 +html[data-estilo="noir"] .main-content { background: transparent; }
4784 +
4785 +html[data-estilo="noir"] .main-content > * {
4786 + opacity: 0;
4787 + transform: translateY(22px) scale(0.98);
4788 + animation: viewEntrance 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
4789 +}
4790 +
4791 +html[data-estilo="noir"] .main-content > *:nth-child(1) { animation-delay: 0.05s; }
4792 +html[data-estilo="noir"] .main-content > *:nth-child(2) { animation-delay: 0.12s; }
4793 +html[data-estilo="noir"] .main-content > *:nth-child(3) { animation-delay: 0.19s; }
4794 +html[data-estilo="noir"] .main-content > *:nth-child(4) { animation-delay: 0.26s; }
4795 +
4796 +html[data-estilo="noir"] .card {
4797 + background: linear-gradient(135deg, rgba(24, 8, 14, 0.92) 0%, rgba(8, 3, 5, 0.96) 100%);
4798 + border: 2px solid #e11d48;
4799 + border-left: 8px solid #e11d48;
4800 + box-shadow: 0 15px 40px rgba(0, 0, 0, 0.9);
4801 + position: relative;
4802 + overflow: hidden;
4803 + backdrop-filter: none;
4804 + transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.35s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.35s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.35s cubic-bezier(0.16, 1, 0.3, 1), color 0.35s cubic-bezier(0.16, 1, 0.3, 1);
4805 +}
4806 +
4807 +html[data-estilo="noir"] .card::after {
4808 + content: '//';
4809 + position: absolute;
4810 + top: 10px;
4811 + right: 15px;
4812 + font-size: 1.2rem;
4813 + color: rgba(225, 29, 72, 0.3);
4814 + pointer-events: none;
4815 +}
4816 +
4817 +html[data-estilo="noir"] .card:hover {
4818 + border-color: #ffffff;
4819 + border-left-color: #ffffff;
4820 + box-shadow: 0 20px 60px rgba(225, 29, 72, 0.35);
4821 + transform: translateY(-3px);
4822 +}
4823 +
4824 +html[data-estilo="noir"] .card-title {
4825 + color: #ffffff;
4826 + font-weight: 900;
4827 +}
4828 +
4829 +html[data-estilo="noir"] .card p,
4830 +html[data-estilo="noir"] .card li { color: #cbd5e1; }
4831 +html[data-estilo="noir"] .card strong { color: #ffffff; }
4832 +
4833 +/* --- Botones --- */
4834 +html[data-estilo="noir"] .btn {
4835 + border: 2px solid #e11d48;
4836 + color: #f1f5f9;
4837 + background: rgba(17, 6, 9, 0.9);
4838 + text-transform: uppercase;
4839 + letter-spacing: 1px;
4840 + font-weight: bold;
4841 + transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.25s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), color 0.25s cubic-bezier(0.16, 1, 0.3, 1);
4842 +}
4843 +
4844 +html[data-estilo="noir"] .btn:hover {
4845 + border-color: #ffffff;
4846 + box-shadow: 0 0 20px rgba(225, 29, 72, 0.8);
4847 + color: #ffffff;
4848 +}
4849 +
4850 +html[data-estilo="noir"] .btn-primary,
4851 +html[data-estilo="noir"] .btn-primary:hover {
4852 + background: #e11d48;
4853 + border: 2px solid #ffffff;
4854 + color: #ffffff;
4855 + box-shadow: 0 0 20px rgba(225, 29, 72, 0.6);
4856 + animation: noirPulse 3s infinite;
4857 +}
4858 +
4859 +html[data-estilo="noir"] .btn-primary:hover { transform: scale(1.03); background: #ffffff; color: #080305; box-shadow: 0 0 30px rgba(255, 255, 255, 0.6); }
4860 +
4861 +@keyframes noirPulse {
4862 + 0%, 100% { box-shadow: 0 0 15px rgba(225, 29, 72, 0.6); }
4863 + 50% { box-shadow: 0 0 30px rgba(255, 255, 255, 0.8); }
4864 +}
4865 +
4866 +html[data-estilo="noir"] .btn-secondary {
4867 + background: rgba(17, 6, 9, 0.9);
4868 + border: 2px solid #e11d48;
4869 + color: #fb7185;
4870 +}
4871 +
4872 +html[data-estilo="noir"] .btn-outline {
4873 + background: transparent;
4874 + border: 2px solid #e11d48;
4875 + color: #fb7185;
4876 +}
4877 +
4878 +html[data-estilo="noir"] .btn-outline:hover {
4879 + background: #e11d48;
4880 + color: #ffffff;
4881 + box-shadow: 0 0 20px rgba(225, 29, 72, 0.8);
4882 +}
4883 +
4884 +html[data-estilo="noir"] .btn-danger {
4885 + border: 2px solid #ff2d55;
4886 + color: #ff2d55;
4887 + background: transparent;
4888 +}
4889 +
4890 +html[data-estilo="noir"] .btn-danger:hover {
4891 + background: #ff2d55;
4892 + color: #ffffff;
4893 +}
4894 +
4895 +html[data-estilo="noir"] .btn-success {
4896 + border: 2px solid #22c55e;
4897 + color: #4ade80;
4898 + background: transparent;
4899 +}
4900 +
4901 +html[data-estilo="noir"] .btn-success:hover {
4902 + background: #22c55e;
4903 + color: #080305;
4904 +}
4905 +
4906 +/* Botones solid/outline de paneles admin y docente */
4907 +html[data-estilo="noir"] .btn-solid {
4908 + background: #e11d48;
4909 + border: 2px solid #ffffff;
4910 + color: #ffffff;
4911 + box-shadow: 0 0 15px rgba(225, 29, 72, 0.6);
4912 +}
4913 +
4914 +html[data-estilo="noir"] .btn-solid:hover {
4915 + background: #ffffff;
4916 + color: #080305;
4917 + border-color: #ffffff;
4918 +}
4919 +
4920 +html[data-estilo="noir"] .btn-danger-clean {
4921 + background: #2b000d;
4922 + border: 2px solid #e11d48;
4923 + color: #fb7185;
4924 +}
4925 +
4926 +html[data-estilo="noir"] .btn-danger-clean:hover {
4927 + background: #e11d48;
4928 + color: #ffffff;
4929 +}
4930 +
4931 +html[data-estilo="noir"] .btn-success-clean {
4932 + background: #052e11;
4933 + border: 2px solid #22c55e;
4934 + color: #4ade80;
4935 +}
4936 +
4937 +html[data-estilo="noir"] .btn-success-clean:hover {
4938 + background: #22c55e;
4939 + color: #080305;
4940 +}
4941 +
4942 +/* --- Formularios --- */
4943 +html[data-estilo="noir"] input,
4944 +html[data-estilo="noir"] select,
4945 +html[data-estilo="noir"] textarea,
4946 +html[data-estilo="noir"] .form-control {
4947 + background: rgba(8, 3, 5, 0.9);
4948 + border: 2px solid #e11d48;
4949 + color: #ffffff;
4950 + box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.8);
4951 + outline: none;
4952 + transition: transform 0.3s, opacity 0.3s, box-shadow 0.3s, border-color 0.3s, background-color 0.3s, color 0.3s;
4953 +}
4954 +
4955 +html[data-estilo="noir"] input:focus,
4956 +html[data-estilo="noir"] select:focus,
4957 +html[data-estilo="noir"] textarea:focus,
4958 +html[data-estilo="noir"] .form-control:focus {
4959 + border-color: #ffffff;
4960 + box-shadow: 0 0 20px rgba(225, 29, 72, 0.5), inset 0 0 8px rgba(0, 0, 0, 0.8);
4961 +}
4962 +
4963 +html[data-estilo="noir"] input::placeholder,
4964 +html[data-estilo="noir"] textarea::placeholder,
4965 +html[data-estilo="noir"] .form-control::placeholder { color: #9f5463; }
4966 +
4967 +html[data-estilo="noir"] label { color: #cbd5e1; }
4968 +
4969 +/* --- Insignias y avatares --- */
4970 +html[data-estilo="noir"] .avatar {
4971 + border: 2px solid #e11d48;
4972 + box-shadow: 0 0 8px rgba(225, 29, 72, 0.4);
4973 +}
4974 +
4975 +html[data-estilo="noir"] .badge-info,
4976 +html[data-estilo="noir"] .badge-primary {
4977 + background: rgba(225, 29, 72, 0.2);
4978 + color: #fda4af;
4979 + border: 1px solid #e11d48;
4980 +}
4981 +
4982 +html[data-estilo="noir"] .badge-warning {
4983 + background: rgba(245, 158, 11, 0.15);
4984 + color: #fbbf24;
4985 + border: 1px solid #b45309;
4986 +}
4987 +
4988 +html[data-estilo="noir"] .badge-success,
4989 +html[data-estilo="noir"] .badge-green {
4990 + background: rgba(34, 197, 94, 0.15);
4991 + color: #4ade80;
4992 + border: 1px solid #22c55e;
4993 +}
4994 +
4995 +html[data-estilo="noir"] .badge-danger,
4996 +html[data-estilo="noir"] .badge-gray {
4997 + background: rgba(225, 29, 72, 0.15);
4998 + color: #94a3b8;
4999 + border: 1px solid #e11d48;

This file is too large to show in full.

frontend/css/inicio.css
+516 -57
@@ -1,58 +1,517 @@
1 -/* MAPS Connect - Panel de inicio */
2 -
3 -.inicio-container { max-width: 1100px; margin: 0 auto; }
4 -.loading-message { color: var(--gray-600); }
5 -.inicio-container .card, .inicio-container .btn, .inicio-container .badge, .inicio-container input, .inicio-container select { border-radius: 0; }
6 -.maps-banner { border-left: 4px solid var(--sidebar-bg); }
7 -.banner-content, .post-header, .post-footer, .create-post-actions { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
8 -.banner-content { align-items: flex-start; flex-wrap: wrap; }
9 -.banner-content h1 { color: var(--sidebar-bg); font-size: 1.3rem; }
10 -.banner-content p, .post-body, .side-card p { color: var(--gray-600); }
11 -.progress-container { margin-top: 1rem; }
12 -.progress-labels { display: flex; justify-content: space-between; gap: 1rem; margin-bottom: 0.375rem; color: var(--gray-600); font-size: 0.8rem; font-weight: 600; }
13 -.progress-bar { height: 0.5rem; background: var(--gray-200); }
14 -.progress-fill { width: 50%; height: 100%; background: var(--sidebar-bg); }
15 -.inicio-layout { display: grid; grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr); gap: 1.25rem; }
16 -.create-post input, .create-post select { width: 100%; padding: 0.75rem; border: 1px solid var(--gray-300); background: white; font: inherit; }
17 -.create-post input { margin-bottom: 0.75rem; }
18 -.create-post-actions select { max-width: 60%; }
19 -.btn-solid { display: inline-block; padding: 0.55rem 1rem; border: 1px solid var(--sidebar-bg); background: var(--sidebar-bg); color: white; font: inherit; font-size: 0.9rem; font-weight: 700; cursor: pointer; text-align: center; }
20 -.btn-solid:hover, .btn-solid:focus-visible { background: #043b10; color: white; text-decoration: none; }
21 -.post-card { padding: 1.125rem; }
22 -.post-header { align-items: flex-start; margin-bottom: 0.75rem; }
23 -.user-block, .badges-wrap, .action-links { display: flex; align-items: center; gap: 0.625rem; }
24 -.badges-wrap, .action-links { flex-wrap: wrap; }
25 -.avatar { display: grid; width: 2.375rem; height: 2.375rem; place-items: center; background: var(--sidebar-bg); color: white; font-size: 0.85rem; font-weight: 700; }
26 -.avatar.prof { background: #0284c7; }
27 -.user-data strong, .user-data span { display: block; }
28 -.user-data strong { font-size: 0.9rem; }
29 -.user-data span, .post-status { color: var(--gray-600); font-size: 0.75rem; }
30 -.badge-materia { border: 1px solid #c8e6c9; background: #e8f5e9; color: var(--sidebar-bg); }
31 -.badge-interacted { border: 1px solid #fde68a; background: #fef3c7; color: #92400e; }
32 -.badge-solved { border: 1px solid #bbf7d0; background: #dcfce7; color: #166534; }
33 -.badge-verified { border: 1px solid #bae6fd; background: #e0f2fe; color: #0369a1; }
34 -.post-title { font-size: 1.05rem; }
35 -.post-body { margin-bottom: 0.75rem; font-size: 0.9rem; }
36 -.embed-box { margin: 0.75rem 0; border-left: 3px solid var(--sidebar-bg); background: var(--gray-50); padding: 0.625rem 0.75rem; font-size: 0.85rem; }
37 -.embed-box p { margin-top: 0.25rem; }
38 -.prof-box { border-left-color: #0284c7; background: #f0f9ff; }
39 -.post-footer { margin-top: 0.75rem; border-top: 1px solid var(--gray-200); padding-top: 0.625rem; }
40 -.btn-action { border: 0; background: transparent; color: var(--gray-600); font: inherit; font-size: 0.82rem; font-weight: 600; cursor: pointer; }
41 -.btn-action:hover, .btn-action.is-active { color: var(--sidebar-bg); text-decoration: none; }
42 -.side-card { border-top: 3px solid var(--sidebar-bg); }
43 -.side-card.prof-card { border-top-color: #0284c7; }
44 -.side-card h3 { margin-top: 0.5rem; font-size: 0.95rem; }
45 -.side-card p { margin: 0.5rem 0 0.75rem; font-size: 0.85rem; }
46 -.side-card .btn-solid, .btn-clean { width: 100%; }
47 -.btn-clean { display: inline-block; border: 1px solid var(--sidebar-bg); background: transparent; padding: 0.45rem 0.75rem; color: var(--sidebar-bg); font: inherit; font-size: 0.85rem; font-weight: 600; cursor: pointer; text-align: center; }
48 -.side-link { color: var(--sidebar-bg); font-size: 0.85rem; font-weight: 700; }
49 -.empty-state { border-left: 4px solid var(--gray-300); text-align: center; }
50 -.empty-state h2 { font-size: 1.05rem; }
51 -.empty-state p { margin: 0.5rem 0 1rem; color: var(--gray-600); }
52 -
53 -@media (max-width: 850px) { .inicio-layout { grid-template-columns: 1fr; } }
54 -@media (max-width: 560px) {
55 - .post-header, .post-footer, .create-post-actions { align-items: flex-start; flex-direction: column; }
56 - .create-post-actions select, .create-post-actions .btn-solid { max-width: none; width: 100%; }
57 - .progress-labels { flex-direction: column; gap: 0.125rem; }
1 +/* Panel de inicio - estilos del dashboard (mockup institucional) */
2 +
3 +.inicio-container,
4 +.inicio-container * {
5 + border-radius: 0 !important;
6 +}
7 +
8 +.inicio-container {
9 + max-width: 1100px;
10 + margin: 0 auto;
11 + color: #1a1a1a;
12 +}
13 +
14 +.inicio-container .card {
15 + background: #ffffff;
16 + border: 1px solid #dcdfe6;
17 + padding: 20px;
18 + margin-bottom: 20px;
19 +}
20 +
21 +/* Aviso de filtro por carrera */
22 +.filter-note {
23 + display: inline-block;
24 + margin: 0 0 0.75rem;
25 + padding: 0.35rem 0.625rem;
26 + background: var(--accent-soft, #e8f5e9);
27 + border: 1px solid var(--accent-soft-border, #c8e6c9);
28 + color: var(--accent-dark, #005a2b);
29 + font-size: 0.83rem;
30 +}
31 +
32 +.filter-note[hidden] { display: none; }
33 +
34 +/* --- Banner MAPS superior --- */
35 +.inicio-container .maps-banner {
36 + border-left: 4px solid var(--accent-dark, #005a2b);
37 +}
38 +
39 +.inicio-container .banner-content {
40 + display: flex;
41 + justify-content: space-between;
42 + align-items: center;
43 + flex-wrap: wrap;
44 + gap: 12px;
45 +}
46 +
47 +.inicio-container .banner-content h2 {
48 + color: var(--accent-dark, #005a2b);
49 + font-size: 1.3rem;
50 + margin: 0;
51 +}
52 +
53 +.inicio-container .banner-content p {
54 + color: #555;
55 + font-size: 0.9rem;
56 + margin: 4px 0 0;
57 +}
58 +
59 +.inicio-container .progress-container {
60 + margin-top: 14px;
61 +}
62 +
63 +.inicio-container .progress-labels {
64 + display: flex;
65 + justify-content: space-between;
66 + font-size: 0.8rem;
67 + color: #666;
68 + font-weight: 600;
69 + margin-bottom: 6px;
70 +}
71 +
72 +.inicio-container .progress-bar {
73 + width: 100%;
74 + height: 8px;
75 + background-color: #e2e8f0;
76 +}
77 +
78 +.inicio-container .progress-fill {
79 + height: 100%;
80 + background-color: var(--accent-dark, #005a2b);
81 +}
82 +
83 +/* --- Layout dos columnas --- */
84 +.inicio-container .main-layout {
85 + display: grid;
86 + grid-template-columns: 2fr 1fr;
87 + gap: 20px;
88 +}
89 +
90 +@media (max-width: 850px) {
91 + .inicio-container .main-layout {
92 + grid-template-columns: 1fr;
93 + }
94 +}
95 +
96 +/* --- Caja de publicar --- */
97 +.inicio-container .create-post input {
98 + width: 100%;
99 + padding: 12px;
100 + border: 1px solid #dcdfe6;
101 + margin-bottom: 12px;
102 + outline: none;
103 + font-size: 0.9rem;
104 +}
105 +
106 +.inicio-container .create-post-actions {
107 + display: flex;
108 + justify-content: space-between;
109 + align-items: center;
110 + gap: 8px;
111 +}
112 +
113 +.inicio-container .create-post-actions select {
114 + padding: 8px;
115 + border: 1px solid #dcdfe6;
116 + outline: none;
117 + color: #444;
118 + flex: 1;
119 +}
120 +
121 +.inicio-container .btn-solid {
122 + background-color: var(--accent-dark, #005a2b);
123 + color: #ffffff;
124 + border: none;
125 + padding: 8px 18px;
126 + font-size: 0.9rem;
127 + font-weight: bold;
128 + cursor: pointer;
129 + text-decoration: none;
130 + display: inline-block;
131 + text-align: center;
132 +}
133 +
134 +/* --- Publicaciones --- */
135 +.inicio-container .feed-column .post-card {
136 + padding: 18px;
137 +}
138 +
139 +.inicio-container .post-header {
140 + display: flex;
141 + justify-content: space-between;
142 + align-items: flex-start;
143 + gap: 10px;
144 + margin-bottom: 10px;
145 +}
146 +
147 +.inicio-container .user-block {
148 + display: flex;
149 + align-items: center;
150 + gap: 10px;
151 +}
152 +
153 +.inicio-container .avatar {
154 + width: 38px;
155 + height: 38px;
156 + background-color: var(--accent-dark, #005a2b);
157 + color: #ffffff;
158 + display: flex;
159 + align-items: center;
160 + justify-content: center;
161 + font-weight: bold;
162 + font-size: 0.85rem;
163 + flex-shrink: 0;
164 +}
165 +
166 +.inicio-container .avatar.prof {
167 + background-color: #0284c7;
168 +}
169 +
170 +.inicio-container .avatar-foto {
171 + width: 38px;
172 + height: 38px;
173 + object-fit: cover;
174 + background-color: var(--accent-dark, #005a2b);
175 +}
176 +
177 +.inicio-container .avatar-foto.prof,
178 +.inicio-container .avatar.prof {
179 + background-color: #0284c7;
180 +}
181 +
182 +.inicio-container .user-data-line {
183 + display: flex;
184 + align-items: center;
185 + gap: 8px;
186 +}
187 +
188 +.inicio-container .user-data strong {
189 + font-size: 0.9rem;
190 + color: #222;
191 + display: block;
192 +}
193 +
194 +.inicio-container .user-data span {
195 + font-size: 0.75rem;
196 + color: #777;
197 +}
198 +
199 +.inicio-container .badges-wrap {
200 + display: flex;
201 + gap: 6px;
202 + flex-wrap: wrap;
203 +}
204 +
205 +.inicio-container .badge {
206 + padding: 3px 8px;
207 + font-size: 0.75rem;
208 + font-weight: bold;
209 + display: inline-block;
210 +}
211 +
212 +.inicio-container .badge-materia {
213 + background-color: var(--accent-soft, #e8f5e9);
214 + color: var(--accent-dark, #005a2b);
215 + border: 1px solid var(--accent-soft-border, #c8e6c9);
216 +}
217 +
218 +.inicio-container .badge-interacted {
219 + background-color: #fef3c7;
220 + color: #92400e;
221 + border: 1px solid #fde68a;
222 +}
223 +
224 +.inicio-container .badge-solved {
225 + background-color: var(--accent-soft, #dcfce7);
226 + color: #166534;
227 + border: 1px solid #bbf7d0;
228 +}
229 +
230 +.inicio-container .badge-verified {
231 + background-color: #e0f2fe;
232 + color: #0369a1;
233 + border: 1px solid #bae6fd;
234 +}
235 +
236 +.inicio-container .post-title {
237 + font-size: 1.05rem;
238 + color: #111;
239 + margin: 0 0 6px;
240 +}
241 +
242 +.inicio-container .post-body {
243 + font-size: 0.9rem;
244 + color: #444;
245 + line-height: 1.4;
246 + margin: 0 0 12px;
247 +}
248 +
249 +/* --- Respuestas / archivo incrustados --- */
250 +.inicio-container .embed-box {
251 + background-color: #f9fafb;
252 + border-left: 3px solid var(--accent-dark, #005a2b);
253 + padding: 10px 12px;
254 + margin: 10px 0;
255 + font-size: 0.85rem;
256 +}
257 +
258 +.inicio-container .embed-box strong {
259 + display: block;
260 + margin-bottom: 4px;
261 + color: var(--accent-dark, #005a2b);
262 +}
263 +
264 +.inicio-container .embed-box p {
265 + margin: 0;
266 + color: #444;
267 +}
268 +
269 +.inicio-container .embed-box.prof-box {
270 + border-left-color: #0284c7;
271 + background-color: #f0f9ff;
272 +}
273 +
274 +.inicio-container .embed-box code {
275 + background: #f1f5f9;
276 + padding: 1px 4px;
277 + font-size: 0.78rem;
278 +}
279 +
280 +.inicio-container .post-footer {
281 + display: flex;
282 + justify-content: space-between;
283 + align-items: center;
284 + gap: 8px;
285 + border-top: 1px solid #f0f0f0;
286 + padding-top: 10px;
287 + margin-top: 10px;
288 +}
289 +
290 +.inicio-container .action-links {
291 + display: flex;
292 + gap: 14px;
293 + flex-wrap: wrap;
294 +}
295 +
296 +.inicio-container .btn-action {
297 + background: none;
298 + border: none;
299 + color: #555;
300 + cursor: pointer;
301 + font-size: 0.82rem;
302 + font-weight: 600;
303 + padding: 0;
304 +}
305 +
306 +.inicio-container .btn-action:hover {
307 + color: var(--accent-dark, #005a2b);
308 +}
309 +
310 +.inicio-container .btn-action.voted {
311 + color: var(--accent-dark, #005a2b);
312 + font-weight: bold;
313 +}
314 +
315 +.inicio-container .post-status {
316 + font-size: 0.75rem;
317 + color: #888;
318 + white-space: nowrap;
319 +}
320 +
321 +/* --- Barra lateral --- */
322 +.inicio-container .sidebar-section h3,
323 +.inicio-container .sidebar-section h4 {
324 + margin: 0 0 8px;
325 +}
326 +
327 +.inicio-container .sidebar-section .side-card {
328 + border-top: 3px solid var(--accent-dark, #005a2b);
329 +}
330 +
331 +.inicio-container .sidebar-section .side-card.prof-card {
332 + border-top-color: #0284c7;
333 +}
334 +
335 +.inicio-container .side-card h4 {
336 + font-size: 0.95rem;
337 +}
338 +
339 +.inicio-container .side-card p {
340 + font-size: 0.85rem;
341 + color: #555;
342 + line-height: 1.4;
343 + margin: 0 0 12px;
344 +}
345 +
346 +.inicio-container .btn-clean {
347 + background-color: transparent;
348 + color: var(--accent-dark, #005a2b);
349 + border: 1px solid var(--accent-dark, #005a2b);
350 + padding: 6px 12px;
351 + font-size: 0.85rem;
352 + cursor: pointer;
353 + font-weight: 600;
354 + width: 100%;
355 + text-decoration: none;
356 + display: inline-block;
357 + text-align: center;
358 +}
359 +
360 +.inicio-container .side-link {
361 + display: inline-block;
362 + color: var(--accent-dark, #005a2b);
363 + font-size: 0.85rem;
364 + font-weight: bold;
365 + text-decoration: none;
366 + cursor: pointer;
367 +}
368 +
369 +.inicio-container .side-link:hover {
370 + text-decoration: underline;
371 +}
372 +
373 +/* ============================================================
374 + MODO OSCURO ÔÇö Panel de inicio (inversi├│n pareja)
375 + ============================================================ */
376 +html.dark-mode .inicio-container {
377 + color: #e6edf3;
378 +}
379 +
380 +html.dark-mode .inicio-container .card {
381 + background: #161b22;
382 + border-color: #30363d;
383 +}
384 +
385 +html.dark-mode .filter-note {
386 + background: #12291d;
387 + border-color: #14532d;
388 + color: #a7f3d0;
389 +}
390 +
391 +html.dark-mode .inicio-container .banner-content h2,
392 +html.dark-mode .inicio-container .side-card h4 {
393 + color: var(--accent-bright, #3fb950);
394 +}
395 +
396 +html.dark-mode .inicio-container .banner-content p,
397 +html.dark-mode .inicio-container .progress-labels,
398 +html.dark-mode .inicio-container .side-card p,
399 +html.dark-mode .inicio-container .action-links .btn-action {
400 + color: #8b949e;
401 +}
402 +
403 +html.dark-mode .inicio-container .progress-bar {
404 + background-color: #21262d;
405 +}
406 +
407 +html.dark-mode .inicio-container .progress-fill {
408 + background-color: var(--accent-bright, #3fb950);
409 +}
410 +
411 +html.dark-mode .inicio-container .create-post input,
412 +html.dark-mode .inicio-container .create-post-actions select {
413 + background-color: #0d1117;
414 + color: #e6edf3;
415 + border-color: #30363d;
416 +}
417 +
418 +html.dark-mode .inicio-container .create-post-actions select {
419 + color: #e6edf3;
420 +}
421 +
422 +html.dark-mode .inicio-container .create-post-actions select option {
423 + background-color: #161b22;
424 + color: #e6edf3;
425 +}
426 +
427 +html.dark-mode .inicio-container .btn-solid {
428 + background-color: var(--accent-mid, #2ea043);
429 + color: #ffffff;
430 +}
431 +
432 +html.dark-mode .inicio-container .user-data strong,
433 +html.dark-mode .inicio-container .post-title {
434 + color: #e6edf3;
435 +}
436 +
437 +html.dark-mode .inicio-container .user-data span,
438 +html.dark-mode .inicio-container .post-body,
439 +html.dark-mode .inicio-container .embed-box p {
440 + color: #8b949e;
441 +}
442 +
443 +html.dark-mode .inicio-container .embed-box {
444 + background-color: #0d1117;
445 + border-left-color: var(--accent-bright, #3fb950);
446 +}
447 +
448 +html.dark-mode .inicio-container .embed-box strong {
449 + color: var(--accent-bright, #3fb950);
450 +}
451 +
452 +html.dark-mode .inicio-container .embed-box.prof-box {
453 + border-left-color: #38bdf8;
454 + background-color: #0b1520;
455 +}
456 +
457 +html.dark-mode .inicio-container .embed-box code {
458 + background: #21262d;
459 +}
460 +
461 +html.dark-mode .inicio-container .post-footer {
462 + border-top-color: #21262d;
463 +}
464 +
465 +html.dark-mode .inicio-container .btn-action.voted {
466 + color: var(--accent-bright, #3fb950);
467 +}
468 +
469 +html.dark-mode .inicio-container .btn-action:hover {
470 + color: var(--accent-bright, #3fb950);
471 +}
472 +
473 +html.dark-mode .inicio-container .post-status {
474 + color: #8b949e;
475 +}
476 +
477 +html.dark-mode .inicio-container .badge-materia {
478 + background-color: #12291d;
479 + color: var(--accent-bright, #3fb950);
480 + border-color: #14532d;
481 +}
482 +
483 +html.dark-mode .inicio-container .badge-interacted {
484 + background-color: #332a12;
485 + color: #fbbf24;
486 + border-color: #4d3d17;
487 +}
488 +
489 +html.dark-mode .inicio-container .badge-solved {
490 + background-color: #12291d;
491 + color: #34d399;
492 + border-color: #14532d;
493 +}
494 +
495 +html.dark-mode .inicio-container .badge-verified {
496 + background-color: #0b1520;
497 + color: #38bdf8;
498 + border-color: #123f5c;
499 +}
500 +
501 +html.dark-mode .inicio-container .btn-clean {
502 + color: var(--accent-bright, #3fb950);
503 + border-color: var(--accent-mid, #2ea043);
504 +}
505 +
506 +html.dark-mode .inicio-container .btn-clean:hover {
507 + background-color: rgba(46, 160, 67, 0.15);
508 +}
509 +
510 +html.dark-mode .inicio-container .side-link {
511 + color: var(--accent-bright, #3fb950);
512 +}
513 +
514 +html.dark-mode .inicio-container .avatar {
515 + background-color: var(--accent-mid, #2ea043);
516 + color: #ffffff;
517 }
frontend/css/layout.css
+318
@@ -40,6 +40,40 @@
40 color: var(--sidebar-text);
41 }
42
43 +.sidebar-user {
44 + display: flex;
45 + align-items: center;
46 + gap: 10px;
47 + margin-top: 10px;
48 + min-width: 0;
49 +}
50 +
51 +.sidebar-user small {
52 + white-space: nowrap;
53 + overflow: hidden;
54 + text-overflow: ellipsis;
55 +}
56 +
57 +.sidebar-avatar {
58 + width: 34px;
59 + height: 34px;
60 + background-color: rgba(255, 255, 255, 0.12);
61 + color: #ffffff;
62 + display: flex;
63 + align-items: center;
64 + justify-content: center;
65 + font-weight: bold;
66 + font-size: 0.8rem;
67 + flex-shrink: 0;
68 + border-radius: 0;
69 +}
70 +
71 +.sidebar-avatar.avatar-foto {
72 + object-fit: cover;
73 + width: 34px;
74 + height: 34px;
75 +}
76 +
77 .sidebar-nav {
78 list-style: none;
79 padding: 1rem 0;
@@ -134,6 +168,134 @@
168 overflow-y: auto;
169 }
170
171 +/* ---------- Buscador global (topbar) ---------- */
172 +.global-search {
173 + position: fixed;
174 + top: 0.8rem;
175 + left: 50%;
176 + transform: translateX(-50%);
177 + z-index: 115;
178 + width: min(500px, calc(100vw - 10rem));
179 +}
180 +
181 +.global-search-input {
182 + width: 100%;
183 + padding: 0.55rem 0.9rem;
184 + border: 1px solid var(--gray-300);
185 + background: #ffffff;
186 + font: inherit;
187 + font-size: 0.9rem;
188 + box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.12);
189 + border-radius: 0;
190 +}
191 +
192 +.global-search-input:focus {
193 + outline: none;
194 + border-color: var(--sidebar-bg);
195 +}
196 +
197 +.global-search-dropdown {
198 + position: absolute;
199 + top: calc(100% + 6px);
200 + left: 0;
201 + right: 0;
202 + background: #ffffff;
203 + border: 1px solid var(--gray-300);
204 + box-shadow: 0 0.5rem 1.25rem rgba(0, 0, 0, 0.18);
205 + max-height: 320px;
206 + overflow-y: auto;
207 + z-index: 116;
208 +}
209 +
210 +.global-search-item {
211 + display: flex;
212 + align-items: center;
213 + gap: 10px;
214 + padding: 8px 10px;
215 + cursor: pointer;
216 + border-bottom: 1px solid #f0f0f0;
217 +}
218 +
219 +.global-search-item:hover {
220 + background: #f6f8fa;
221 +}
222 +
223 +.global-search-item:last-child {
224 + border-bottom: none;
225 +}
226 +
227 +.global-search-item .avatar {
228 + width: 36px;
229 + height: 36px;
230 + font-size: 0.85rem;
231 + flex-shrink: 0;
232 + background-color: var(--hub-accent, #0284c7);
233 + color: #ffffff;
234 + display: flex;
235 + align-items: center;
236 + justify-content: center;
237 + font-weight: bold;
238 +}
239 +
240 +.global-search-item .avatar-foto {
241 + width: 36px;
242 + height: 36px;
243 + object-fit: cover;
244 + flex-shrink: 0;
245 +}
246 +
247 +.global-search-info {
248 + flex: 1;
249 + min-width: 0;
250 +}
251 +
252 +.global-search-info strong {
253 + display: block;
254 + font-size: 0.88rem;
255 + color: #111111;
256 + white-space: nowrap;
257 + overflow: hidden;
258 + text-overflow: ellipsis;
259 +}
260 +
261 +.global-search-info span {
262 + display: block;
263 + font-size: 0.75rem;
264 + color: #666666;
265 + white-space: nowrap;
266 + overflow: hidden;
267 + text-overflow: ellipsis;
268 +}
269 +
270 +.global-search-chat {
271 + border: 1px solid var(--accent-dark, #005a2b);
272 + background: transparent;
273 + color: var(--accent-dark, #005a2b);
274 + font-size: 0.72rem;
275 + font-weight: 700;
276 + padding: 5px 10px;
277 + cursor: pointer;
278 + border-radius: 0;
279 + white-space: nowrap;
280 +}
281 +
282 +.global-search-chat:hover {
283 + background: var(--accent-soft, #e8f5e9);
284 +}
285 +
286 +.global-search-empty {
287 + padding: 12px;
288 + font-size: 0.85rem;
289 + color: #777777;
290 + text-align: center;
291 +}
292 +
293 +@media (max-width: 768px) {
294 + .global-search {
295 + width: calc(100vw - 5.5rem);
296 + }
297 +}
298 +
299 .visually-hidden {
300 position: absolute;
301 width: 1px;
@@ -228,3 +390,159 @@
390 padding: 4.75rem 1rem 1rem;
391 }
392 }
393 +
394 +/* ---------- Notificaciones (campana global) ---------- */
395 +.notif-bell {
396 + position: fixed;
397 + top: 0.9rem;
398 + right: 1.25rem;
399 + z-index: 115;
400 +}
401 +
402 +.notif-toggle {
403 + position: relative;
404 + width: 2.75rem;
405 + height: 2.75rem;
406 + border: none;
407 + border-radius: 50%;
408 + background: #ffffff;
409 + font-size: 1.15rem;
410 + cursor: pointer;
411 + box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.2);
412 +}
413 +
414 +.notif-toggle:hover {
415 + background: #f6f8fa;
416 +}
417 +
418 +.notif-badge {
419 + position: absolute;
420 + top: -2px;
421 + right: -2px;
422 + min-width: 1.1rem;
423 + height: 1.1rem;
424 + padding: 0 0.24rem;
425 + border-radius: 999px;
426 + background: #b3261e;
427 + color: #ffffff;
428 + font-size: 0.68rem;
429 + font-weight: 700;
430 + line-height: 1.1rem;
431 + text-align: center;
432 +}
433 +
434 +.notif-panel {
435 + position: absolute;
436 + top: calc(100% + 0.5rem);
437 + right: 0;
438 + width: min(340px, calc(100vw - 2rem));
439 + background: #ffffff;
440 + border: 1px solid var(--gray-300);
441 + box-shadow: 0 0.5rem 1.25rem rgba(0, 0, 0, 0.18);
442 + z-index: 116;
443 +}
444 +
445 +.notif-panel-head {
446 + display: flex;
447 + justify-content: space-between;
448 + align-items: center;
449 + gap: 8px;
450 + padding: 0.6rem 0.75rem;
451 + border-bottom: 1px solid #f0f0f0;
452 +}
453 +
454 +.notif-marcar-todas {
455 + background: none;
456 + border: none;
457 + color: var(--sidebar-bg, #0284c7);
458 + font-size: 0.78rem;
459 + cursor: pointer;
460 +}
461 +
462 +.notif-marcar-todas:hover {
463 + text-decoration: underline;
464 +}
465 +
466 +.notif-list {
467 + max-height: 340px;
468 + overflow-y: auto;
469 +}
470 +
471 +.notif-item {
472 + display: flex;
473 + align-items: center;
474 + gap: 10px;
475 + padding: 0.55rem 0.75rem;
476 + cursor: pointer;
477 + border-bottom: 1px solid #f6f8fa;
478 + text-decoration: none;
479 + color: inherit;
480 +}
481 +
482 +.notif-item:hover {
483 + background: #f6f8fa;
484 +}
485 +
486 +.notif-item .avatar {
487 + width: 34px;
488 + height: 34px;
489 + font-size: 0.8rem;
490 + flex-shrink: 0;
491 + background-color: var(--sidebar-bg, #0284c7);
492 + color: #ffffff;
493 + display: flex;
494 + align-items: center;
495 + justify-content: center;
496 + font-weight: bold;
497 +}
498 +
499 +.notif-item-info {
500 + flex: 1;
501 + min-width: 0;
502 +}
503 +
504 +.notif-item-info strong {
505 + display: block;
506 + font-size: 0.85rem;
507 + color: #222;
508 +}
509 +
510 +.notif-item-info span {
511 + font-size: 0.76rem;
512 + color: #666;
513 + display: block;
514 +}
515 +
516 +.notif-item-info .notif-preview {
517 + overflow: hidden;
518 + text-overflow: ellipsis;
519 + white-space: nowrap;
520 +}
521 +
522 +.notif-item-badge {
523 + flex-shrink: 0;
524 + min-width: 1.05rem;
525 + height: 1.05rem;
526 + padding: 0 0.22rem;
527 + border-radius: 999px;
528 + background: #b3261e;
529 + color: #ffffff;
530 + font-size: 0.68rem;
531 + font-weight: 700;
532 + line-height: 1.05rem;
533 + text-align: center;
534 +}
535 +
536 +.notif-vacio {
537 + padding: 1rem;
538 + color: #888;
539 + font-size: 0.85rem;
540 + text-align: center;
541 +}
542 +
543 +@media (max-width: 768px) {
544 + .notif-bell {
545 + right: 0.75rem;
546 + }
547 +}
548 +
frontend/css/mensajes.css
+142 -4
@@ -1,9 +1,9 @@
1 /* MAPS Connect - Mensajes */
2
3 .messages-container {
4 - --hub-accent: #005a2b;
4 + --hub-accent: var(--accent-dark, #005a2b);
5 --hub-accent-dark: #004420;
6 - --hub-accent-soft: #e8f5e9;
6 + --hub-accent-soft: var(--accent-soft, #e8f5e9);
7 --hub-border: #dcdfe6;
8 --hub-muted: #666;
9
@@ -25,7 +25,7 @@
25 }
26 }
27
28 -/* Bordes rectos institucionales (scopeado a la página) */
28 +/* Bordes rectos institucionales (scopeado a la página) */
29 .messages-container .avatar,
30 .messages-container .btn-solid,
31 .messages-container .btn-clean,
@@ -212,7 +212,7 @@
212 opacity: 0.8;
213 }
214
215 -/* Estado vacío del chat */
215 +/* Estado vacío del chat */
216 .chat-empty {
217 flex: 1;
218 display: flex;
@@ -269,3 +269,141 @@
269 cursor: pointer;
270 font-weight: 600;
271 }
272 +
273 +/* --- ADJUNTOS --- */
274 +.adjunto-area {
275 + display: flex;
276 + align-items: center;
277 + gap: 10px;
278 + flex-shrink: 0;
279 +}
280 +
281 +.chat-input-bar .btn-clean {
282 + white-space: nowrap;
283 +}
284 +
285 +.adjunto-pendiente {
286 + display: inline-flex;
287 + align-items: center;
288 + gap: 6px;
289 + max-width: 220px;
290 + padding: 4px 8px;
291 + background: var(--hub-accent-soft);
292 + border: 1px solid var(--hub-accent);
293 + font-size: 0.78rem;
294 + color: var(--hub-accent-dark);
295 + white-space: nowrap;
296 + overflow: hidden;
297 + text-overflow: ellipsis;
298 +}
299 +
300 +.adjunto-pendiente[hidden] {
301 + display: none;
302 +}
303 +
304 +.adjunto-pendiente span {
305 + overflow: hidden;
306 + text-overflow: ellipsis;
307 +}
308 +
309 +.adjunto-quitar {
310 + background: transparent;
311 + border: none;
312 + color: var(--hub-accent-dark);
313 + font-weight: bold;
314 + cursor: pointer;
315 + padding: 0 2px;
316 + font-size: 0.8rem;
317 +}
318 +
319 +.message-attachment {
320 + display: flex;
321 + align-items: center;
322 + justify-content: space-between;
323 + gap: 10px;
324 + margin-bottom: 6px;
325 + padding: 8px 10px;
326 + background: rgba(255, 255, 255, 0.9);
327 + border: 1px solid rgba(0, 0, 0, 0.12);
328 + border-radius: 0;
329 +}
330 +
331 +.message.sent .message-attachment {
332 + background: rgba(255, 255, 255, 0.92);
333 + border-color: rgba(0, 0, 0, 0.15);
334 +}
335 +
336 +.attachment-info {
337 + display: flex;
338 + align-items: center;
339 + gap: 8px;
340 + min-width: 0;
341 +}
342 +
343 +.attachment-icon {
344 + background: var(--hub-accent);
345 + color: #ffffff;
346 + font-size: 0.62rem;
347 + font-weight: bold;
348 + padding: 4px 6px;
349 + letter-spacing: 0.5px;
350 + flex-shrink: 0;
351 +}
352 +
353 +.message.sent .attachment-icon {
354 + background: #ffffff;
355 + color: var(--hub-accent);
356 +}
357 +
358 +.attachment-info > span:last-child {
359 + display: flex;
360 + flex-direction: column;
361 + min-width: 0;
362 +}
363 +
364 +.attachment-nombre {
365 + font-size: 0.8rem;
366 + color: #111;
367 + white-space: nowrap;
368 + overflow: hidden;
369 + text-overflow: ellipsis;
370 + max-width: 200px;
371 +}
372 +
373 +.attachment-peso {
374 + font-size: 0.68rem;
375 + color: var(--hub-muted);
376 +}
377 +
378 +.adjunto-descargar {
379 + flex-shrink: 0;
380 + background: transparent;
381 + border: 1px solid var(--hub-accent);
382 + color: var(--hub-accent);
383 + padding: 4px 10px;
384 + font-size: 0.72rem;
385 + font-weight: 700;
386 + cursor: pointer;
387 + border-radius: 0;
388 +}
389 +
390 +.message.sent .adjunto-descargar {
391 + border-color: #ffffff;
392 + color: #ffffff;
393 +}
394 +
395 +.adjunto-descargar:hover {
396 + background: var(--hub-accent);
397 + color: #ffffff;
398 +}
399 +
400 +.message.sent .adjunto-descargar:hover {
401 + background: #ffffff;
402 + color: var(--hub-accent);
403 +}
404 +
405 +.message-texto {
406 + margin: 0 0 4px;
407 + overflow-wrap: break-word;
408 +}
409 +
frontend/css/perfil.css
+283 -5
@@ -1,9 +1,9 @@
1 /* MAPS Connect - Mi Perfil */
2
3 .perfil-container {
4 - --hub-accent: #005a2b;
4 + --hub-accent: var(--accent-dark, #005a2b);
5 --hub-accent-dark: #004420;
6 - --hub-accent-soft: #e8f5e9;
6 + --hub-accent-soft: var(--accent-soft, #e8f5e9);
7 --hub-border: #dcdfe6;
8 --hub-muted: #666;
9
@@ -12,7 +12,7 @@
12 width: 100%;
13 }
14
15 -/* Bordes rectos institucionales (scopeado a la página) */
15 +/* Bordes rectos institucionales (scopeado a la página) */
16 .perfil-container .card,
17 .perfil-container .profile-avatar,
18 .perfil-container .btn-solid,
@@ -137,7 +137,7 @@
137 .badge-green {
138 background-color: var(--hub-accent-soft);
139 color: var(--hub-accent);
140 - border: 1px solid #c8e6c9;
140 + border: 1px solid var(--accent-soft-border, #c8e6c9);
141 }
142
143 .badge-gray {
@@ -200,7 +200,7 @@
200 line-height: 1.4;
201 }
202
203 -/* --- COLUMNA DERECHA: APORTACIONES Y ESTADÍSTICAS --- */
203 +/* --- COLUMNA DERECHA: APORTACIONES Y ESTADÍSTICAS --- */
204 .stats-summary {
205 display: grid;
206 grid-template-columns: 1fr 1fr;
@@ -252,3 +252,281 @@
252 color: #888888;
253 font-size: 0.75rem;
254 }
255 +
256 +/* --- MODAL: EDICIÓN DE PROPÓSITO DE VIDA --- */
257 +.proposito-modal-overlay {
258 + position: fixed;
259 + inset: 0;
260 + z-index: 4000;
261 + background: rgba(0, 0, 0, 0.45);
262 + display: flex;
263 + align-items: center;
264 + justify-content: center;
265 + padding: 1rem;
266 +}
267 +
268 +.proposito-modal {
269 + background: #ffffff;
270 + border-radius: 0;
271 + max-width: 520px;
272 + width: 100%;
273 + padding: 24px;
274 + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
275 + border-top: 4px solid var(--accent-dark, #005a2b);
276 +}
277 +
278 +.proposito-modal h3 {
279 + font-size: 1.15rem;
280 + color: #111111;
281 + margin-bottom: 6px;
282 +}
283 +
284 +.proposito-modal-sub {
285 + font-size: 0.85rem;
286 + color: #666666;
287 + margin-bottom: 14px;
288 +}
289 +
290 +.proposito-modal textarea {
291 + width: 100%;
292 + padding: 10px 12px;
293 + border: 1px solid #dcdfe6;
294 + border-radius: 0;
295 + font-size: 0.95rem;
296 + font-family: inherit;
297 + resize: vertical;
298 + box-sizing: border-box;
299 +}
300 +
301 +.proposito-modal textarea:focus {
302 + outline: none;
303 + border-color: var(--accent-dark, #005a2b);
304 + box-shadow: 0 0 0 2px rgba(0, 90, 43, 0.15);
305 +}
306 +
307 +.proposito-modal-msg {
308 + font-size: 0.85rem;
309 + color: #b91c1c;
310 + margin-top: 8px;
311 + min-height: 1em;
312 +}
313 +
314 +.proposito-modal-actions {
315 + display: flex;
316 + justify-content: flex-end;
317 + gap: 10px;
318 + margin-top: 16px;
319 +}
320 +
321 +.proposito-modal-actions .btn-solid {
322 + background-color: var(--accent-dark, #005a2b);
323 + color: #ffffff;
324 + border: none;
325 + padding: 9px 18px;
326 + font-size: 0.9rem;
327 + font-weight: bold;
328 + cursor: pointer;
329 + border-radius: 0;
330 +}
331 +
332 +.proposito-modal-actions .btn-solid:hover {
333 + background-color: #004420;
334 +}
335 +
336 +.proposito-modal-actions .btn-clean {
337 + background-color: transparent;
338 + color: var(--accent-dark, #005a2b);
339 + border: 1px solid var(--accent-dark, #005a2b);
340 + padding: 8px 16px;
341 + font-size: 0.85rem;
342 + font-weight: 600;
343 + cursor: pointer;
344 + border-radius: 0;
345 +}
346 +
347 +.proposito-modal-actions .btn-clean:hover {
348 + background-color: var(--accent-soft, #e8f5e9);
349 +}
350 +.proposito-modal-actions .btn-clean {
351 + background-color: transparent;
352 + color: var(--accent-dark, #005a2b);
353 + border: 1px solid var(--accent-dark, #005a2b);
354 + padding: 8px 16px;
355 + font-size: 0.85rem;
356 + font-weight: 600;
357 + cursor: pointer;
358 + border-radius: 0;
359 +}
360 +
361 +.proposito-modal-actions .btn-clean:hover {
362 + background-color: var(--accent-soft, #e8f5e9);
363 +}
364 +
365 +/* --- 3. MI RED: SEGUIDORES Y DESCUBRIR PERSONAS --- */
366 +.red-stats {
367 + display: flex;
368 + gap: 18px;
369 + flex-wrap: wrap;
370 + margin-bottom: 8px;
371 +}
372 +
373 +.red-stat {
374 + background: #fafafa;
375 + border: 1px solid #e0e0e0;
376 + padding: 10px 18px;
377 + text-align: center;
378 +}
379 +
380 +.red-stat .number {
381 + font-size: 1.2rem;
382 + font-weight: bold;
383 + color: var(--hub-accent);
384 + display: block;
385 +}
386 +
387 +.red-stat .label {
388 + font-size: 0.72rem;
389 + color: var(--hub-muted);
390 + text-transform: uppercase;
391 +}
392 +
393 +.discover-grid {
394 + display: grid;
395 + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
396 + gap: 12px;
397 +}
398 +
399 +.discover-person {
400 + display: flex;
401 + align-items: center;
402 + gap: 12px;
403 + border: 1px solid var(--hub-border);
404 + padding: 12px;
405 + background: #ffffff;
406 +}
407 +
408 +.discover-person .avatar,
409 +.discover-person .avatar-foto {
410 + width: 46px;
411 + height: 46px;
412 + background-color: var(--hub-accent);
413 + color: #ffffff;
414 + display: flex;
415 + align-items: center;
416 + justify-content: center;
417 + font-weight: bold;
418 + font-size: 1rem;
419 + flex-shrink: 0;
420 +}
421 +
422 +.discover-person .avatar-foto {
423 + object-fit: cover;
424 +}
425 +
426 +.discover-info {
427 + min-width: 0;
428 + flex: 1;
429 +}
430 +
431 +.discover-info strong {
432 + display: block;
433 + font-size: 0.88rem;
434 + color: #111111;
435 + white-space: nowrap;
436 + overflow: hidden;
437 + text-overflow: ellipsis;
438 +}
439 +
440 +.discover-info span {
441 + display: block;
442 + font-size: 0.75rem;
443 + color: var(--hub-muted);
444 +}
445 +
446 +.discover-empty {
447 + color: var(--hub-muted);
448 + font-size: 0.85rem;
449 +}
450 +
451 +/* ============================================================
452 + MODO OSCURO (página de perfil)
453 + ============================================================ */
454 +html.dark-mode .perfil-container .card,
455 +html.dark-mode .certificate-item,
456 +html.dark-mode .discover-person {
457 + background: #161b22;
458 + border-color: #30363d;
459 +}
460 +
461 +html.dark-mode .profile-name,
462 +html.dark-mode .cert-title,
463 +html.dark-mode .section-heading,
464 +html.dark-mode .sub-heading,
465 +html.dark-mode .discover-info strong {
466 + color: #e6edf3;
467 +}
468 +
469 +html.dark-mode .profile-sub,
470 +html.dark-mode .cert-desc,
471 +html.dark-mode .discover-info span {
472 + color: #8b949e;
473 +}
474 +
475 +html.dark-mode .purpose-box {
476 + background: #0d1117;
477 + border-color: #21262d;
478 + border-left-color: var(--accent-mid, #2ea043);
479 +}
480 +
481 +html.dark-mode .purpose-box strong { color: var(--accent-bright, #3fb950); }
482 +html.dark-mode .purpose-box p { color: #c9d1d9; }
483 +
484 +html.dark-mode .stat-box,
485 +html.dark-mode .red-stat {
486 + background: #0d1117;
487 + border-color: #30363d;
488 +}
489 +
490 +html.dark-mode .stat-box .number,
491 +html.dark-mode .red-stat .number {
492 + color: var(--accent-bright, #3fb950);
493 +}
494 +
495 +html.dark-mode .stat-box .label,
496 +html.dark-mode .red-stat .label,
497 +html.dark-mode .activity-item span,
498 +html.dark-mode .discover-empty {
499 + color: #8b949e;
500 +}
501 +
502 +html.dark-mode .activity-item strong { color: #e6edf3; }
503 +html.dark-mode .activity-item { border-bottom-color: #21262d; }
504 +
505 +html.dark-mode .perfil-container .btn-clean {
506 + color: var(--accent-bright, #3fb950);
507 + border-color: var(--accent-mid, #2ea043);
508 +}
509 +html.dark-mode .perfil-container .btn-clean:hover {
510 + background-color: rgba(46, 160, 67, 0.15);
511 +}
512 +
513 +html.dark-mode .proposito-modal {
514 + background: #161b22;
515 +}
516 +html.dark-mode .proposito-modal h3 { color: #e6edf3; }
517 +html.dark-mode .proposito-modal-sub { color: #8b949e; }
518 +html.dark-mode .proposito-modal textarea {
519 + background: #0d1117;
520 + color: #e6edf3;
521 + border-color: #30363d;
522 +}
523 +html.dark-mode .badge-green {
524 + background-color: rgba(46, 160, 67, 0.15);
525 + color: var(--accent-bright, #3fb950);
526 + border-color: var(--accent-mid, #2ea043);
527 +}
528 +html.dark-mode .badge-gray {
529 + background-color: #21262d;
530 + color: #c9d1d9;
531 + border-color: #30363d;
532 +}
frontend/css/usuario.css new
+531
@@ -0,0 +1,531 @@
1 +/* MAPS Connect - Perfil público (usuarios) */
2 +
3 +.summary-section {
4 + margin-bottom: 20px;
5 +}
6 +
7 +.perfil-container {
8 + max-width: 1050px;
9 + margin: 0 auto;
10 + width: 100%;
11 +}
12 +
13 +/* Cabecera de perfil ajeno */
14 +.profile-header-card {
15 + display: flex;
16 + justify-content: space-between;
17 + align-items: center;
18 + flex-wrap: wrap;
19 + gap: 20px;
20 + border-left: 4px solid var(--accent-dark, #005a2b);
21 +}
22 +
23 +.profile-main-info {
24 + display: flex;
25 + align-items: center;
26 + gap: 18px;
27 +}
28 +
29 +/* Avatar interactivo (ampliar en modal) */
30 +.profile-avatar {
31 + width: 70px;
32 + height: 70px;
33 + background-color: #0284c7;
34 + color: #ffffff;
35 + display: flex;
36 + align-items: center;
37 + justify-content: center;
38 + font-weight: bold;
39 + font-size: 1.5rem;
40 + flex-shrink: 0;
41 +}
42 +
43 +.profile-avatar.trigger {
44 + cursor: pointer;
45 + transition: transform 0.15s ease, opacity 0.15s ease;
46 +}
47 +
48 +.profile-avatar.trigger:hover {
49 + transform: scale(1.05);
50 + opacity: 0.9;
51 +}
52 +
53 +.profile-avatar.avatar-foto {
54 + object-fit: cover;
55 +}
56 +
57 +.profile-name {
58 + font-size: 1.4rem;
59 + color: #111111;
60 + margin-bottom: 4px;
61 +}
62 +
63 +.profile-sub {
64 + font-size: 0.9rem;
65 + color: #555555;
66 +}
67 +
68 +.profile-sub + .profile-sub {
69 + margin-top: 2px;
70 +}
71 +
72 +.header-actions {
73 + display: flex;
74 + gap: 10px;
75 + flex-wrap: wrap;
76 +}
77 +
78 +.btn-solid {
79 + background-color: var(--accent-dark, #005a2b);
80 + color: #ffffff;
81 + border: none;
82 + padding: 9px 18px;
83 + font-size: 0.9rem;
84 + font-weight: bold;
85 + cursor: pointer;
86 +}
87 +
88 +.btn-solid:hover {
89 + background-color: #004420;
90 +}
91 +
92 +.btn-clean {
93 + background-color: transparent;
94 + color: var(--accent-dark, #005a2b);
95 + border: 1px solid var(--accent-dark, #005a2b);
96 + padding: 8px 16px;
97 + font-size: 0.85rem;
98 + font-weight: 600;
99 + cursor: pointer;
100 + text-decoration: none;
101 + display: inline-block;
102 +}
103 +
104 +.btn-clean:hover {
105 + background-color: var(--accent-soft, #f0fdf4);
106 +}
107 +
108 +/* Layout 2 columnas */
109 +.profile-grid {
110 + display: grid;
111 + grid-template-columns: 1.8fr 1.2fr;
112 + gap: 20px;
113 +}
114 +
115 +@media (max-width: 850px) {
116 + .profile-grid {
117 + grid-template-columns: 1fr;
118 + }
119 +}
120 +
121 +.section-heading {
122 + font-size: 1.1rem;
123 + color: #222222;
124 + margin-bottom: 14px;
125 + border-bottom: 1px solid #e0e0e0;
126 + padding-bottom: 6px;
127 +}
128 +
129 +.subsection-heading {
130 + font-size: 0.95rem;
131 + color: #333333;
132 + margin: 16px 0 10px 0;
133 +}
134 +
135 +.badge {
136 + padding: 3px 8px;
137 + font-size: 0.75rem;
138 + font-weight: bold;
139 + display: inline-block;
140 +}
141 +
142 +.badge-green {
143 + background-color: var(--accent-soft, #e8f5e9);
144 + color: var(--accent-dark, #005a2b);
145 + border: 1px solid var(--accent-soft-border, #c8e6c9);
146 +}
147 +
148 +.badge-blue {
149 + background-color: #e0f2fe;
150 + color: #0369a1;
151 + border: 1px solid #bae6fd;
152 +}
153 +
154 +.purpose-box {
155 + background-color: #f9fafb;
156 + border-left: 3px solid var(--accent-dark, #005a2b);
157 + padding: 12px 16px;
158 + margin-bottom: 18px;
159 +}
160 +
161 +.purpose-box strong {
162 + font-size: 0.85rem;
163 + color: var(--accent-dark, #005a2b);
164 + display: block;
165 + text-transform: uppercase;
166 + margin-bottom: 4px;
167 +}
168 +
169 +.purpose-box p {
170 + font-size: 0.92rem;
171 + color: #333333;
172 + font-style: italic;
173 + margin: 0;
174 +}
175 +
176 +/* Certificados (alumno) */
177 +.certificate-item {
178 + border: 1px solid #dcdfe6;
179 + padding: 14px;
180 + margin-bottom: 12px;
181 + background: #ffffff;
182 +}
183 +
184 +.certificate-item:last-child {
185 + margin-bottom: 0;
186 +}
187 +
188 +.certificate-item.active {
189 + border-left: 4px solid var(--accent-dark, #005a2b);
190 +}
191 +
192 +.cert-header {
193 + display: flex;
194 + justify-content: space-between;
195 + align-items: center;
196 + gap: 10px;
197 + margin-bottom: 6px;
198 +}
199 +
200 +.cert-title {
201 + font-size: 0.95rem;
202 + color: #111111;
203 + font-weight: bold;
204 +}
205 +
206 +.cert-desc {
207 + font-size: 0.85rem;
208 + color: #555555;
209 + line-height: 1.4;
210 +}
211 +
212 +/* Ficha del docente */
213 +.prof-detail-list {
214 + list-style: none;
215 + margin: 0 0 14px 0;
216 + padding: 0;
217 +}
218 +
219 +.prof-detail-list li {
220 + font-size: 0.85rem;
221 + color: #333333;
222 + padding: 6px 0;
223 + border-bottom: 1px solid #f0f0f0;
224 +}
225 +
226 +.prof-detail-list li:last-child {
227 + border-bottom: none;
228 +}
229 +
230 +.prof-detail-list strong {
231 + color: #111111;
232 +}
233 +
234 +.prof-detail-list .ok {
235 + color: var(--accent-dark, #005a2b);
236 + font-weight: bold;
237 +}
238 +
239 +.prof-detail-list .off {
240 + color: #b91c1c;
241 + font-weight: bold;
242 +}
243 +
244 +.materia-tag {
245 + display: inline-block;
246 + background: #e0f2fe;
247 + border: 1px solid #bae6fd;
248 + color: #0369a1;
249 + padding: 2px 8px;
250 + font-size: 0.75rem;
251 + font-weight: 600;
252 + margin: 0 6px 6px 0;
253 +}
254 +
255 +/* Estadísticas y reputación */
256 +.stats-summary {
257 + display: grid;
258 + grid-template-columns: 1fr 1fr;
259 + gap: 10px;
260 + margin-bottom: 18px;
261 +}
262 +
263 +.stat-box {
264 + background: #fafafa;
265 + border: 1px solid #e0e0e0;
266 + padding: 12px;
267 + text-align: center;
268 +}
269 +
270 +.stat-box .number {
271 + font-size: 1.3rem;
272 + font-weight: bold;
273 + color: var(--accent-dark, #005a2b);
274 + display: block;
275 +}
276 +
277 +.stat-box .label {
278 + font-size: 0.75rem;
279 + color: #666666;
280 + text-transform: uppercase;
281 +}
282 +
283 +/* Aportaciones */
284 +.public-item {
285 + padding: 12px 0;
286 + border-bottom: 1px solid #f0f0f0;
287 +}
288 +
289 +.public-item:last-child {
290 + border-bottom: none;
291 + padding-bottom: 0;
292 +}
293 +
294 +.public-item strong {
295 + font-size: 0.88rem;
296 + color: #222222;
297 + display: block;
298 + margin-bottom: 3px;
299 +}
300 +
301 +.public-item p {
302 + font-size: 0.82rem;
303 + color: #555555;
304 + line-height: 1.3;
305 + margin: 0;
306 +}
307 +
308 +.item-meta {
309 + display: flex;
310 + justify-content: space-between;
311 + gap: 8px;
312 + font-size: 0.78rem;
313 + color: #888888;
314 + margin-top: 6px;
315 +}
316 +
317 +/* Estado vacío */
318 +.empty-note {
319 + font-size: 0.85rem;
320 + color: #888888;
321 + padding: 6px 0;
322 +}
323 +
324 +.side-link {
325 + color: var(--accent-dark, #005a2b);
326 + font-weight: bold;
327 + text-decoration: none;
328 + display: inline-block;
329 + margin-top: 6px;
330 +}
331 +
332 +.side-link:hover {
333 + text-decoration: underline;
334 +}
335 +
336 +/* --- MODAL PARA AMPLIAR AVATAR --- */
337 +.avatar-modal-backdrop {
338 + display: none;
339 + position: fixed;
340 + top: 0;
341 + left: 0;
342 + width: 100vw;
343 + height: 100vh;
344 + background-color: rgba(0, 0, 0, 0.75);
345 + z-index: 9999;
346 + align-items: center;
347 + justify-content: center;
348 +}
349 +
350 +.avatar-modal-backdrop.show {
351 + display: flex;
352 +}
353 +
354 +.avatar-modal-content {
355 + background: #ffffff;
356 + border: 1px solid #dcdfe6;
357 + padding: 16px;
358 + display: flex;
359 + flex-direction: column;
360 + align-items: center;
361 + gap: 12px;
362 + max-width: 90vw;
363 +}
364 +
365 +.avatar-expanded-box {
366 + width: 300px;
367 + height: 300px;
368 + background-color: #0284c7;
369 + color: #ffffff;
370 + display: flex;
371 + align-items: center;
372 + justify-content: center;
373 + font-size: 6rem;
374 + font-weight: bold;
375 + border: 2px solid var(--accent-dark, #005a2b);
376 + object-fit: cover;
377 +}
378 +
379 +.btn-close-modal {
380 + align-self: flex-end;
381 + background: transparent;
382 + border: 1px solid #dcdfe6;
383 + color: #333333;
384 + padding: 6px 12px;
385 + font-size: 0.82rem;
386 + font-weight: 600;
387 + cursor: pointer;
388 +}
389 +
390 +.btn-close-modal:hover {
391 + background-color: #f4f6f8;
392 + border-color: var(--accent-dark, #005a2b);
393 +}
394 +
395 +/* ============================================================
396 + MODO OSCURO — Perfil público (usuarios) (inversión pareja)
397 + ============================================================ */
398 +html.dark-mode .perfil-container {
399 + color: #e6edf3;
400 +}
401 +
402 +html.dark-mode .profile-name {
403 + color: #e6edf3;
404 +}
405 +
406 +html.dark-mode .profile-sub {
407 + color: #8b949e;
408 +}
409 +
410 +html.dark-mode .section-heading {
411 + color: #e6edf3;
412 + border-bottom-color: #30363d;
413 +}
414 +
415 +html.dark-mode .subsection-heading {
416 + color: #c9d1d9;
417 +}
418 +
419 +html.dark-mode .badge-green {
420 + background-color: #12291d;
421 + color: var(--accent-bright, #3fb950);
422 + border-color: #14532d;
423 +}
424 +
425 +html.dark-mode .badge-blue {
426 + background-color: #0b1520;
427 + color: #38bdf8;
428 + border-color: #123f5c;
429 +}
430 +
431 +html.dark-mode .purpose-box {
432 + background-color: #0d1117;
433 + border-left-color: var(--accent-mid, #2ea043);
434 +}
435 +
436 +html.dark-mode .purpose-box strong {
437 + color: var(--accent-bright, #3fb950);
438 +}
439 +
440 +html.dark-mode .purpose-box p {
441 + color: #c9d1d9;
442 +}
443 +
444 +html.dark-mode .certificate-item {
445 + background: #161b22;
446 + border-color: #30363d;
447 +}
448 +
449 +html.dark-mode .cert-title {
450 + color: #e6edf3;
451 +}
452 +
453 +html.dark-mode .cert-desc {
454 + color: #8b949e;
455 +}
456 +
457 +html.dark-mode .prof-detail-list li {
458 + color: #c9d1d9;
459 + border-bottom-color: #21262d;
460 +}
461 +
462 +html.dark-mode .prof-detail-list strong {
463 + color: #e6edf3;
464 +}
465 +
466 +html.dark-mode .materia-tag {
467 + background: #0b1520;
468 + border-color: #123f5c;
469 + color: #38bdf8;
470 +}
471 +
472 +html.dark-mode .stat-box {
473 + background: #0d1117;
474 + border-color: #30363d;
475 +}
476 +
477 +html.dark-mode .stat-box .number {
478 + color: var(--accent-bright, #3fb950);
479 +}
480 +
481 +html.dark-mode .stat-box .label {
482 + color: #8b949e;
483 +}
484 +
485 +html.dark-mode .public-item {
486 + border-bottom-color: #21262d;
487 +}
488 +
489 +html.dark-mode .public-item strong {
490 + color: #e6edf3;
491 +}
492 +
493 +html.dark-mode .public-item p {
494 + color: #8b949e;
495 +}
496 +
497 +html.dark-mode .item-meta {
498 + color: #8b949e;
499 +}
500 +
501 +html.dark-mode .empty-note {
502 + color: #8b949e;
503 +}
504 +
505 +html.dark-mode .side-link {
506 + color: var(--accent-bright, #3fb950);
507 +}
508 +
509 +html.dark-mode .avatar-modal-content {
510 + background: #161b22;
511 + border-color: #30363d;
512 +}
513 +
514 +html.dark-mode .btn-close-modal {
515 + border-color: #30363d;
516 + color: #c9d1d9;
517 +}
518 +
519 +html.dark-mode .btn-close-modal:hover {
520 + background-color: #21262d;
521 + border-color: var(--accent-bright, #3fb950);
522 +}
523 +
524 +html.dark-mode .perfil-container .btn-clean {
525 + color: var(--accent-bright, #3fb950);
526 + border-color: var(--accent-mid, #2ea043);
527 +}
528 +
529 +html.dark-mode .perfil-container .btn-clean:hover {
530 + background-color: rgba(46, 160, 67, 0.15);
531 +}
\ No newline at end of file
frontend/index.html
+18 -8
@@ -4,9 +4,13 @@
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Iniciar sesión | MAPS Connect</title>
7 - <link rel="stylesheet" href="css/base.css">
8 - <link rel="stylesheet" href="css/layout.css">
9 - <link rel="stylesheet" href="css/components.css">
7 + <script>try{if(localStorage.getItem('tema-maps')==='oscuro'){document.documentElement.classList.add('dark-mode')}}catch(e){}</script>
8 + <script src="js/estilos.js?v=17"></script>
9 + <link rel="stylesheet" href="css/base.css?v=8">
10 + <link rel="stylesheet" href="css/layout.css?v=5">
11 + <link rel="stylesheet" href="css/components.css?v=8">
12 + <link rel="stylesheet" href="css/colores.css?v=1">
13 + <link rel="stylesheet" href="css/estilos.css?v=26">
14 </head>
15 <body class="login-page">
16 <div class="auth-layout">
@@ -25,16 +29,22 @@
29 <button type="submit" class="btn btn-primary" style="width:100%">Entrar</button>
30 </form>
31 <p class="form-hint" style="margin-top:1rem;text-align:center">
28 - Acceso provisional: <strong>demo@tecmilenio.mx</strong> / <strong>Demo1234</strong>
32 + Acceso exclusivo para la comunidad Tecmilenio
33 </p>
34 <p style="margin-top:0.5rem;text-align:center">
35 <a href="registro.html">Crear cuenta</a>
36 </p>
37 + <p style="margin-top:0.75rem;text-align:center;font-size:0.8rem;">
38 + <a id="link-aviso-login" role="button">Aviso de Privacidad</a>
39 + </p>
40 </div>
41 </div>
35 - <script src="js/api.js"></script>
36 - <script src="js/auth.js"></script>
37 - <script src="js/utils.js"></script>
38 - <script src="js/pages/login.js"></script>
42 + <script src="js/api.js?v=5"></script>
43 + <script src="js/auth.js?v=7"></script>
44 + <script src="js/utils.js?v=5"></script>
45 + <script src="js/tema.js?v=2"></script>
46 + <script src="js/colores.js?v=1"></script>
47 + <script src="js/aviso-privacidad.js?v=1"></script>
48 + <script src="js/pages/login.js?v=8"></script>
49 </body>
50 </html>
frontend/js/api.js
+25 -5
@@ -1,4 +1,11 @@
1 -const API_BASE_URL = 'http://localhost:8080/api';
1 +const API_BASE_URL = (() => {
2 + const path = window.location.pathname;
3 + const sameOrigin = path === '/' || path === '/index.html' || path.startsWith('/api');
4 + if (sameOrigin) {
5 + return `${window.location.origin}/api`;
6 + }
7 + return 'http://localhost:8080/api';
8 +})();
9
10 const API = {
11 async request(endpoint, options = {}) {
@@ -13,13 +20,26 @@ const API = {
20 ...options
21 };
22
16 - const response = await fetch(url, config);
17 - const data = await response.json().catch(() => null);
23 +const response = await fetch(url, config);
24 + const body = await response.json().catch(() => null);
25
26 if (!response.ok) {
20 - throw new Error(data?.message || `Error ${response.status}`);
27 + if (response.status === 401 || response.status === 403) {
28 + localStorage.removeItem('token');
29 + localStorage.removeItem('user');
30 + const path = window.location.pathname;
31 + if (!path.endsWith('login.html') && !path.endsWith('index.html') && !path.endsWith('registro.html')) {
32 + const prefix = path.includes('/pages/') ? '../' : '';
33 + window.location.href = `${prefix}login.html`;
34 + }
35 + throw new Error(body?.message || 'Sesión expirada o sin autorización. Vuelve a iniciar sesión.');
36 + }
37 + const error = new Error(body?.message || `Error ${response.status}`);
38 + error.status = response.status;
39 + error.data = body && body.data !== undefined ? body.data : body;
40 + throw error;
41 }
42
23 - return data;
43 + return body && body.data !== undefined ? body.data : body;
44 }
45 };
frontend/js/auth.js
+43 -3
@@ -20,6 +20,22 @@ const Auth = {
20 return user ? JSON.parse(user) : null;
21 },
22
23 + /** Actualiza la foto del usuario en la sesión local (nuevaFoto null = eliminar). */
24 + foto(nuevaFoto) {
25 + const user = this.getUser();
26 + if (!user) return;
27 + user.foto = nuevaFoto || null;
28 + localStorage.setItem(this.USER_KEY, JSON.stringify(user));
29 + },
30 +
31 + /** Actualiza campos del usuario en la sesión local (p. ej. nombre). */
32 + actualizar(patch) {
33 + const user = this.getUser();
34 + if (!user) return;
35 + Object.assign(user, patch);
36 + localStorage.setItem(this.USER_KEY, JSON.stringify(user));
37 + },
38 +
39 isAuthenticated() {
40 return !!this.getToken();
41 },
@@ -39,11 +55,35 @@ const Auth = {
55 return true;
56 },
57
42 - /** Redirige al dashboard si ya hay sesión. Usar en login/registro. */
43 - redirectIfAuthenticated() {
44 - if (this.isAuthenticated()) {
58 + /** Valida la sesión contra el servidor y redirige al dashboard si es válida.
59 + * Usar en login/registro: evita el bucle con tokens viejos o expirados. */
60 + async redirectIfAuthenticated() {
61 + if (!this.isAuthenticated()) return false;
62 + try {
63 + await API.request('/auth/perfil-estado');
64 window.location.href = this.resolvePath('pages/inicio.html');
65 + return true;
66 + } catch (e) {
67 + // Token inválido o expirado: se limpia la sesión y se deja ver el login.
68 + localStorage.removeItem(this.TOKEN_KEY);
69 + localStorage.removeItem(this.USER_KEY);
70 + return false;
71 + }
72 + },
73 +
74 + /** Si el perfil está incompleto, lleva al usuario a completar onboarding. */
75 + async redirectIfPerfilIncompleto() {
76 + if (!this.isAuthenticated()) return false;
77 + try {
78 + const res = await API.request('/auth/perfil-estado');
79 + if (!res?.completado) {
80 + window.location.href = this.resolvePath('onboarding.html');
81 + return true;
82 + }
83 + } catch (e) {
84 + // Sin perfil consultable (p. ej. usuario demo) se permite navegar.
85 }
86 + return false;
87 },
88
89 resolvePath(relativePath) {
frontend/js/aviso-privacidad.js new
+73
@@ -0,0 +1,73 @@
1 +/**
2 + * MAPS Connect - Aviso de Privacidad y Términos y Condiciones
3 + * Renderiza un modal reutilizable con el contenido legal. Se abre con
4 + * AvisoPrivacidad.mostrar(). Se usa en login, registro y ajustes.
5 + */
6 +const AvisoPrivacidad = {
7 + mostrar(titulo = 'Aviso de Privacidad') {
8 + let root = document.getElementById('aviso-privacidad-root');
9 + if (!root) {
10 + root = document.createElement('div');
11 + root.id = 'aviso-privacidad-root';
12 + document.body.appendChild(root);
13 + }
14 +
15 + root.innerHTML = `
16 + <div class="aviso-modal-overlay" id="aviso-modal-overlay">
17 + <div class="aviso-modal" role="dialog" aria-modal="true" aria-label="${titulo}">
18 + <div class="aviso-modal-head">
19 + <h3>${titulo}</h3>
20 + <button type="button" class="aviso-modal-cerrar" aria-label="Cerrar">&times;</button>
21 + </div>
22 + <div class="aviso-modal-body">
23 + <h4>1. Responsable</h4>
24 + <p>MAPS Connect, comunidad académica del Tecnológico de Monterrey / Universidad Tecmilenio ("la Plataforma"), es responsable del tratamiento de los datos personales que nos proporcionas, conforme a la Ley Federal de Protección de Datos Personales en Posesión de los Particulares (México).</p>
25 +
26 + <h4>2. Datos que recabamos</h4>
27 + <p>Nombre(s), apellidos, matrícula o nómina, correo institucional, fotografía de perfil, semestre, carrera/campus, y la información que generes al usar la Plataforma (publicaciones, mensajes, círculos de estudio, asesorías y preferencias de la aplicación como el modo oscuro).</p>
28 +
29 + <h4>3. Finalidades del tratamiento</h4>
30 + <p>Los datos se utilizan para: (a) crear y administrar tu cuenta; (b) permitir la interacción entre la comunidad (foro, mensajes, círculos y asesorías); (c) mostrar tu perfil y reputación; (d) notificarte de eventos relevantes; y (e) mejorar el funcionamiento de la Plataforma.</p>
31 +
32 + <h4>4. Transferencia de datos</h4>
33 + <p>Tus datos no se transfieren a terceros ajenos a la Plataforma, salvo que la ley lo exija o que nos otorgues tu consentimiento expreso.</p>
34 +
35 + <h4>5. Derechos ARCO</h4>
36 + <p>Puedes ejercer tus derechos de Acceso, Rectificación, Cancelación y Oposición (ARCO) contactando al administrador a través de los medios institucionales o escribiendo a la cuenta de soporte de la Plataforma.</p>
37 +
38 + <h4>6. Seguridad</h4>
39 + <p>Implementamos medidas técnicas, administrativas y físicas para proteger tus datos personales contra daño, pérdida, alteración, destrucción, uso, acceso o tratamiento no autorizado.</p>
40 +
41 + <h4>7. Conservación</h4>
42 + <p>Tus datos se conservarán mientras tu cuenta esté activa o mientras se requiera para fines legales y de auditoría. Al dar de baja tu cuenta, los datos se eliminarán o se conservarán de forma anonimizada según lo dispuesto por la ley.</p>
43 +
44 + <h4>8. Cambios al aviso</h4>
45 + <p>Este aviso puede actualizarse. La versión vigente estará siempre disponible en esta página.</p>
46 +
47 + <hr>
48 +
49 + <h4>Términos y Condiciones</h4>
50 + <p>Al crear tu cuenta aceptas utilizar la Plataforma exclusivamente con fines académicos y de convivencia universitaria, respetar a los demás miembros de la comunidad, no difundir contenido ofensivo o ajeno a la institución, y mantener la confidencialidad de tu contraseña. El mal uso de la Plataforma puede resultar en la suspensión o baja de tu cuenta.</p>
51 + </div>
52 + <div class="aviso-modal-foot">
53 + <button type="button" class="btn btn-primary" id="aviso-modal-aceptar">Aceptar</button>
54 + </div>
55 + </div>
56 + </div>
57 + `;
58 +
59 + const cerrar = () => root.innerHTML = '';
60 + root.querySelector('.aviso-modal-cerrar')?.addEventListener('click', cerrar);
61 + root.querySelector('#aviso-modal-aceptar')?.addEventListener('click', cerrar);
62 + root.querySelector('#aviso-modal-overlay')?.addEventListener('click', e => {
63 + if (e.target.id === 'aviso-modal-overlay') cerrar();
64 + });
65 + },
66 +
67 + /** Abre el aviso y marca el checkbox de aceptación al confirmar. */
68 + desdeCheckbox(checkboxId) {
69 + this.mostrar();
70 + const cb = document.getElementById(checkboxId);
71 + if (cb) document.getElementById('aviso-modal-aceptar')?.addEventListener('click', () => { cb.checked = true; });
72 + }
73 +};
frontend/js/colores.js new
+115
@@ -0,0 +1,115 @@
1 +/**
2 + * MAPS Connect - Personalización del color de la página
3 + * Permite elegir un color de acento (preestablecido o a medida) que se
4 + * aplica en toda la aplicación (sidebar, topbar, botones, enlaces,
5 + * tarjetas, chat...). Guarda la preferencia en localStorage y la aplica
6 + * como atributo "data-color" + variables --accent-* sobre <html>;
7 + * frontend/css/colores.css se encarga de mapearlas a las variables
8 + * existentes del tema (claro y oscuro).
9 + */
10 +const Colores = {
11 + KEY: 'color-maps',
12 +
13 + PRESETS: [
14 + { id: 'verde', hex: '#087527', nombre: 'Verde' },
15 + { id: 'rosa', hex: '#e1367f', nombre: 'Rosa' },
16 + { id: 'azul', hex: '#0a6ab3', nombre: 'Azul' },
17 + { id: 'morado', hex: '#7c3aed', nombre: 'Morado' },
18 + { id: 'naranja', hex: '#ea580c', nombre: 'Naranja' },
19 + { id: 'teal', hex: '#0d9488', nombre: 'Teal' }
20 + ],
21 +
22 + guardado() {
23 + try { return localStorage.getItem(this.KEY); } catch (e) { return null; }
24 + },
25 +
26 + limpiar() {
27 + const r = document.documentElement;
28 + r.removeAttribute('data-color');
29 + const vars = ['--accent', '--accent-dark', '--accent-mid', '--accent-bright',
30 + '--accent-deep', '--accent-soft', '--accent-soft-border', '--accent-contrast'];
31 + for (const k of vars) r.style.removeProperty(k);
32 + try { localStorage.removeItem(this.KEY); } catch (e) { /* almacenamiento no disponible */ }
33 + },
34 +
35 + /* --- Utilidades de color (HSL) --- */
36 + _alHsl(hex) {
37 + hex = hex.replace('#', '');
38 + const r = parseInt(hex.slice(0, 2), 16) / 255;
39 + const g = parseInt(hex.slice(2, 4), 16) / 255;
40 + const b = parseInt(hex.slice(4, 6), 16) / 255;
41 + const max = Math.max(r, g, b), min = Math.min(r, g, b);
42 + let h = 0, s = 0;
43 + const l = (max + min) / 2;
44 + if (max !== min) {
45 + const d = max - min;
46 + s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
47 + switch (max) {
48 + case r: h = (g - b) / d + (g < b ? 6 : 0); break;
49 + case g: h = (b - r) / d + 2; break;
50 + default: h = (r - g) / d + 4;
51 + }
52 + h *= 60;
53 + }
54 + return { h, s, l };
55 + },
56 +
57 + _aHex({ h, s, l }) {
58 + h = ((h % 360) + 360) % 360;
59 + const c = (1 - Math.abs(2 * l - 1)) * s;
60 + const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
61 + const m = l - c / 2;
62 + let r = 0, g = 0, b = 0;
63 + if (h < 60) { r = c; g = x; }
64 + else if (h < 120) { r = x; g = c; }
65 + else if (h < 180) { g = c; b = x; }
66 + else if (h < 240) { g = x; b = c; }
67 + else if (h < 300) { r = x; b = c; }
68 + else { r = c; b = x; }
69 + const to = (v) => Math.round((v + m) * 255).toString(16).padStart(2, '0');
70 + return '#' + to(r) + to(g) + to(b);
71 + },
72 +
73 + _conLuz({ h, s, l }, nl) { return this._aHex({ h, s, l: nl }); },
74 +
75 + /* --- Aplicación --- */
76 +
77 + /** Aplica el color guardado; si no hay, restaura el color por defecto. */
78 + aplicar() {
79 + const hex = this.guardado();
80 + if (!hex) { this.limpiar(); return; }
81 + this.set(hex, false);
82 + },
83 +
84 + /** Aplica un color de acento y (por defecto) lo guarda como preferencia.
85 + * Calcula toda la paleta derivada (oscuros, claros, tintes) y la aplica
86 + * como variables CSS, de modo que toda la página se "sintoniza". */
87 + set(hex, guardar = true) {
88 + if (!/^#[0-9a-f]{6}$/i.test(hex)) return;
89 + const base = this._alHsl(hex);
90 + const clampL = (l) => Math.min(Math.max(l, 0), 1);
91 + const accentDark = clampL(Math.max(base.l * 0.62, 0.3));
92 + const midL = clampL(Math.max(Math.min(base.l + 0.14, 0.52), 0.38));
93 + const brightL = clampL(Math.max(Math.min(base.l + 0.3, 0.68), 0.5));
94 + const deepL = clampL(Math.max(Math.min(base.l * 0.55, 0.32), 0.16));
95 + const vars = {
96 + '--accent': hex,
97 + '--accent-soft': this._conLuz(base, 0.93),
98 + '--accent-soft-border': this._conLuz(base, 0.8),
99 + '--accent-dark': this._conLuz(base, accentDark),
100 + '--accent-mid': this._conLuz(base, midL),
101 + '--accent-bright': this._conLuz(base, brightL),
102 + '--accent-deep': this._conLuz(base, deepL),
103 + '--accent-contrast': base.l > 0.62 ? '#1f2937' : '#ffffff'
104 + };
105 + const r = document.documentElement;
106 + r.setAttribute('data-color', 'personalizado');
107 + for (const k in vars) r.style.setProperty(k, vars[k]);
108 + if (guardar) {
109 + try { localStorage.setItem(this.KEY, hex); } catch (e) { /* almacenamiento no disponible */ }
110 + }
111 + }
112 +};
113 +
114 +/* Se aplica en cuanto se carga el script, igual que Tema (evita parpadeo). */
115 +Colores.aplicar();
\ No newline at end of file
frontend/js/estilos.js new
+1021
@@ -0,0 +1,1021 @@
1 +/**
2 + * MAPS Connect - Estilos de la página
3 + * Permite elegir un estilo visual global (skin) que afecta a toda la
4 + * aplicación (sidebar, topbar, tarjetas, botones, formularios, fondo...).
5 + * Guarda la preferencia en localStorage y la aplica como atributo
6 + * "data-estilo" sobre <html>; frontend/css/estilos.css define los skins
7 + * bajo html[data-estilo="..."].
8 + *
9 + * Para añadir un estilo nuevo solo hay que:
10 + * 1) Agregar su entrada en ESTILOS (id, nombre, descripcion, preview).
11 + * 2) Escribir su bloque CSS en estilos.css bajo html[data-estilo="id"].
12 + * 3) Si el estilo inyecta su propio fondo (como "cyber"), añadir la
13 + * lógica de inyección en _ponFondo/_limpiarFondo.
14 + */
15 +const Estilos = {
16 + KEY: 'estilo-maps',
17 +
18 + STYLES: [
19 + {
20 + id: 'clasico',
21 + nombre: 'Clásico',
22 + descripcion: 'Estilo predeterminado de MAPS Connect con la paleta institucional.',
23 + preview: 'linear-gradient(135deg, #087527 0%, #3fb950 60%, #a3e635 100%)'
24 + },
25 + {
26 + id: 'cyber',
27 + nombre: 'Cyberpunk Network',
28 + descripcion: 'Red neuronal de neón cian y magenta sobre fondo de terminal oscura.',
29 + preview: 'linear-gradient(135deg, #00f0ff 0%, #1a0033 50%, #ff007f 100%)'
30 + },
31 + {
32 + id: 'medieval',
33 + nombre: 'Hogwarts Medieval',
34 + descripcion: 'Oro y pergamino sobre la fortaleza mágica (experiencia 3D tipo Hogwarts).',
35 + preview: 'linear-gradient(135deg, #d4af37 0%, #18110b 50%, #5a1414 100%)'
36 + },
37 + {
38 + id: 'saiyan',
39 + nombre: 'Saiyan Power Edition',
40 + descripcion: 'Nube Kinto, estelas de ki y aura dorada sobre el dojo de los Z-Warriors (naranja intenso).',
41 + preview: 'linear-gradient(135deg, #f97316 0%, #1e3a8a 50%, #facc15 100%)'
42 + },
43 + {
44 + id: 'noir',
45 + nombre: 'Cyber Noir Edition',
46 + descripcion: 'Callejón nocturno con neón rojo, lluvia digital y expedientes confidenciales (gótico cyber).',
47 + preview: 'linear-gradient(135deg, #e11d48 0%, #080305 50%, #3a0011 100%)'
48 + },
49 + {
50 + id: 'alchemy',
51 + nombre: 'Alchemical Codex Edition',
52 + descripcion: 'Astrolabio, geometría sagrada y oro de filósofo sobre la cámara hermetista (esmeralda).',
53 + preview: 'linear-gradient(135deg, #10b981 0%, #020d08 50%, #fbbf24 100%)'
54 + },
55 + {
56 + id: 'minimal',
57 + nombre: 'Minimalist Tricolor Edition',
58 + descripcion: 'Geometría suiza azul, rojo y blanco con retícula fina y parallax pausado (estilo internacional).',
59 + preview: 'linear-gradient(135deg, #2563eb 0%, #f8fafc 50%, #dc2626 100%)'
60 + },
61 + {
62 + id: 'invernadero',
63 + nombre: 'Invernadero',
64 + descripcion: 'Cubierta vegetal, foliaje mecido y luz tamizada sobre una experiencia académica que crece (verde institucional).',
65 + preview: 'linear-gradient(135deg, #3E6B4F 0%, #E7EEDA 50%, #C97B4A 100%)'
66 + }
67 + ],
68 +
69 + _VARIABLES_ACCENTO: ['--accent', '--accent-dark', '--accent-mid', '--accent-bright',
70 + '--accent-deep', '--accent-soft', '--accent-soft-border', '--accent-contrast'],
71 +
72 + /** Textos personalizados por skin (botones, enlaces y placeholders).
73 + Las claves son el texto original exacto y los valores el texto temático. */
74 + TEXTOS_ESTILO: {
75 + cyber: {},
76 + medieval: {
77 + 'Publicar duda': 'Enviar al Palacio',
78 + 'Publicar Duda': 'Enviar al Palacio',
79 + 'Publicar': 'Enviar al Palacio',
80 + 'Publicar de todos modos': 'Enviar de todos modos',
81 + 'Cerrar sesión': 'Abandonar el Castillo',
82 + 'Guardar': 'Guardar en el Pergamino',
83 + 'Enviar Mensaje Privado': 'Enviar por Búho',
84 + '+ Crear sesión de repaso': '+ Convocar Círculo de Estudio',
85 + 'Crear sesión': 'Convocar Estudio',
86 + 'Unirme': 'Unirme al Círculo',
87 + '¿Tienes una duda académica? Pregunta a tu comunidad...': 'Lanza tu pregón académico a la Comunidad del Castillo...'
88 + },
89 + saiyan: {
90 + 'Publicar duda': 'Lanzar Ataque Ki',
91 + 'Publicar Duda': 'Lanzar Ataque Ki',
92 + 'Publicar': 'Lanzar Ataque Ki',
93 + 'Publicar de todos modos': 'Lanzar de todos modos',
94 + 'Cerrar sesión': 'Descansar / Salir',
95 + 'Guardar': 'Guardar Energía',
96 + 'Enviar Mensaje Privado': 'Telepatía Instantánea',
97 + '+ Crear sesión de repaso': '+ Crear Sala de Entrenamiento',
98 + 'Crear sesión': 'Crear Entrenamiento',
99 + 'Unirme': 'Entrar al Dojo',
100 + '¿Tienes una duda académica? Pregunta a tu comunidad...': 'Lanza tu duda o reto técnico al campo de entrenamiento...'
101 + },
102 + noir: {
103 + 'Publicar duda': 'Registrar Pista',
104 + 'Publicar Duda': 'Registrar Pista',
105 + 'Publicar': 'Registrar Pista',
106 + 'Publicar de todos modos': 'Registrar de todos modos',
107 + 'Cerrar sesión': 'Cerrar Expediente',
108 + 'Guardar': 'Sellar en Archivo',
109 + 'Enviar Mensaje Privado': 'Enviar Nota Cifrada',
110 + '+ Crear sesión de repaso': '+ Convocar Ronda de Estudio',
111 + 'Crear sesión': 'Convocar Ronda',
112 + 'Unirme': 'Entrar a la Ronda',
113 + '¿Tienes una duda académica? Pregunta a tu comunidad...': 'Inscribe una pista, duda o cabo suelto al expediente...'
114 + },
115 + alchemy: {
116 + 'Publicar duda': 'Liberar Sello',
117 + 'Publicar Duda': 'Liberar Sello',
118 + 'Publicar': 'Liberar Sello',
119 + 'Publicar de todos modos': 'Liberar de todos modos',
120 + 'Cerrar sesión': 'Cerrar Grimorio / Salir',
121 + 'Guardar': 'Sellar en Papiro',
122 + 'Enviar Mensaje Privado': 'Enviar Sello Privado',
123 + '+ Crear sesión de repaso': '+ Convocar Círculo Esotérico',
124 + 'Crear sesión': 'Convocar Círculo',
125 + 'Unirme': 'Unirme al Círculo',
126 + '¿Tienes una duda académica? Pregunta a tu comunidad...': 'Inscribe una consulta o runa técnica al Códice...'
127 + },
128 + minimal: {
129 + 'Publicar duda': 'Enviar Solicitud',
130 + 'Publicar Duda': 'Enviar Solicitud',
131 + 'Publicar': 'Enviar Solicitud',
132 + 'Publicar de todos modos': 'Enviar de todos modos',
133 + 'Cerrar sesión': 'Cerrar Sesión',
134 + 'Guardar': 'Guardar Cambios',
135 + 'Enviar Mensaje Privado': 'Enviar Mensaje',
136 + '+ Crear sesión de repaso': '+ Programar Sesión',
137 + 'Crear sesión': 'Programar Sesión',
138 + 'Unirme': 'Unirme a la Sesión',
139 + '¿Tienes una duda académica? Pregunta a tu comunidad...': 'Registra tu duda académica en el sistema...'
140 + },
141 + invernadero: {
142 + 'Publicar duda': 'Plantar la consulta',
143 + 'Publicar Duda': 'Plantar la consulta',
144 + 'Publicar': 'Plantar la consulta',
145 + 'Publicar de todos modos': 'Plantar de todos modos',
146 + 'Cerrar sesión': 'Regar y cerrar sesión',
147 + 'Guardar': 'Guardar en el semillero',
148 + 'Enviar Mensaje Privado': 'Enviar rama privada',
149 + '+ Crear sesión de repaso': '+ Crear sesión de cultivo',
150 + 'Crear sesión': 'Crear sesión de cultivo',
151 + 'Unirme': 'Unirme al cultivo',
152 + '¿Tienes una duda académica? Pregunta a tu comunidad...': 'Siembra una duda o nota para tu comunidad...'
153 + }
154 + },
155 +
156 + guardado() {
157 + try { return localStorage.getItem(this.KEY) || 'clasico'; } catch (e) { return 'clasico'; }
158 + },
159 +
160 + /** Estilo activo en este momento (sin consultar storage). */
161 + activo() {
162 + return document.documentElement.getAttribute('data-estilo') || 'clasico';
163 + },
164 +
165 + /* ------------------------------------------------------------
166 + Fondos de estilo (elementos inyectados al final de <body>)
167 + ------------------------------------------------------------ */
168 +
169 + _cyberSVG() {
170 + return `
171 + <svg class="cyber-svg-layer" viewBox="0 0 1400 900" preserveAspectRatio="none" aria-hidden="true">
172 + <rect width="1400" height="900" fill="#010204"/>
173 + <g class="data-link">
174 + <line x1="200" y1="200" x2="500" y2="350"/>
175 + <line x1="500" y1="350" x2="700" y2="200"/>
176 + <line x1="700" y1="200" x2="1000" y2="300"/>
177 + <line x1="1000" y1="300" x2="1200" y2="600"/>
178 + <line x1="500" y1="350" x2="600" y2="650"/>
179 + <line x1="600" y1="650" x2="300" y2="750"/>
180 + <line x1="300" y1="750" x2="200" y2="200"/>
181 + <line x1="700" y1="200" x2="850" y2="500"/>
182 + <line x1="850" y1="500" x2="1200" y2="600"/>
183 + <line x1="500" y1="350" x2="850" y2="500"/>
184 + </g>
185 + <circle class="data-node" cx="200" cy="200" r="6"/>
186 + <circle class="data-node" cx="1000" cy="300" r="5"/>
187 + <circle class="data-node" cx="600" cy="650" r="6"/>
188 + <circle class="data-node" cx="300" cy="750" r="5"/>
189 + <circle class="data-node" cx="1200" cy="600" r="7"/>
190 + <circle class="core-node" cx="700" cy="350" r="16"/>
191 + <circle class="data-node" cx="500" cy="350" r="8"/>
192 + <circle class="data-node" cx="700" cy="200" r="8"/>
193 + <circle class="data-node" cx="850" cy="500" r="9"/>
194 + </svg>`;
195 + },
196 +
197 + /** Mapa de página -> posición de cámara (pan/zoom) sobre el skyline del
198 + castillo. dx/dy en píxeles sobre la capa de 2600x1100 y escala. */
199 + _castilloZonas() {
200 + return {
201 + 'inicio.html': { dx: 0, dy: 40, scale: 0.62 },
202 + 'perfil.html': { dx: 1000, dy: -60, scale: 1.15 },
203 + 'certificados.html': { dx: 650, dy: -40, scale: 1.1 },
204 + 'comunidad.html': { dx: 300, dy: -20, scale: 1.05 },
205 + 'mensajes.html': { dx: -300, dy: -30, scale: 1.15 },
206 + 'empresarial.html': { dx: -650, dy: -10, scale: 1.05 },
207 + 'circulos.html': { dx: -950, dy: -30, scale: 1.1 },
208 + 'ajustes.html': { dx: -1200, dy: -20, scale: 1.2 },
209 + 'admin.html': { dx: 0, dy: 60, scale: 1.25 },
210 + 'docente.html': { dx: 0, dy: 40, scale: 1.15 }
211 + };
212 + },
213 +
214 + /** Skyline panorámico del castillo (2600x1100) con zonas por sección. */
215 + _castleSkyline() {
216 + return `
217 + <svg class="castle-layer" id="castleLayer" viewBox="0 0 2600 1100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
218 + <defs>
219 + <linearGradient id="skyGrad" x1="0" y1="0" x2="0" y2="1">
220 + <stop offset="0%" stop-color="#1C0F0B"/>
221 + <stop offset="100%" stop-color="#0E0806"/>
222 + </linearGradient>
223 + <linearGradient id="stoneGrad" x1="0" y1="0" x2="0" y2="1">
224 + <stop offset="0%" stop-color="#4A2E24"/>
225 + <stop offset="100%" stop-color="#2A1A15"/>
226 + </linearGradient>
227 + </defs>
228 +
229 + <rect width="2600" height="1100" fill="url(#skyGrad)"/>
230 + <circle cx="1300" cy="180" r="90" fill="#3A2C1E" opacity="0.5"/>
231 +
232 + <g fill="#180E0A" opacity="0.7">
233 + <polygon points="0,500 200,380 400,500 600,410 800,500 1000,430 1200,500 1400,420 1600,500 1800,440 2000,500 2200,410 2400,500 2600,440 2600,700 0,700"/>
234 + </g>
235 +
236 + <g id="zone-perfil">
237 + <rect x="220" y="480" width="160" height="420" fill="url(#stoneGrad)"/>
238 + <polygon points="200,480 300,380 400,480" fill="#3E1717"/>
239 + <rect x="270" y="560" width="60" height="90" fill="#0E0806"/>
240 + <rect x="245" y="700" width="30" height="50" fill="#0E0806"/>
241 + <rect x="325" y="700" width="30" height="50" fill="#0E0806"/>
242 + <rect x="290" y="360" width="20" height="40" fill="#A9834B"/>
243 + <polygon points="290,360 300,335 310,360" fill="#A9834B"/>
244 + </g>
245 +
246 + <g id="zone-ruta">
247 + <rect x="560" y="440" width="180" height="460" fill="url(#stoneGrad)"/>
248 + <polygon points="540,440 650,320 760,440" fill="#3E1717"/>
249 + <circle cx="650" cy="520" r="34" fill="#0E0806"/>
250 + <circle cx="650" cy="520" r="24" fill="#A9834B" opacity="0.3"/>
251 + <rect x="600" y="720" width="30" height="55" fill="#0E0806"/>
252 + <rect x="670" y="720" width="30" height="55" fill="#0E0806"/>
253 + <rect x="640" y="300" width="20" height="35" fill="#A9834B"/>
254 + <polygon points="640,300 650,278 660,300" fill="#A9834B"/>
255 + <path d="M615,470 L685,470 M650,470 L650,510" stroke="#A9834B" stroke-width="2" opacity="0.4"/>
256 + </g>
257 +
258 + <g id="zone-comunidad">
259 + <rect x="900" y="560" width="420" height="340" fill="url(#stoneGrad)"/>
260 + <polygon points="880,560 1110,470 1340,560" fill="#3E1717"/>
261 + <circle cx="960" cy="640" r="26" fill="#0E0806"/>
262 + <circle cx="1040" cy="640" r="26" fill="#0E0806"/>
263 + <circle cx="1120" cy="640" r="26" fill="#0E0806"/>
264 + <circle cx="1200" cy="640" r="26" fill="#0E0806"/>
265 + <circle cx="1280" cy="640" r="26" fill="#0E0806"/>
266 + <rect x="950" y="760" width="45" height="60" fill="#0E0806"/>
267 + <rect x="1240" y="760" width="45" height="60" fill="#0E0806"/>
268 + </g>
269 +
270 + <g id="zone-inicio">
271 + <rect x="1180" y="330" width="240" height="570" fill="url(#stoneGrad)"/>
272 + <polygon points="1150,330 1300,180 1450,330" fill="#3E1717"/>
273 + <rect x="1270" y="150" width="60" height="60" fill="#3E1717"/>
274 + <polygon points="1270,150 1300,110 1330,150" fill="#5C2323"/>
275 + <rect x="1290" y="90" width="10" height="40" fill="#A9834B"/>
276 + <polygon points="1300,90 1330,105 1300,110" fill="#5C2323"/>
277 + <circle cx="1300" cy="430" r="46" fill="#0E0806"/>
278 + <circle cx="1300" cy="430" r="32" fill="#A9834B" opacity="0.25"/>
279 + <rect x="1225" y="560" width="60" height="90" fill="#0E0806"/>
280 + <rect x="1315" y="560" width="60" height="90" fill="#0E0806"/>
281 + <rect x="1250" y="760" width="100" height="140" fill="#0E0806"/>
282 + <polygon points="1250,760 1300,715 1350,760" fill="#180E0A"/>
283 + <rect x="900" y="850" width="800" height="30" fill="#241512"/>
284 + <g fill="#241512">
285 + <rect x="900" y="826" width="20" height="24"/><rect x="940" y="826" width="20" height="24"/>
286 + <rect x="980" y="826" width="20" height="24"/><rect x="1020" y="826" width="20" height="24"/>
287 + <rect x="1060" y="826" width="20" height="24"/><rect x="1100" y="826" width="20" height="24"/>
288 + <rect x="1140" y="826" width="20" height="24"/><rect x="1180" y="826" width="20" height="24"/>
289 + <rect x="1220" y="826" width="20" height="24"/><rect x="1260" y="826" width="20" height="24"/>
290 + <rect x="1300" y="826" width="20" height="24"/><rect x="1340" y="826" width="20" height="24"/>
291 + <rect x="1380" y="826" width="20" height="24"/><rect x="1420" y="826" width="20" height="24"/>
292 + <rect x="1460" y="826" width="20" height="24"/><rect x="1500" y="826" width="20" height="24"/>
293 + <rect x="1540" y="826" width="20" height="24"/><rect x="1580" y="826" width="20" height="24"/>
294 + <rect x="1620" y="826" width="20" height="24"/><rect x="1660" y="826" width="20" height="24"/>
295 + </g>
296 + </g>
297 +
298 + <g id="zone-mensajes">
299 + <rect x="1700" y="470" width="150" height="430" fill="url(#stoneGrad)"/>
300 + <polygon points="1680,470 1775,370 1870,470" fill="#3E1717"/>
301 + <rect x="1745" y="540" width="24" height="70" fill="#0E0806"/>
302 + <rect x="1785" y="540" width="24" height="70" fill="#0E0806"/>
303 + <rect x="1730" y="700" width="30" height="50" fill="#0E0806"/>
304 + <rect x="1795" y="700" width="30" height="50" fill="#0E0806"/>
305 + <path d="M1700,500 L1660,470 M1870,500 L1910,470" stroke="#A9834B" stroke-width="2" opacity="0.5"/>
306 + <ellipse cx="1660" cy="465" rx="10" ry="6" fill="#A9834B" opacity="0.5"/>
307 + <ellipse cx="1910" cy="465" rx="10" ry="6" fill="#A9834B" opacity="0.5"/>
308 + </g>
309 +
310 + <g id="zone-empresarial">
311 + <rect x="1970" y="600" width="130" height="300" fill="url(#stoneGrad)"/>
312 + <rect x="2110" y="560" width="130" height="340" fill="url(#stoneGrad)"/>
313 + <rect x="2250" y="600" width="130" height="300" fill="url(#stoneGrad)"/>
314 + <polygon points="1960,600 2035,545 2110,600" fill="#3E1717"/>
315 + <polygon points="2100,560 2175,500 2250,560" fill="#5C2323"/>
316 + <polygon points="2240,600 2315,545 2390,600" fill="#3E1717"/>
317 + <rect x="2000" y="700" width="30" height="45" fill="#0E0806"/>
318 + <rect x="2150" y="680" width="30" height="45" fill="#0E0806"/>
319 + <rect x="2300" y="700" width="30" height="45" fill="#0E0806"/>
320 + <rect x="1990" y="900" width="420" height="14" fill="#A9834B" opacity="0.35"/>
321 + </g>
322 +
323 + <g id="zone-circulos">
324 + <rect x="2280" y="500" width="220" height="400" fill="url(#stoneGrad)"/>
325 + <circle cx="2390" cy="480" r="120" fill="#3E1717"/>
326 + <circle cx="2390" cy="480" r="90" fill="#2A1512"/>
327 + <circle cx="2390" cy="480" r="16" fill="#A9834B" opacity="0.4"/>
328 + <rect x="2330" y="720" width="30" height="55" fill="#0E0806"/>
329 + <rect x="2420" y="720" width="30" height="55" fill="#0E0806"/>
330 + </g>
331 +
332 + <g id="zone-ajustes">
333 + <rect x="2470" y="560" width="110" height="340" fill="url(#stoneGrad)"/>
334 + <polygon points="2455,560 2525,480 2595,560" fill="#3E1717"/>
335 + <rect x="2500" y="620" width="50" height="60" fill="#0E0806"/>
336 + <rect x="2495" y="750" width="26" height="40" fill="#0E0806"/>
337 + <rect x="2534" y="750" width="26" height="40" fill="#0E0806"/>
338 + <rect x="2515" y="450" width="18" height="34" fill="#A9834B"/>
339 + <polygon points="2515,450 2524,430 2533,450" fill="#A9834B"/>
340 + </g>
341 +
342 + <g fill="#A9834B" opacity="0.4">
343 + <circle cx="150" cy="90" r="2"/><circle cx="480" cy="60" r="2"/>
344 + <circle cx="900" cy="100" r="2"/><circle cx="1700" cy="70" r="2"/>
345 + <circle cx="2100" cy="110" r="2"/><circle cx="2450" cy="80" r="2"/>
346 + </g>
347 + </svg>`;
348 + },
349 +
350 + /** Posiciona la cámara del castillo según la sección activa del menú. */
351 + _panCastilloZona() {
352 + const layer = document.querySelector('.castle-layer');
353 + if (!layer) return;
354 + const activo = document.querySelector('#sidebar .sidebar-nav a.active, .sidebar-nav a.active');
355 + const href = activo ? activo.getAttribute('href') : 'inicio.html';
356 + const zona = (this._castilloZonas()[href] || this._castilloZonas()['inicio.html']);
357 + layer.style.transform = `translate(${zona.dx}px, ${zona.dy}px) scale(${zona.scale})`;
358 + },
359 +
360 + /** Re-encuadra la cámara del castillo cuando el menú queda listo, al
361 + cambiar de skin y al navegar (la página activa determina la zona). */
362 + _bindCastilloPan() {
363 + if (this._castlePanBinded) {
364 + this._panCastilloZona();
365 + return;
366 + }
367 + this._castlePanBinded = true;
368 + document.addEventListener('estilos:cambio', () => this._panCastilloZona());
369 + if (document.readyState === 'loading') {
370 + document.addEventListener('DOMContentLoaded', () => this._panCastilloZona(), { once: true });
371 + } else {
372 + this._panCastilloZona();
373 + }
374 + },
375 +
376 + _saiyanSVG() {
377 + return `
378 + <svg class="saiyan-svg-layer" viewBox="0 0 1400 900" preserveAspectRatio="none" aria-hidden="true">
379 + <rect width="1400" height="900" fill="#f97316"/>
380 +
381 + <path class="kintoun-cloud" d="M300,500 Q350,420 450,450 Q520,380 620,430 Q700,390 780,460 Q880,440 920,520 Q1000,530 980,620 Q900,680 300,680 Z"/>
382 +
383 + <path class="ki-energy-trail" d="M100,100 L1300,800"/>
384 + <path class="ki-energy-trail" d="M200,800 L1200,100"/>
385 + <path class="ki-energy-trail" d="M0,450 L1400,450"/>
386 + </svg>`;
387 + },
388 +
389 + _noirSVG() {
390 + return `
391 + <svg class="noir-svg-layer" viewBox="0 0 1400 900" preserveAspectRatio="none" aria-hidden="true">
392 + <rect width="1400" height="900" fill="#080305"/>
393 + <path d="M0,700 L250,500 L450,550 L750,380 L1050,480 L1400,400 L1400,900 L0,900 Z" fill="#0d0407"/>
394 + <path class="neon-sign" d="M300,320 L350,280 L400,320 L350,360 Z"/>
395 + <path class="neon-sign" d="M950,250 L1020,250 L1020,310 L950,310 Z"/>
396 + <g class="rain-streak">
397 + <line x1="100" y1="0" x2="60" y2="150"/>
398 + <line x1="300" y1="0" x2="260" y2="150"/>
399 + <line x1="500" y1="0" x2="460" y2="150"/>
400 + <line x1="700" y1="0" x2="660" y2="150"/>
401 + <line x1="900" y1="0" x2="860" y2="150"/>
402 + <line x1="1100" y1="0" x2="1060" y2="150"/>
403 + <line x1="1300" y1="0" x2="1260" y2="150"/>
404 + </g>
405 + </svg>`;
406 + },
407 +
408 + _alchemySVG() {
409 + return `
410 + <svg class="alchemy-svg-layer" viewBox="0 0 1400 900" preserveAspectRatio="none" aria-hidden="true">
411 + <rect width="1400" height="900" fill="#020d08"/>
412 + <g transform="translate(700, 450)">
413 + <circle class="sacred-geometry" cx="0" cy="0" r="350" />
414 + <circle class="sacred-geometry" cx="0" cy="0" r="220" />
415 + <circle class="sacred-geometry" cx="0" cy="0" r="100" />
416 + <polygon class="sacred-geometry" points="0,-350 303,175 -303,175" />
417 + <polygon class="sacred-geometry" points="0,350 -303,-175 303,-175" />
418 + <circle class="alchemical-ring" cx="0" cy="0" r="280" />
419 + </g>
420 + </svg>`;
421 + },
422 +
423 + _minimalSVG() {
424 + return `
425 + <svg class="minimal-svg-layer" viewBox="0 0 1400 900" preserveAspectRatio="none" aria-hidden="true">
426 + <rect width="1400" height="900" fill="#f8fafc"/>
427 + <rect class="accent-block-blue" x="80" y="60" width="420" height="300" />
428 + <rect class="accent-block-red" x="940" y="420" width="360" height="330" />
429 + <g class="geo-line">
430 + <line x1="0" y1="200" x2="1400" y2="200" />
431 + <line x1="0" y1="600" x2="1400" y2="600" />
432 + <line x1="340" y1="0" x2="340" y2="900" />
433 + <line x1="1060" y1="0" x2="1060" y2="900" />
434 + </g>
435 + </svg>`;
436 + },
437 +
438 + /** Capas de follaje del invernadero: el dosel de hojas se divide en tres
439 + profundidades (atrás / medio / frente) que se mecen con el viento. */
440 + _greenhouseSVG() {
441 + return `
442 + <div class="greenhouse-canopy">
443 + <svg class="canopy-svg canopy-layer-back leaf-sway" viewBox="0 0 1400 900" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
444 + <g fill="#A9C29B">
445 + <ellipse cx="90" cy="30" rx="60" ry="34" transform="rotate(30 90 30)"/>
446 + <ellipse cx="180" cy="10" rx="50" ry="28" transform="rotate(-15 180 10)"/>
447 + <ellipse cx="1300" cy="20" rx="65" ry="36" transform="rotate(-25 1300 20)"/>
448 + <ellipse cx="1220" cy="50" rx="45" ry="26" transform="rotate(20 1220 50)"/>
449 + <ellipse cx="70" cy="870" rx="70" ry="38" transform="rotate(-20 70 870)"/>
450 + <ellipse cx="1330" cy="860" rx="60" ry="34" transform="rotate(15 1330 860)"/>
451 + </g>
452 + </svg>
453 + <svg class="canopy-svg canopy-layer-mid leaf-sway-rev" viewBox="0 0 1400 900" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
454 + <g stroke="#3E6B4F" stroke-width="3" fill="none" stroke-linecap="round">
455 + <path d="M0,0 C40,60 10,140 60,200 C110,260 70,340 120,400"/>
456 + <path d="M1400,0 C1360,70 1390,150 1330,210 C1270,270 1310,350 1250,410"/>
457 + <path d="M0,900 C50,830 20,750 70,690 C120,630 80,560 130,500"/>
458 + <path d="M1400,900 C1350,830 1380,750 1320,690 C1260,630 1300,560 1250,500"/>
459 + </g>
460 + <g fill="#5E8A63">
461 + <ellipse cx="60" cy="200" rx="20" ry="11" transform="rotate(35 60 200)"/>
462 + <ellipse cx="120" cy="400" rx="18" ry="10" transform="rotate(-20 120 400)"/>
463 + <ellipse cx="1330" cy="210" rx="20" ry="11" transform="rotate(-35 1330 210)"/>
464 + <ellipse cx="1250" cy="410" rx="18" ry="10" transform="rotate(20 1250 410)"/>
465 + <ellipse cx="70" cy="690" rx="19" ry="10" transform="rotate(-30 70 690)"/>
466 + <ellipse cx="130" cy="500" rx="17" ry="9" transform="rotate(25 130 500)"/>
467 + <ellipse cx="1320" cy="690" rx="19" ry="10" transform="rotate(30 1320 690)"/>
468 + <ellipse cx="1250" cy="500" rx="17" ry="9" transform="rotate(-25 1250 500)"/>
469 + </g>
470 + </svg>
471 + <svg class="canopy-svg canopy-layer-front leaf-drift" viewBox="0 0 1400 900" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
472 + <g fill="#3E6B4F">
473 + <ellipse cx="30" cy="0" rx="90" ry="55" transform="rotate(20 30 0)"/>
474 + <ellipse cx="1370" cy="0" rx="95" ry="58" transform="rotate(-18 1370 0)"/>
475 + <ellipse cx="10" cy="900" rx="85" ry="50" transform="rotate(-22 10 900)"/>
476 + <ellipse cx="1390" cy="900" rx="88" ry="52" transform="rotate(18 1390 900)"/>
477 + </g>
478 + <g fill="#2F5340" opacity="0.5">
479 + <circle cx="120" cy="70" r="6"/>
480 + <circle cx="1280" cy="80" r="5"/>
481 + <circle cx="90" cy="830" r="5"/>
482 + <circle cx="1310" cy="820" r="6"/>
483 + </g>
484 + </svg>
485 + </div>
486 + <div class="light-dapple"></div>`;
487 + },
488 +
489 + _limpiarFondo() {
490 + document.querySelectorAll('.cyber-bg-viewport, .medieval-bg-viewport, .saiyan-bg-viewport, .noir-bg-viewport, .alchemy-bg-viewport, .minimal-bg-viewport, .invernadero-bg-viewport, .greenhouse-canopy, .light-dapple, .atmosphere-overlay, .castle-vignette, .cyber-blade-transition, .med-blade-transition, .saiyan-aura-transition, .noir-blade-transition, .alchemy-blade-transition, .minimal-blade-transition, .invernadero-leaf-transition, .heraldic-transition').forEach(el => el.remove());
491 + },
492 +
493 + _limpiarAccentoInline() {
494 + const r = document.documentElement;
495 + for (const k of this._VARIABLES_ACCENTO) r.style.removeProperty(k);
496 + },
497 +
498 + /** Carga las fuentes decorativas (Cinzel/Bangers/Montserrat) cuando el
499 + skin activo las usa; evita cargarlas en skins que no las necesitan. */
500 + _cargarFuentes() {
501 + if (this._fuentesCargadas) return;
502 + this._fuentesCargadas = true;
503 + const id = 'estilos-fuentes';
504 + if (document.getElementById(id)) return;
505 + const link = document.createElement('link');
506 + link.id = id;
507 + link.rel = 'stylesheet';
508 + link.href = 'https://fonts.googleapis.com/css2?family=Bangers&family=Cinzel:wght@400;700;900&family=EB+Garamond:ital,wght@0,400;0,500;0,600;1,400&family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600;700;800&family=Montserrat:wght@500;600;700;900&family=Share+Tech+Mono&family=Special+Elite&family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Work+Sans:wght@400;500;600;700&display=swap';
509 + document.head.appendChild(link);
510 + },
511 +
512 + _ponFondo() {
513 + const estilo = this.activo();
514 + if (estilo === 'medieval') return this._ponFondoMedieval();
515 + if (estilo === 'saiyan') return this._ponFondoSaiyan();
516 + if (estilo === 'noir') return this._ponFondoNoir();
517 + if (estilo === 'alchemy') return this._ponFondoAlchemy();
518 + if (estilo === 'minimal') return this._ponFondoMinimal();
519 + if (estilo === 'invernadero') return this._ponFondoInvernadero();
520 + return this._ponFondoCyber();
521 + },
522 +
523 + _ponFondoCyber() {
524 + if (document.querySelector('.cyber-bg-viewport')) return;
525 + const inyectar = () => {
526 + const viewport = document.createElement('div');
527 + viewport.className = 'cyber-bg-viewport';
528 + viewport.innerHTML = this._cyberSVG();
529 +
530 + const atmosfera = document.createElement('div');
531 + atmosfera.className = 'atmosphere-overlay';
532 +
533 + // Cortina de transición "acordeón neural" al navegar (mockup).
534 + const blade = document.createElement('div');
535 + blade.className = 'cyber-blade-transition';
536 + blade.id = 'accordionBlade';
537 + blade.innerHTML = `
538 + <div class="accordion-panel"></div>
539 + <div class="accordion-panel"></div>
540 + <div class="accordion-panel"></div>
541 + <div class="accordion-panel"></div>`;
542 +
543 + document.body.insertBefore(viewport, document.body.firstChild);
544 + document.body.insertBefore(atmosfera, document.body.firstChild);
545 + document.body.insertBefore(blade, document.body.firstChild);
546 + this._registrarParallax(viewport.querySelector('.cyber-svg-layer'), 'cyber');
547 + this._bindBlade();
548 + };
549 +
550 + if (document.body) {
551 + inyectar();
552 + } else {
553 + // El script corre en <head> (anti-FOUC): el fondo se inyecta al
554 + // terminar de cargar el DOM, y de paso se vuelve a liberar el
555 + // acento por si colores.js aplicó sus variables inline.
556 + document.addEventListener('DOMContentLoaded', () => {
557 + this._limpiarAccentoInline();
558 + inyectar();
559 + }, { once: true });
560 + }
561 + },
562 +
563 + _ponFondoMedieval() {
564 + if (document.querySelector('.medieval-bg-viewport')) return;
565 + this._cargarFuentes();
566 + const inyectar = () => {
567 + const viewport = document.createElement('div');
568 + viewport.className = 'medieval-bg-viewport';
569 + viewport.innerHTML = this._castleSkyline();
570 +
571 + // Viñeta nocturna del castillo.
572 + const vineta = document.createElement('div');
573 + vineta.className = 'castle-vignette';
574 +
575 + // Cortina heráldica "sello del castillo" al navegar (mockup El Castillo).
576 + const heraldico = document.createElement('div');
577 + heraldico.className = 'heraldic-transition';
578 + heraldico.id = 'heraldicTransition';
579 +
580 + document.body.insertBefore(viewport, document.body.firstChild);
581 + document.body.insertBefore(vineta, document.body.firstChild);
582 + document.body.insertBefore(heraldico, document.body.firstChild);
583 + this._bindCastilloPan();
584 + this._bindBlade();
585 + };
586 +
587 + if (document.body) {
588 + inyectar();
589 + } else {
590 + document.addEventListener('DOMContentLoaded', () => {
591 + this._limpiarAccentoInline();
592 + inyectar();
593 + }, { once: true });
594 + }
595 + },
596 +
597 + _ponFondoSaiyan() {
598 + if (document.querySelector('.saiyan-bg-viewport')) return;
599 + this._cargarFuentes();
600 + const inyectar = () => {
601 + const viewport = document.createElement('div');
602 + viewport.className = 'saiyan-bg-viewport';
603 + viewport.innerHTML = this._saiyanSVG();
604 +
605 + const atmosfera = document.createElement('div');
606 + atmosfera.className = 'atmosphere-overlay';
607 +
608 + // Cortina de transición "aura de ki" al navegar (mockup Saiyan).
609 + const blade = document.createElement('div');
610 + blade.className = 'saiyan-aura-transition';
611 +
612 + document.body.insertBefore(viewport, document.body.firstChild);
613 + document.body.insertBefore(atmosfera, document.body.firstChild);
614 + document.body.insertBefore(blade, document.body.firstChild);
615 + this._registrarParallax(viewport.querySelector('.saiyan-svg-layer'), 'saiyan');
616 + this._bindBlade();
617 + };
618 +
619 + if (document.body) {
620 + inyectar();
621 + } else {
622 + document.addEventListener('DOMContentLoaded', () => {
623 + this._limpiarAccentoInline();
624 + inyectar();
625 + }, { once: true });
626 + }
627 + },
628 +
629 + _ponFondoNoir() {
630 + if (document.querySelector('.noir-bg-viewport')) return;
631 + this._cargarFuentes();
632 + const inyectar = () => {
633 + const viewport = document.createElement('div');
634 + viewport.className = 'noir-bg-viewport';
635 + viewport.innerHTML = this._noirSVG();
636 +
637 + const atmosfera = document.createElement('div');
638 + atmosfera.className = 'atmosphere-overlay';
639 +
640 + // Cortina de transición "barrido" del callejón noir al navegar (mockup Noir).
641 + const blade = document.createElement('div');
642 + blade.className = 'noir-blade-transition';
643 +
644 + document.body.insertBefore(viewport, document.body.firstChild);
645 + document.body.insertBefore(atmosfera, document.body.firstChild);
646 + document.body.insertBefore(blade, document.body.firstChild);
647 + this._registrarParallax(viewport.querySelector('.noir-svg-layer'), 'noir');
648 + this._bindBlade();
649 + };
650 +
651 + if (document.body) {
652 + inyectar();
653 + } else {
654 + document.addEventListener('DOMContentLoaded', () => {
655 + this._limpiarAccentoInline();
656 + inyectar();
657 + }, { once: true });
658 + }
659 + },
660 +
661 + _ponFondoAlchemy() {
662 + if (document.querySelector('.alchemy-bg-viewport')) return;
663 + this._cargarFuentes();
664 + const inyectar = () => {
665 + const viewport = document.createElement('div');
666 + viewport.className = 'alchemy-bg-viewport';
667 + viewport.innerHTML = this._alchemySVG();
668 +
669 + const atmosfera = document.createElement('div');
670 + atmosfera.className = 'atmosphere-overlay';
671 +
672 + // Cortina de transmutación al navegar (mockup Alchemical Codex).
673 + const blade = document.createElement('div');
674 + blade.className = 'alchemy-blade-transition';
675 +
676 + document.body.insertBefore(viewport, document.body.firstChild);
677 + document.body.insertBefore(atmosfera, document.body.firstChild);
678 + document.body.insertBefore(blade, document.body.firstChild);
679 + this._registrarParallax(viewport.querySelector('.alchemy-svg-layer'), 'alchemy');
680 + this._bindBlade();
681 + };
682 +
683 + if (document.body) {
684 + inyectar();
685 + } else {
686 + document.addEventListener('DOMContentLoaded', () => {
687 + this._limpiarAccentoInline();
688 + inyectar();
689 + }, { once: true });
690 + }
691 + },
692 +
693 + _ponFondoMinimal() {
694 + if (document.querySelector('.minimal-bg-viewport')) return;
695 + this._cargarFuentes();
696 + const inyectar = () => {
697 + const viewport = document.createElement('div');
698 + viewport.className = 'minimal-bg-viewport';
699 + viewport.innerHTML = this._minimalSVG();
700 +
701 + // Cortina tricolor (azul→blanco→rojo) al navegar (mockup Swiss).
702 + const blade = document.createElement('div');
703 + blade.className = 'minimal-blade-transition';
704 +
705 + document.body.insertBefore(viewport, document.body.firstChild);
706 + document.body.insertBefore(blade, document.body.firstChild);
707 + this._registrarParallax(viewport.querySelector('.minimal-svg-layer'), 'minimal');
708 + this._bindBlade();
709 + };
710 +
711 + if (document.body) {
712 + inyectar();
713 + } else {
714 + document.addEventListener('DOMContentLoaded', () => {
715 + this._limpiarAccentoInline();
716 + inyectar();
717 + }, { once: true });
718 + }
719 + },
720 +
721 + _ponFondoInvernadero() {
722 + if (document.querySelector('.invernadero-bg-viewport')) return;
723 + this._cargarFuentes();
724 + const inyectar = () => {
725 + const viewport = document.createElement('div');
726 + viewport.className = 'invernadero-bg-viewport';
727 + viewport.innerHTML = this._greenhouseSVG();
728 +
729 + // Cortina de transición "hoja" al navegar (mockup invernadero).
730 + const leaf = document.createElement('div');
731 + leaf.className = 'invernadero-leaf-transition';
732 + leaf.id = 'leafTransition';
733 +
734 + document.body.insertBefore(viewport, document.body.firstChild);
735 + document.body.insertBefore(leaf, document.body.firstChild);
736 + this._bindBlade();
737 + };
738 +
739 + if (document.body) {
740 + inyectar();
741 + } else {
742 + document.addEventListener('DOMContentLoaded', () => {
743 + this._limpiarAccentoInline();
744 + inyectar();
745 + }, { once: true });
746 + }
747 + },
748 +
749 + /**
750 + * Motor de parallax unificado: un único listener de mousemove comparte el
751 + * cursor y una única RAF por frame aplica el transform al fondo del skin
752 + * activo. En vez de 6 listeners y una escritura de estilo por evento,
753 + * hay 1 listener y como máximo 1 write por frame (lo esencial para
754 + * suavidad en pantallas de 60-120 Hz).
755 + */
756 + _registrarParallax(layer, color, extra) {
757 + if (!layer || !color || layer.dataset.parallax) return;
758 + layer.dataset.parallax = '1';
759 + if (!this._parallaxCapas) this._parallaxCapas = {};
760 + this._parallaxCapas[color] = { layer, extra: extra || null };
761 + if (this._parallaxListo) return;
762 + this._parallaxListo = true;
763 + window.addEventListener('mousemove', e => {
764 + this._parallaxX = e.clientX;
765 + this._parallaxY = e.clientY;
766 + if (this._parallaxRAFId) return;
767 + this._parallaxRAFId = requestAnimationFrame(() => {
768 + this._parallaxRAFId = 0;
769 + this._aplicarParallax();
770 + });
771 + }, { passive: true });
772 + },
773 +
774 + /** Aplica el parallax del skin activo con su geometría específica. */
775 + _aplicarParallax() {
776 + const estilo = document.documentElement.getAttribute('data-estilo');
777 + const capa = this._parallaxCapas && this._parallaxCapas[estilo];
778 + if (!capa) return;
779 + const layer = capa.layer;
780 + // Quita la transición CSS en el primer movimiento para que el
781 + // parallax siga el cursor de forma inmediata y no se sienta "muerto".
782 + if (layer.style.transition !== 'none') layer.style.transition = 'none';
783 + const x = (this._parallaxX / window.innerWidth) - 0.5;
784 + const y = (this._parallaxY / window.innerHeight) - 0.5;
785 + let t = '';
786 + switch (estilo) {
787 + case 'cyber':
788 + t = `translate3d(${x * 30}px, ${y * 22}px, 0) scale(1.05) rotateX(${15 - y * 8}deg)`;
789 + break;
790 + case 'medieval':
791 + t = `translate3d(${x * 24}px, ${y * 18}px, 0) scale(${1 + y * 0.05}) rotateY(${x * 4}deg)`;
792 + if (capa.extra) capa.extra.style.transform = `translateX(${x * -35}px) translateY(${y * -22}px)`;
793 + break;
794 + case 'saiyan':
795 + t = `translate3d(${x * -30}px, ${y * -20}px, 0) scale(${1 + y * 0.06})`;
796 + break;
797 + case 'noir':
798 + t = `translate3d(${x * 22}px, ${y * 16}px, 0) scale(${1 + y * 0.05}) rotateX(${8 - y * 5}deg)`;
799 + break;
800 + case 'alchemy':
801 + t = `translate3d(${x * 30}px, ${y * 22}px, 0) scale(${1 + y * 0.05}) rotateX(${15 - y * 8}deg)`;
802 + break;
803 + case 'minimal':
804 + t = `translate3d(${x * 18}px, ${y * 14}px, 0) scale(${1 + y * 0.03}) rotateX(${4 - y * 2}deg)`;
805 + break;
806 + }
807 + if (t) layer.style.transform = t;
808 + },
809 +
810 + /** Cortina de transición al navegar entre páginas del sidebar. */
811 + _bindBlade() {
812 + if (this._bladeBinded) return;
813 + this._bladeBinded = true;
814 +
815 + document.addEventListener('click', (e) => {
816 + const estilo = document.documentElement.getAttribute('data-estilo');
817 + if (estilo !== 'cyber' && estilo !== 'medieval' && estilo !== 'saiyan' && estilo !== 'noir' && estilo !== 'alchemy' && estilo !== 'minimal' && estilo !== 'invernadero') return;
818 + if (!(e.target instanceof Element)) return;
819 +
820 + const enlace = e.target.closest('#sidebar a[href$=".html"], .sidebar-nav a[href$=".html"]');
821 + if (!enlace) return;
822 + if (e.defaultPrevented) return;
823 + if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || (e.button !== undefined && e.button !== 0)) return;
824 +
825 + const href = enlace.getAttribute('href');
826 + if (!href || href === '#') return;
827 +
828 + if (estilo === 'medieval') {
829 + // Transición heráldica del sello del castillo (mockup El Castillo).
830 + const heraldico = document.querySelector('.heraldic-transition');
831 + if (!heraldico) return;
832 + e.preventDefault();
833 + this._triggerHeraldico(() => { window.location.href = href; });
834 + return;
835 + }
836 +
837 + if (estilo === 'cyber') {
838 + // Transición de "acordeón neural": cierra los pliegues, cambia la
839 + // vista en el punto medio y luego abre los pliegues nuevamente.
840 + const accordion = document.querySelector('.cyber-blade-transition');
841 + if (!accordion) return;
842 + e.preventDefault();
843 + this._triggerAccordion(() => { window.location.href = href; });
844 + return;
845 + }
846 +
847 + if (estilo === 'invernadero') {
848 + // Cortina de círculo "hoja creciendo" (mockup invernadero).
849 + const leaf = document.querySelector('.invernadero-leaf-transition');
850 + if (!leaf) return;
851 + e.preventDefault();
852 + this._triggerLeaf(() => { window.location.href = href; });
853 + return;
854 + }
855 +
856 + const blade = document.querySelector(estilo === 'noir' ? '.noir-blade-transition' : estilo === 'alchemy' ? '.alchemy-blade-transition' : estilo === 'minimal' ? '.minimal-blade-transition' : '.saiyan-aura-transition');
857 + if (!blade) return;
858 +
859 + e.preventDefault();
860 + blade.classList.add('active');
861 + setTimeout(() => { window.location.href = href; }, estilo === 'noir' ? 340 : estilo === 'alchemy' ? 340 : estilo === 'minimal' ? 350 : 350);
862 + }, true);
863 + },
864 +
865 + /** Dispara la cortina de acordeón: cierra los pliegues, ejecuta onComplete
866 + en el punto medio (400 ms) y abre los pliegues nuevamente. */
867 + _triggerAccordion(onComplete) {
868 + const accordion = document.querySelector('.cyber-blade-transition');
869 + if (!accordion) {
870 + if (typeof onComplete === 'function') onComplete();
871 + return;
872 + }
873 +
874 + accordion.classList.add('active');
875 +
876 + setTimeout(() => {
877 + if (typeof onComplete === 'function') onComplete();
878 + accordion.classList.remove('active');
879 + }, 400);
880 + },
881 +
882 + /** Cortina de círculo "hoja creciendo" del invernadero: se abre un círculo
883 + de follaje, se cambia la vista en el pico y se cierra de nuevo. */
884 + _triggerLeaf(onComplete) {
885 + const leaf = document.querySelector('.invernadero-leaf-transition');
886 + if (!leaf) {
887 + if (typeof onComplete === 'function') onComplete();
888 + return;
889 + }
890 + leaf.classList.add('active');
891 + setTimeout(() => {
892 + if (typeof onComplete === 'function') onComplete();
893 + leaf.classList.remove('active');
894 + }, 320);
895 + },
896 +
897 + /** Dispara la cortina heráldica del sello: se abre un círculo desde un
898 + vértice superior y ejecuta onComplete en el punto medio, luego se limpia. */
899 + _triggerHeraldico(onComplete) {
900 + const container = document.querySelector('.heraldic-transition');
901 + if (!container) {
902 + if (typeof onComplete === 'function') onComplete();
903 + return;
904 + }
905 +
906 + container.classList.add('active');
907 + this._panCastilloZona();
908 +
909 + setTimeout(() => {
910 + if (typeof onComplete === 'function') onComplete();
911 + }, 260);
912 +
913 + setTimeout(() => {
914 + container.classList.remove('active');
915 + }, 620);
916 + },
917 +
918 + /* ------------------------------------------------------------
919 + Aplicación
920 + ------------------------------------------------------------ */
921 +
922 + /** Aplica un estilo visual y (por defecto) lo guarda como preferencia. */
923 + set(id, guardar = true) {
924 + const estilo = this.STYLES.some(s => s.id === id) ? id : 'clasico';
925 + const r = document.documentElement;
926 + r.setAttribute('data-estilo', estilo);
927 +
928 + if (estilo === 'clasico') {
929 + this._limpiarFondo();
930 + // El color de acento vuelve a manos de la personalización (js/colores.js).
931 + if (typeof Colores !== 'undefined') Colores.aplicar();
932 + } else {
933 + // El estilo define su propia paleta: libera las variables inline de
934 + // la personalización para que las del skin (estilos.css) manden.
935 + this._limpiarAccentoInline();
936 + this._ponFondo();
937 + }
938 +
939 + if (guardar) {
940 + try { localStorage.setItem(this.KEY, estilo); } catch (e) { /* almacenamiento no disponible */ }
941 + }
942 + document.dispatchEvent(new CustomEvent('estilos:cambio', { detail: { estilo } }));
943 + this._aplicarTextos();
944 + if (guardar) this._mostrarRecarga();
945 + return estilo;
946 + },
947 +
948 + /** Aplica los textos temáticos del skin activo sobre botones, enlaces y
949 + placeholders (coincidencia exacta e idempotente). */
950 + _aplicarTextos() {
951 + const mapa = this.TEXTOS_ESTILO[this.activo()];
952 + if (!mapa || !document.body) return;
953 + const normalizar = t => (t || '').replace(/\s+/g, ' ').trim();
954 + document.querySelectorAll('button, a').forEach(el => {
955 + const txt = normalizar(el.textContent);
956 + if (mapa[txt]) el.textContent = mapa[txt];
957 + });
958 + document.querySelectorAll('input, textarea').forEach(el => {
959 + const ph = el.getAttribute('placeholder');
960 + if (ph && mapa[ph]) el.setAttribute('placeholder', mapa[ph]);
961 + });
962 + },
963 +
964 + /** Aviso con botón de recarga al cambiar de estilo en caliente: evita que
965 + algunos elementos se queden con aspecto raro hasta el próximo full-load. */
966 + _mostrarRecarga() {
967 + if (document.querySelector('.estilos-recarga-overlay')) return;
968 + if (!document.body) {
969 + document.addEventListener('DOMContentLoaded', () => this._mostrarRecarga(), { once: true });
970 + return;
971 + }
972 + const acento = { clasico: '#3fb950', cyber: '#00f0ff', medieval: '#A9834B', saiyan: '#facc15', noir: '#e11d48', alchemy: '#10b981', minimal: '#2563eb', invernadero: '#3E6B4F' }[this.activo()] || '#3fb950';
973 + const ov = document.createElement('div');
974 + ov.className = 'estilos-recarga-overlay';
975 + ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.72);backdrop-filter:blur(6px);z-index:999999;display:flex;align-items:center;justify-content:center;padding:1rem;overflow:auto;';
976 + ov.innerHTML = `
977 + <div class="estilos-recarga-card" style="background:#161b22;border:2px solid ${acento};box-shadow:0 0 45px rgba(0,0,0,0.7);padding:30px 34px;max-width:430px;width:100%;max-height:calc(100vh - 2rem);overflow:auto;text-align:center;flex-shrink:0;margin:auto;">
978 + <div style="font-size:2.2rem;line-height:1;">⚡</div>
979 + <h3 style="margin:12px 0 8px;font-size:1.3rem;font-weight:900;color:#ffffff;">Estilo aplicado</h3>
980 + <p style="margin:0 0 20px;font-size:0.92rem;color:#8b949e;line-height:1.5;">Para que el nuevo estilo se aplique por completo y no se vea raro, recarga la página.</p>
981 + <div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap;">
982 + <button type="button" class="est-recargar-btn" style="background:${acento};color:#0b1220;border:none;padding:11px 22px;font-weight:800;font-size:0.95rem;cursor:pointer;">Recargar ahora</button>
983 + <button type="button" class="est-quedarme-btn" style="background:transparent;color:#c9d1d9;border:1px solid #30363d;padding:10px 18px;font-weight:600;font-size:0.9rem;cursor:pointer;">Quedarme aquí</button>
984 + </div>
985 + </div>`;
986 + document.body.appendChild(ov);
987 +
988 + const cerrar = () => { ov.remove(); };
989 + ov.querySelector('.est-recargar-btn').addEventListener('click', () => window.location.reload(), { once: true });
990 + ov.querySelector('.est-quedarme-btn').addEventListener('click', cerrar, { once: true });
991 + document.addEventListener('keydown', e => { if (e.key === 'Escape') cerrar(); }, { once: true });
992 + },
993 +
994 + /** Re-aplica los textos cuando se re-renderiza contenido dinámico
995 + (posts, modales, resultados de búsqueda...). */
996 + _vigilarTextos() {
997 + if (this._texVigila) return;
998 + this._texVigila = true;
999 + this._aplicarTextos();
1000 + if (!('MutationObserver' in window)) return;
1001 + if (!this.TEXTOS_ESTILO[this.activo()]) return;
1002 + let timer = null;
1003 + new MutationObserver(() => {
1004 + clearTimeout(timer);
1005 + timer = setTimeout(() => this._aplicarTextos(), 350);
1006 + }).observe(document.body, { childList: true, subtree: true });
1007 + },
1008 +
1009 + /** Aplica el estilo guardado al cargar la página (sin volver a guardar). */
1010 + init() {
1011 + this.set(this.guardado(), false);
1012 + if (document.body) {
1013 + this._vigilarTextos();
1014 + } else {
1015 + document.addEventListener('DOMContentLoaded', () => this._vigilarTextos(), { once: true });
1016 + }
1017 + }
1018 +};
1019 +
1020 +/* Se aplica en cuanto se carga el script, igual que Tema y Colores (evita parpadeo). */
1021 +Estilos.init();
\ No newline at end of file
frontend/js/layout.js
+346 -5
@@ -5,19 +5,113 @@
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' },
11 - { href: 'circulos.html', label: 'Círculos de Estudio' }
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 = {
15 - init(currentPage) {
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) {
@@ -25,15 +119,25 @@ const Layout = {
119 if (!sidebar) return;
120
121 const user = Auth.getUser();
28 - const navLinks = NAV_ITEMS.map(item => {
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' : '';
30 - return `<li><a href="${item.href}" class="${active}">${item.label}</a></li>`;
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>
36 - <small>${user?.nombre || 'Usuario'}</small>
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">
@@ -54,10 +158,112 @@ const Layout = {
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');
@@ -82,5 +288,140 @@ const Layout = {
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 };
frontend/js/pages/admin.js new
+427
@@ -0,0 +1,427 @@
1 +/**
2 + * Panel Administrativo y Reportería.
3 + * Contrato esperado de GET /admin/stats:
4 + * { perfil, kpis: { totalEstudiantes, totalProfesores, totalPublicaciones,
5 + * sesionesAbiertas, totalEmpresas, totalResenas, totalRecursos },
6 + * actividad: [{ modulo, titulo, autor, tipo, estado, fecha }] }
7 + */
8 +document.addEventListener('DOMContentLoaded', async () => {
9 + Layout.init('admin.html');
10 + Layout.setPageTitle('Panel Administrativo');
11 +
12 + const content = document.getElementById('main-content');
13 + if (!content) return;
14 +
15 + content.innerHTML = '<div class="admin-container"><p class="loading-message">Cargando panel administrativo…</p></div>';
16 +
17 + let data = {};
18 + try {
19 + data = await API.request('/admin/stats');
20 + } catch (e) {
21 + content.innerHTML = `<div class="admin-container"><div class="card admin-empty">No se pudo cargar el panel: ${Utils.esc(e.message || 'sin permisos de administrador')}</div></div>`;
22 + return;
23 + }
24 +
25 + renderAdmin(content, data || {});
26 +});
27 +
28 +function renderAdmin(content, data) {
29 + const perfil = data.perfil || {};
30 + const kpis = data.kpis || {};
31 + const actividad = Array.isArray(data.actividad) ? data.actividad : [];
32 +
33 + content.innerHTML = `
34 + <div class="admin-container">
35 + <header class="card admin-header">
36 + <div class="admin-title">
37 + <h2>Panel de Coordinación y Reportería Académica</h2>
38 + <p>Profesor / Asesor Docente: <strong>${Utils.esc(perfil.nombre || 'Administrador')}</strong> • Campus Guadalajara</p>
39 + </div>
40 + <div>
41 + <span class="badge badge-info">Rol: Administrador Docente</span>
42 + </div>
43 + </header>
44 +
45 + <section class="kpi-grid">
46 + <div class="kpi-card">
47 + <div class="kpi-value">${Number(kpis.totalEstudiantes) || 0}</div>
48 + <div class="kpi-label">Estudiantes</div>
49 + <div class="kpi-sub">Certificados activos en la plataforma</div>
50 + </div>
51 + <div class="kpi-card">
52 + <div class="kpi-value">${Number(kpis.totalProfesores) || 0}</div>
53 + <div class="kpi-label">Docentes</div>
54 + <div class="kpi-sub">Personal académico registrado</div>
55 + </div>
56 + <div class="kpi-card">
57 + <div class="kpi-value">${Number(kpis.totalPublicaciones) || 0}</div>
58 + <div class="kpi-label">Dudas / Publicaciones</div>
59 + <div class="kpi-sub">Actividad del foro académico</div>
60 + </div>
61 + <div class="kpi-card">
62 + <div class="kpi-value">${Number(kpis.sesionesAbiertas) || 0}</div>
63 + <div class="kpi-label">Círculos de Estudio</div>
64 + <div class="kpi-sub">Sesiones activas programadas</div>
65 + </div>
66 + <div class="kpi-card">
67 + <div class="kpi-value">${Number(kpis.totalEmpresas) || 0}</div>
68 + <div class="kpi-label">Empresas Vinculadas</div>
69 + <div class="kpi-sub">Directorio del Semestre Empresarial</div>
70 + </div>
71 + <div class="kpi-card alert">
72 + <div class="kpi-value">${Number(kpis.totalResenas) || 0}</div>
73 + <div class="kpi-label">Reseñas Empresariales</div>
74 + <div class="kpi-sub">Evaluaciones del ciclo vigente</div>
75 + </div>
76 + </section>
77 +
78 + <section class="card">
79 + <div class="section-header">
80 + <strong>Extracción de Reportes Institucionales</strong>
81 + <span style="font-size: 0.8rem; color: #666;">Exportación directa para comités y acreditaciones</span>
82 + </div>
83 + <form class="report-form" id="report-form">
84 + <div class="form-group">
85 + <label for="report-tipo">Tipo de Reporte</label>
86 + <select class="form-control" id="report-tipo">
87 + <option value="círculos">Participación en Círculos de Estudio y Asistencia</option>
88 + <option value="dudas">Métricas de Resolución de Dudas por Materia</option>
89 + <option value="recursos">Descargas y Calidad de Repositorio de Apuntes</option>
90 + <option value="empresas">Evaluación y Desempeño de Empresas Vinculadas</option>
91 + </select>
92 + </div>
93 + <div class="form-group">
94 + <label for="report-ciclo">Semestre / Ciclo</label>
95 + <select class="form-control" id="report-ciclo">
96 + <option value="current">Ciclo Vigente (Semestre Actual)</option>
97 + <option value="2026-1">2026-1 (Enero - Mayo)</option>
98 + <option value="all">Histórico Anual</option>
99 + </select>
100 + </div>
101 + <div class="form-group">
102 + <label for="report-formato">Formato de Salida</label>
103 + <select class="form-control" id="report-formato">
104 + <option value="csv">Archivo CSV / Excel (.csv)</option>
105 + <option value="pdf">Documento Ejecutivo (.pdf)</option>
106 + </select>
107 + </div>
108 + <button class="btn-solid" type="submit">⬇ Descargar Reporte</button>
109 + </form>
110 + </section>
111 +
112 + <section class="card">
113 + <div class="section-header">
114 + <strong>Gestión de Docentes</strong>
115 + <span style="font-size: 0.8rem; color: #666;">Alta de cuentas de asesores docentes</span>
116 + </div>
117 + <form class="report-form" id="docente-form">
118 + <div class="form-group">
119 + <label for="doc-nombre">Nombre(s) del docente</label>
120 + <input type="text" class="form-control" id="doc-nombre" required>
121 + </div>
122 + <div class="form-group">
123 + <label for="doc-apellido">Apellido(s)</label>
124 + <input type="text" class="form-control" id="doc-apellido" required>
125 + </div>
126 + <div class="form-group">
127 + <label for="doc-email">Correo institucional</label>
128 + <input type="email" class="form-control" id="doc-email" required placeholder="docente@tecmilenio.mx">
129 + </div>
130 + <div class="form-group">
131 + <label for="doc-nomina">Número de nómina</label>
132 + <input type="text" class="form-control" id="doc-nomina" required placeholder="L01234567">
133 + </div>
134 + <div class="form-group">
135 + <label for="doc-contrasena">Contraseña inicial</label>
136 + <input type="text" class="form-control" id="doc-contrasena" required placeholder="Temporal123">
137 + </div>
138 + <button class="btn-solid" type="submit">➕ Crear Docente</button>
139 + </form>
140 + <div id="docente-resultado" style="margin-top:10px;"></div>
141 + </section>
142 +
143 + <section class="card">
144 + <div class="section-header">
145 + <strong>Gestión de Cuentas (Alumnos y Docentes)</strong>
146 + <span style="font-size: 0.8rem; color: #666;">Dar de baja al graduarse, darse de baja o al causar baja</span>
147 + </div>
148 + <div id="usuarios-contenido"></div>
149 + </section>
150 +
151 + <section class="card">
152 + <div class="section-header">
153 + <strong>Supervisión de Actividad y Validación Docente</strong>
154 + </div>
155 + <div class="table-responsive">
156 + <table class="admin-table">
157 + <thead>
158 + <tr>
159 + <th>Módulo</th>
160 + <th>Elemento / Título</th>
161 + <th>Autor / Alumno</th>
162 + <th>Estado</th>
163 + </tr>
164 + </thead>
165 + <tbody>${actividad.length ? actividad.map(renderActividad).join('') : renderFilaVacia()}</tbody>
166 + </table>
167 + </div>
168 + </section>
169 + </div>
170 + `;
171 +
172 + document.getElementById('report-form')?.addEventListener('submit', event => {
173 + event.preventDefault();
174 + const tipo = document.getElementById('report-tipo').value;
175 + const ciclo = document.getElementById('report-ciclo').value;
176 + const formato = document.getElementById('report-formato').value;
177 + exportarReporte(tipo, ciclo, formato, data);
178 + });
179 +
180 + document.getElementById('docente-form')?.addEventListener('submit', async event => {
181 + event.preventDefault();
182 + const resultado = document.getElementById('docente-resultado');
183 + const nombre = document.getElementById('doc-nombre').value.trim();
184 + const apellido = document.getElementById('doc-apellido').value.trim();
185 + const email = document.getElementById('doc-email').value.trim();
186 + const numeroNomina = document.getElementById('doc-nomina').value.trim();
187 + const contrasena = document.getElementById('doc-contrasena').value;
188 +
189 + if (!/^[^\s@]+@(tecmilenio\.mx|servicios\.tecmilenio\.mx)$/i.test(email)) {
190 + resultado.innerHTML = '<div class="admin-empty" style="color:#b91c1c;">Usa un correo institucional @tecmilenio.mx.</div>';
191 + return;
192 + }
193 + if (contrasena.length < 8 || !/[A-Z]/.test(contrasena) || !/[a-z]/.test(contrasena) || !/\d/.test(contrasena)) {
194 + resultado.innerHTML = '<div class="admin-empty" style="color:#b91c1c;">La contraseña requiere 8+ caracteres con mayúscula, minúscula y número.</div>';
195 + return;
196 + }
197 +
198 + resultado.innerHTML = '<div class="admin-empty">Creando docente…</div>';
199 + try {
200 + await API.request('/admin/crear-docente', {
201 + method: 'POST',
202 + body: JSON.stringify({ nombre, apellido, email, numeroNomina, contrasena })
203 + });
204 + resultado.innerHTML = '<div class="admin-empty" style="color:#087527;">✅ Docente creado correctamente.</div>';
205 + event.target.reset();
206 + cargarUsuarios();
207 + } catch (e) {
208 + resultado.innerHTML = `<div class="admin-empty" style="color:#b91c1c;">${Utils.esc(e.message || 'No se pudo crear el docente.')}</div>`;
209 + }
210 + });
211 +
212 + cargarUsuarios();
213 +}
214 +
215 +async function cargarUsuarios() {
216 + const contenedor = document.getElementById('usuarios-contenido');
217 + if (!contenedor) return;
218 +
219 + contenedor.innerHTML = '<p class="admin-empty">Cargando cuentas…</p>';
220 + let usuarios = [];
221 + try {
222 + usuarios = await API.request('/admin/usuarios');
223 + } catch (e) {
224 + contenedor.innerHTML = `<p class="admin-empty" style="color:#b91c1c;">No se pudo cargar la lista: ${Utils.esc(e.message || 'sin permisos')}</p>`;
225 + return;
226 + }
227 +
228 + if (!Array.isArray(usuarios) || !usuarios.length) {
229 + contenedor.innerHTML = '<p class="admin-empty">Sin cuentas registradas.</p>';
230 + return;
231 + }
232 +
233 + contenedor.innerHTML = `
234 + <div class="usuarios-buscar">
235 + <input type="text" id="usuarios-buscar-input" class="form-control"
236 + placeholder="Buscar por nombre o matrícula / nómina…" autocomplete="off">
237 + </div>
238 + <div class="table-responsive">
239 + <table class="admin-table" id="usuarios-tabla">
240 + <thead>
241 + <tr>
242 + <th>Nombre</th>
243 + <th>Correo</th>
244 + <th>Matrícula / Nómina</th>
245 + <th>Tipo</th>
246 + <th>Estado</th>
247 + <th>Acción</th>
248 + </tr>
249 + </thead>
250 + <tbody id="usuarios-tbody"></tbody>
251 + </table>
252 + </div>`;
253 +
254 + const input = document.getElementById('usuarios-buscar-input');
255 + const tbody = document.getElementById('usuarios-tbody');
256 + let filtro = '';
257 +
258 + const render = () => {
259 + const q = filtro.trim().toLowerCase();
260 + const filtrados = q
261 + ? usuarios.filter(u => {
262 + const mat = (u.matricula || u.nomina || '').toLowerCase();
263 + return (u.nombre || '').toLowerCase().includes(q)
264 + || (u.email || '').toLowerCase().includes(q)
265 + || mat.includes(q);
266 + })
267 + : usuarios;
268 +
269 + if (!filtrados.length) {
270 + tbody.innerHTML = '<tr><td colspan="6"><div class="admin-empty">Sin resultados para la búsqueda.</div></td></tr>';
271 + return;
272 + }
273 +
274 + tbody.innerHTML = filtrados.map(u => {
275 + const activo = u.activo === true;
276 + const rolLbl = u.rol === 'PROFESOR' ? 'Docente' : 'Estudiante';
277 + const credencial = u.rol === 'PROFESOR' ? (u.nomina || '—') : (u.matricula || '—');
278 + const badge = activo
279 + ? '<span class="badge badge-info">Activo</span>'
280 + : '<span class="badge badge-danger">Inhabilitado</span>';
281 + const accion = activo
282 + ? `<button class="btn-solid btn-baja" data-id="${u.id}" style="padding:5px 10px;font-size:0.8rem;">Dar de baja</button>`
283 + : `<button class="btn-outline btn-reactivar" data-id="${u.id}" style="padding:5px 10px;font-size:0.8rem;">Reactivar</button>`;
284 + return `
285 + <tr>
286 + <td><strong>${Utils.esc(u.nombre || '—')}</strong></td>
287 + <td>${Utils.esc(u.email || '—')}</td>
288 + <td>${Utils.esc(credencial)}</td>
289 + <td><span class="badge badge-info">${rolLbl}</span></td>
290 + <td>${badge}</td>
291 + <td>${accion}</td>
292 + </tr>`;
293 + }).join('');
294 + };
295 +
296 + input.addEventListener('input', () => {
297 + filtro = input.value;
298 + render();
299 + });
300 +
301 + render();
302 +
303 + document.querySelectorAll('#usuarios-tabla .btn-baja').forEach(btn => {
304 + btn.addEventListener('click', async () => {
305 + const id = btn.dataset.id;
306 + if (!confirm('¿Inhabilitar esta cuenta? El usuario ya no podrá iniciar sesión.')) return;
307 + try {
308 + await API.request(`/admin/usuarios/${id}/inhabilitar`, { method: 'POST' });
309 + Utils.toast('Cuenta inhabilitada.', 'success');
310 + cargarUsuarios();
311 + } catch (e) {
312 + Utils.toast(e.message || 'No se pudo inhabilitar.', 'error');
313 + }
314 + });
315 + });
316 +
317 + document.querySelectorAll('#usuarios-tabla .btn-reactivar').forEach(btn => {
318 + btn.addEventListener('click', async () => {
319 + const id = btn.dataset.id;
320 + try {
321 + await API.request(`/admin/usuarios/${id}/reactivar`, { method: 'POST' });
322 + Utils.toast('Cuenta reactivada.', 'success');
323 + cargarUsuarios();
324 + } catch (e) {
325 + Utils.toast(e.message || 'No se pudo reactivar.', 'error');
326 + }
327 + });
328 + });
329 +}
330 +
331 +function renderActividad(item) {
332 + const fecha = item.fecha ? formatFecha(item.fecha) : '';
333 + const esPublicacion = item.tipo === 'publicacion';
334 + const badge = esPublicacion
335 + ? '<span class="badge badge-warning">Publicación de foro</span>'
336 + : '<span class="badge badge-info">Actividad</span>';
337 + const modulo = item.modulo || (esPublicacion ? 'Foro Dudas' : 'General');
338 + return `
339 + <tr>
340 + <td><strong>${Utils.esc(modulo)}</strong></td>
341 + <td>${Utils.esc(item.titulo || 'Sin título')}</td>
342 + <td>${Utils.esc(item.autor || '—')}</td>
343 + <td>${badge}${fecha ? `<span style="color:#888; font-size:0.75rem;"> · ${Utils.esc(fecha)}</span>` : ''}</td>
344 + </tr>
345 + `;
346 +}
347 +
348 +function renderFilaVacia() {
349 + return '<tr><td colspan="4"><div class="admin-empty">Sin actividad reciente para supervisar.</div></td></tr>';
350 +}
351 +
352 +function exportarReporte(tipo, ciclo, formato, data) {
353 + const etiquetas = {
354 + 'círculos': 'Participación en Círculos de Estudio y Asistencia',
355 + 'dudas': 'Métricas de Resolución de Dudas por Materia',
356 + 'recursos': 'Descargas y Calidad de Repositorio de Apuntes',
357 + 'empresas': 'Evaluación y Desempeño de Empresas Vinculadas'
358 + };
359 + const kpis = (data && data.kpis) || {};
360 + const actividad = (data && Array.isArray(data.actividad)) ? data.actividad : [];
361 +
362 + const filas = [];
363 + filas.push(['MAPS CONNECT — REPORTE INSTITUCIONAL']);
364 + filas.push(['Tipo de reporte', etiquetas[tipo] || tipo]);
365 + filas.push(['Semestre / Ciclo', ciclo]);
366 + filas.push(['Generado', new Date().toLocaleString()]);
367 + filas.push([]);
368 + filas.push(['INDICADOR', 'VALOR']);
369 + filas.push(['Estudiantes activos', Number(kpis.totalEstudiantes) || 0]);
370 + filas.push(['Docentes registrados', Number(kpis.totalProfesores) || 0]);
371 + filas.push(['Dudas / Publicaciones', Number(kpis.totalPublicaciones) || 0]);
372 + filas.push(['Círculos de Estudio activos', Number(kpis.sesionesAbiertas) || 0]);
373 + filas.push(['Empresas vinculadas', Number(kpis.totalEmpresas) || 0]);
374 + filas.push(['Reseñas empresariales', Number(kpis.totalResenas) || 0]);
375 + filas.push(['Recursos académicos', Number(kpis.totalRecursos) || 0]);
376 + filas.push([]);
377 + filas.push(['ACTIVIDAD RECIENTE — Módulo', 'Elemento / Título', 'Autor', 'Estado', 'Fecha']);
378 + if (actividad.length) {
379 + actividad.forEach(a => {
380 + filas.push([
381 + a.modulo || 'General',
382 + a.titulo || 'Sin título',
383 + a.autor || '—',
384 + a.tipo === 'publicacion' ? 'Foro' : 'Actividad',
385 + formatFecha(a.fecha)
386 + ]);
387 + });
388 + } else {
389 + filas.push(['Sin actividad reciente para este período.']);
390 + }
391 +
392 + const csv = filas.map(fila => fila.map(campo => {
393 + const s = String(campo == null ? '' : campo);
394 + return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
395 + }).join(',')).join('\r\n');
396 +
397 + const nombre = `reporte_${tipo}_${ciclo}`;
398 + if (formato === 'pdf') {
399 + const docu = filas.map(fila => fila.join('\t')).join('\r\n');
400 + const blob = new Blob([docu], { type: 'text/plain;charset=utf-8' });
401 + descargar(blob, `${nombre}.txt`);
402 + Utils.toast('Documento ejecutivo generado.', 'success');
403 + return;
404 + }
405 + const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' });
406 + descargar(blob, `${nombre}.csv`);
407 + Utils.toast('Reporte CSV generado correctamente.', 'success');
408 +}
409 +
410 +function descargar(blob, nombre) {
411 + const url = URL.createObjectURL(blob);
412 + const a = document.createElement('a');
413 + a.href = url;
414 + a.download = nombre;
415 + document.body.appendChild(a);
416 + a.click();
417 + document.body.removeChild(a);
418 + URL.revokeObjectURL(url);
419 +}
420 +
421 +function formatFecha(value) {
422 + if (!value) return '';
423 + const s = String(value).replace('T', ' ');
424 + const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
425 + if (!m) return '';
426 + return `${m[3]}/${m[2]}/${m[1]}`;
427 +}
frontend/js/pages/ajustes.js new
+222
@@ -0,0 +1,222 @@
1 +/**
2 + * MAPS Connect - Ajustes
3 + * Permite: personalizar el color de la página, elegir un estilo visual,
4 + * activar/desactivar el modo oscuro, administrar la cuenta (nombre, contraseña)
5 + * y consultar el aviso de privacidad.
6 + */
7 +const Ajustes = {
8 + init() {
9 + Layout.init('ajustes.html');
10 + Layout.setPageTitle('Ajustes');
11 +
12 + this.initColor();
13 + this.initEstilos();
14 + this.initTema();
15 + this.initCuenta();
16 + document.getElementById('btn-ver-aviso')?.addEventListener('click', () => AvisoPrivacidad.mostrar());
17 + },
18 +
19 + /** Personalización del color de la página (predefinidos + color a medida). */
20 + initColor() {
21 + const swatches = document.getElementById('swatches-colores');
22 + const customInput = document.getElementById('color-custom');
23 + const customTexto = document.getElementById('color-custom-texto');
24 + const btnRestaurar = document.getElementById('btn-restaurar-color');
25 +
26 + // --- Swatches predefinidos ---
27 + const current = Colores.guardado();
28 + const activarSwatch = (hex, boton) => {
29 + if (!swatches) return;
30 + swatches.querySelectorAll('.color-swatch').forEach((el) => {
31 + el.classList.toggle('activo', el === boton);
32 + el.setAttribute('aria-pressed', el === boton ? 'true' : 'false');
33 + });
34 + if (customInput) customInput.value = hex;
35 + if (customTexto) customTexto.textContent = hex;
36 + };
37 +
38 + if (swatches) {
39 + Colores.PRESETS.forEach(({ id, hex, nombre }) => {
40 + const btn = document.createElement('button');
41 + btn.type = 'button';
42 + btn.className = 'color-swatch';
43 + btn.dataset.id = id;
44 + btn.dataset.hex = hex;
45 + btn.style.setProperty('--swatch', hex);
46 + btn.title = nombre;
47 + btn.setAttribute('aria-label', `Color ${nombre}`);
48 + btn.setAttribute('aria-pressed', 'false');
49 + if (current === hex) {
50 + btn.classList.add('activo');
51 + btn.setAttribute('aria-pressed', 'true');
52 + if (customInput) customInput.value = hex;
53 + if (customTexto) customTexto.textContent = hex;
54 + }
55 + btn.addEventListener('click', () => {
56 + Colores.set(hex);
57 + activarSwatch(hex, btn);
58 + Utils.toast(`Color ${nombre} aplicado.`, 'success');
59 + });
60 + swatches.appendChild(btn);
61 + });
62 + }
63 +
64 + // --- Color a medida ---
65 + if (customInput) {
66 + customInput.addEventListener('input', () => {
67 + const hex = customInput.value.toLowerCase();
68 + Colores.set(hex);
69 + if (customTexto) customTexto.textContent = hex;
70 + swatches?.querySelectorAll('.color-swatch').forEach((el) => el.classList.remove('activo'));
71 + });
72 + }
73 +
74 + // --- Restablecer color por defecto ---
75 + btnRestaurar?.addEventListener('click', () => {
76 + Colores.limpiar();
77 + swatches?.querySelectorAll('.color-swatch').forEach((el) => el.classList.remove('activo'));
78 + // Marca el verde (por defecto) como activo.
79 + const verde = swatches?.querySelector('.color-swatch[data-id="verde"]');
80 + if (verde) {
81 + verde.classList.add('activo');
82 + if (customInput) customInput.value = verde.dataset.hex;
83 + if (customTexto) customTexto.textContent = verde.dataset.hex;
84 + }
85 + Utils.toast('Color por defecto restablecido.', 'success');
86 + });
87 + },
88 +
89 + /** Selector de estilos de la página (Estilos.STYLES). */
90 + initEstilos() {
91 + const lista = document.getElementById('estilos-lista');
92 + if (!lista) return;
93 +
94 + const renderizar = () => {
95 + const activo = Estilos.activo();
96 + lista.querySelectorAll('.estilo-opcion').forEach((op) => {
97 + const esActivo = op.dataset.estilo === activo;
98 + op.classList.toggle('activo', esActivo);
99 + op.setAttribute('aria-pressed', esActivo ? 'true' : 'false');
100 + });
101 + this._bloquearPersonalizaColor();
102 + };
103 +
104 + Estilos.STYLES.forEach(({ id, nombre, descripcion, preview }) => {
105 + const op = document.createElement('button');
106 + op.type = 'button';
107 + op.className = 'estilo-opcion';
108 + op.dataset.estilo = id;
109 + op.setAttribute('aria-pressed', 'false');
110 + op.innerHTML = `
111 + <span class="estilo-preview" style="background: ${preview}" aria-hidden="true"></span>
112 + <span class="estilo-nombre">${nombre}</span>
113 + <span class="estilo-desc">${descripcion}</span>
114 + <span class="estilo-check" aria-hidden="true">✓</span>`;
115 + op.addEventListener('click', () => {
116 + const activado = Estilos.set(id);
117 + renderizar();
118 + const e = Estilos.STYLES.find((s) => s.id === activado);
119 + Utils.toast(`Estilo "${e ? e.nombre : activado}" aplicado.`, 'success');
120 + });
121 + lista.appendChild(op);
122 + });
123 +
124 + renderizar();
125 + },
126 +
127 + /**
128 + * La personalización de color solo aplica al estilo "Clásico". Con otro
129 + * estilo activo se deshabilita para evitar mezclar paletas.
130 + */
131 + _bloquearPersonalizaColor() {
132 + const personaliza = document.querySelector('.personaliza-seccion');
133 + if (!personaliza) return;
134 + const bloqueado = Estilos.activo() !== 'clasico';
135 + personaliza.querySelectorAll('button, input').forEach((el) => { el.disabled = bloqueado; });
136 + personaliza.classList.toggle('personaliza-bloqueado', bloqueado);
137 + },
138 +
139 + /** Modo oscuro / claro. */
140 + initTema() {
141 + const toggle = document.getElementById('tema-toggle');
142 + if (toggle) {
143 + toggle.checked = Tema.activo();
144 + toggle.addEventListener('change', () => {
145 + const oscuro = Tema.alternar();
146 + // Después de alternar la clase real, sincronizamos el switch.
147 + toggle.checked = oscuro;
148 + Utils.toast(oscuro ? 'Modo oscuro activado.' : 'Modo claro activado.', 'success');
149 + });
150 + }
151 + },
152 +
153 + /** Mi cuenta: cambiar nombre y contraseña. */
154 + initCuenta() {
155 + const user = Auth.getUser();
156 + const cuentaEmail = document.getElementById('cuenta-email');
157 + if (cuentaEmail) cuentaEmail.textContent = user?.email || '—';
158 +
159 + const formNombre = document.getElementById('form-nombre');
160 + const inputNombre = document.getElementById('input-nombre');
161 + if (inputNombre) inputNombre.value = user?.nombre || '';
162 +
163 + formNombre?.addEventListener('submit', async (e) => {
164 + e.preventDefault();
165 + const nombre = inputNombre.value.trim();
166 + if (nombre.length < 2) {
167 + return Utils.toast('Escribe tu nombre completo (mínimo 2 caracteres).', 'error');
168 + }
169 + try {
170 + const data = await API.request('/usuarios/cuenta/nombre', {
171 + method: 'PUT',
172 + body: JSON.stringify({ nombre })
173 + });
174 + Auth.actualizar({ nombre: data.nombre });
175 + inputNombre.value = data.nombre;
176 + Utils.toast('Nombre actualizado.', 'success');
177 + } catch (err) {
178 + Utils.toast(err.message || 'No se pudo actualizar el nombre.', 'error');
179 + }
180 + });
181 +
182 + const formContrasena = document.getElementById('form-contrasena');
183 + const actual = document.getElementById('input-contrasena-actual');
184 + const nueva = document.getElementById('input-contrasena-nueva');
185 + const confirmar = document.getElementById('input-contrasena-confirmar');
186 +
187 + formContrasena?.addEventListener('submit', async (e) => {
188 + e.preventDefault();
189 + const a = actual.value;
190 + const n = nueva.value;
191 + const c = confirmar.value;
192 +
193 + if (!a || !n || !c) {
194 + return Utils.toast('Completa todos los campos.', 'error');
195 + }
196 + if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(n)) {
197 + return Utils.toast('La contraseña nueva debe tener al menos 8 caracteres y combinar mayúscula, minúscula y número.', 'error');
198 + }
199 + if (n !== c) {
200 + return Utils.toast('La confirmación no coincide.', 'error');
201 + }
202 + if (n === a) {
203 + return Utils.toast('La nueva contraseña no puede ser igual a la actual.', 'error');
204 + }
205 +
206 + try {
207 + await API.request('/usuarios/cuenta/contrasena', {
208 + method: 'PUT',
209 + body: JSON.stringify({ contrasenaActual: a, contrasenaNueva: n })
210 + });
211 + actual.value = '';
212 + nueva.value = '';
213 + confirmar.value = '';
214 + Utils.toast('Contraseña actualizada.', 'success');
215 + } catch (err) {
216 + Utils.toast(err.message || 'No se pudo cambiar la contraseña.', 'error');
217 + }
218 + });
219 + }
220 +};
221 +
222 +document.addEventListener('DOMContentLoaded', () => Ajustes.init());
frontend/js/pages/certificados.js new
+223
@@ -0,0 +1,223 @@
1 +/**
2 + * Mi Ruta MAPS - Certificados del modelo académico.
3 + * Contrato esperado de GET /estudiantes/ruta:
4 + * { estudiante: { id, matricula, semestre, carrera },
5 + * ruta: [{ id, nombre, descripcion, semestreMin, semestreMax, materias[], enRuta }],
6 + * catalogo: [ { id, nombre, descripcion, semestreMin, semestreMax, materias[], enRuta } ] }
7 + * ASIGNACION: POST /certificados/asignar { idEstudiante, idCertificado }.
8 + */
9 +document.addEventListener('DOMContentLoaded', async () => {
10 + Layout.init('certificados.html');
11 + Layout.setPageTitle('Mi Ruta MAPS');
12 +
13 + const content = document.getElementById('main-content');
14 + if (!content) return;
15 +
16 + const user = Auth.getUser();
17 + if (!user || user.rol === 'PROFESOR') {
18 + renderVistaProfesor(content);
19 + return;
20 + }
21 +
22 + content.innerHTML = '<div class="ruta-container"><p class="loading-message">Cargando tu ruta MAPS…</p></div>';
23 + content.addEventListener('click', handlerAgregar);
24 +
25 + let data = {};
26 + try {
27 + data = await API.request('/estudiantes/ruta');
28 + } catch (error) {
29 + const sinPerfil = /completa tu perfil|perfil de estudiante aún no está completo/i.test(error?.message || '');
30 + renderError(content, sinPerfil);
31 + return;
32 + }
33 +
34 + renderRuta(content, data || {});
35 +});
36 +
37 +function renderVistaProfesor(content) {
38 + content.innerHTML = `
39 + <div class="ruta-container">
40 + <header class="module-heading"><h1>Mi Ruta MAPS</h1><p>Panel de la trayectoria de certificados de los estudiantes.</p></header>
41 + <section class="card empty-state-box">
42 + <h2>Vista exclusiva para estudiantes</h2>
43 + <p>La ruta MAPS agrupa los certificados y sus materias por semestre sugerido. Como docente, puedes orientar a tus estudiantes desde tu vista de docencia.</p>
44 + <a class="btn-clean" href="inicio.html">Volver a inicio</a>
45 + </section>
46 + </div>
47 + `;
48 +}
49 +
50 +function renderError(content, sinPerfil) {
51 + content.innerHTML = `
52 + <div class="ruta-container">
53 + <header class="module-heading"><h1>Mi Ruta MAPS</h1><p>Explora y arma la ruta de certificados de tu carrera.</p></header>
54 + <section class="card empty-state-box">
55 + <h2>${sinPerfil ? 'Completa tu perfil de estudiante' : 'No se pudo cargar tu ruta'}</h2>
56 + <p>${sinPerfil
57 + ? 'Para ver tu ruta MAPS primero debes completar tu perfil de estudiante y elegir tu carrera.'
58 + : 'Ocurrió un problema al consultar tu ruta. Inténtalo de nuevo en un momento.'}</p>
59 + ${sinPerfil ? '<a class="btn-solid" href="onboarding.html">Completar mi perfil</a>' : ''}
60 + </section>
61 + </div>
62 + `;
63 +}
64 +
65 +let estadoRuta = { estudiante: null, ruta: [], catalogo: [] };
66 +
67 +function renderRuta(content, data) {
68 + estadoRuta = {
69 + estudiante: data.estudiante || null,
70 + ruta: Array.isArray(data.ruta) ? data.ruta : [],
71 + catalogo: Array.isArray(data.catalogo) ? data.catalogo : []
72 + };
73 +
74 + const estudiante = estadoRuta.estudiante;
75 + const semestreActual = Number(estudiante?.semestre) || 0;
76 + const maxCertificados = 3;
77 + const enRuta = estadoRuta.ruta.length;
78 +
79 + content.innerHTML = `
80 + <div class="ruta-container">
81 + <header class="module-heading">
82 + <h1>Mi Ruta MAPS</h1>
83 + <p>Tu plan de certificados ${estudiante?.carrera ? `en <strong>${escapeHtml(estudiante.carrera)}</strong>` : ''}, ordenado por el semestre sugerido de sus materias.</p>
84 + </header>
85 + ${estudiante ? renderBanner(estudiante, semestreActual, enRuta, maxCertificados) : ''}
86 + <section id="ruta-section" class="ruta-section" aria-labelledby="ruta-title">
87 + <h2 class="ruta-section-title" id="ruta-title">Tu ruta ${enRuta ? `<small>${enRuta} de ${maxCertificados} certificados</small>` : ''}</h2>
88 + ${enRuta ? estadoRuta.ruta.map(renderRutaBlock).join('') : renderRutaVacia()}
89 + </section>
90 + <section class="ruta-section" aria-labelledby="catalogo-title">
91 + <h2 class="ruta-section-title" id="catalogo-title">Catálogo de certificados</h2>
92 + ${estadoRuta.catalogo.map(cert => renderCatalogoBlock(cert, semestreActual, enRuta, maxCertificados)).join('')}
93 + </section>
94 + <p class="ruta-footer-note">Sugerencia: arma tu ruta con hasta ${maxCertificados} certificados según los intereses de tu carrera.</p>
95 + </div>
96 + `;
97 +}
98 +
99 +function renderBanner(estudiante, semestreActual, enRuta, maxCertificados) {
100 + const bloque = semestreActual
101 + ? `Semestre vigente: <strong>${semestreActual}°</strong>`
102 + : 'Sin semestre asignado';
103 + return `
104 + <section class="card ruta-banner" aria-labelledby="ruta-banner-title">
105 + <h2 id="ruta-banner-title">${escapeHtml(estudiante.matricula || 'Estudiante')}</h2>
106 + <p>${bloque} • Certificados seleccionados: <strong>${enRuta} de ${maxCertificados}</strong></p>
107 + </section>
108 + `;
109 +}
110 +
111 +function renderRutaVacia() {
112 + return `
113 + <section class="card empty-state-box">
114 + <h2>Aún no tienes certificados en tu ruta</h2>
115 + <p>Explora el catálogo de abajo y añade hasta 3 certificados relacionados con tu carrera. Seguirán su semestre sugerido en el plan de estudios.</p>
116 + </section>
117 + `;
118 +}
119 +
120 +function renderRutaBlock(cert) {
121 + return renderCertificadoCard(cert, true);
122 +}
123 +
124 +function renderCatalogoBlock(cert, semestreActual, enRuta, maxCertificados) {
125 + const lleno = enRuta >= maxCertificados && !cert.enRuta;
126 + return renderCertificadoCard(cert, false, lleno);
127 +}
128 +
129 +function renderCertificadoCard(cert, enRuta, lleno) {
130 + const rango = rangoSemestres(cert);
131 + const estado = estadoCertificado(cert, enRuta);
132 + const materias = Array.isArray(cert.materias) ? cert.materias : [];
133 + const chips = materias.map(materia =>
134 + `<span class="materia-chip">${escapeHtml(materia.nombre || 'Materia')}${Number.isFinite(Number(materia.semestre)) ? ` · S.${materia.semestre}` : ''}</span>`
135 + ).join('');
136 +
137 + const cuerpo = `
138 + <div class="ruta-block-head">
139 + <div>
140 + <h3 class="ruta-block-title">${escapeHtml(cert.nombre || 'Certificado')}</h3>
141 + ${rango ? `<span class="ruta-block-range">${escapeHtml(rango)}</span>` : ''}
142 + </div>
143 + <div class="ruta-badges">${estado.badges}</div>
144 + </div>
145 + ${cert.descripcion ? `<p class="ruta-block-desc">${escapeHtml(cert.descripcion)}</p>` : ''}
146 + ${chips ? `<div class="ruta-materias">${chips}</div>` : ''}
147 + `;
148 +
149 + const acciones = enRuta
150 + ? `<div class="ruta-actions"><button class="btn-clean" type="button" disabled>En tu ruta</button></div>`
151 + : `<div class="ruta-actions"><button class="btn-solid" type="button" data-action="agregar" data-cert-id="${escapeHtml(cert.id ?? '')}"${lleno ? ' disabled' : ''}>${lleno ? 'Ruta completa' : 'Añadir a mi ruta'}</button></div>`;
152 +
153 + return `
154 + <article class="card ruta-block">
155 + ${cuerpo}
156 + ${acciones}
157 + </article>
158 + `;
159 +}
160 +
161 +function rangoSemestres(cert) {
162 + const min = Number.isFinite(Number(cert.semestreMin)) ? Number(cert.semestreMin) : null;
163 + const max = Number.isFinite(Number(cert.semestreMax)) ? Number(cert.semestreMax) : null;
164 + if (!min && !max) return '';
165 + if (!min || !max) return `Semestre ${min || max}`;
166 + return min === max ? `Semestre ${min}` : `Semestres ${min} – ${max}`;
167 +}
168 +
169 +function estadoCertificado(cert, enRuta) {
170 + const semestreActual = Number(estadoRuta.estudiante?.semestre) || 0;
171 + const min = Number.isFinite(Number(cert.semestreMin)) ? Number(cert.semestreMin) : null;
172 + const max = Number.isFinite(Number(cert.semestreMax)) ? Number(cert.semestreMax) : null;
173 +
174 + let badge = '';
175 + if (enRuta) {
176 + badge = '<span class="badge-ruta">En tu ruta</span>';
177 + }
178 +
179 + if (min == null && max == null) {
180 + badge += ' <span class="badge-proximo">Sin semestre asociado</span>';
181 + return { badges: badge };
182 + }
183 +
184 + if (max != null && semestreActual > max) {
185 + badge += ' <span class="badge-cursado">Cursado</span>';
186 + } else if (semestreActual >= (min ?? 1) && (max == null || semestreActual <= max)) {
187 + badge += ' <span class="badge-encurso">En curso</span>';
188 + } else {
189 + badge += ' <span class="badge-proximo">Próximo</span>';
190 + }
191 + return { badges: badge };
192 +}
193 +
194 +async function handlerAgregar(event) {
195 + const btn = event.target.closest('button[data-action="agregar"]');
196 + if (!btn) return;
197 +
198 + const estudianteId = estadoRuta.estudiante?.id;
199 + const certificadoId = btn.dataset.certId;
200 + if (!estudianteId || !certificadoId) return;
201 +
202 + btn.disabled = true;
203 + try {
204 + await API.request('/certificados/asignar', {
205 + method: 'POST',
206 + body: JSON.stringify({ idEstudiante: Number(estudianteId), idCertificado: Number(certificadoId) })
207 + });
208 + Utils.toast('Certificado añadido a tu ruta MAPS.', 'success');
209 + const content = document.getElementById('main-content');
210 + if (content) {
211 + content.innerHTML = '<div class="ruta-container"><p class="loading-message">Actualizando tu ruta…</p></div>';
212 + const data = await API.request('/estudiantes/ruta');
213 + renderRuta(content, data || {});
214 + }
215 + } catch (error) {
216 + btn.disabled = false;
217 + Utils.toast(error.message || 'No se pudo añadir el certificado.', 'error');
218 + }
219 +}
220 +
221 +function escapeHtml(value) {
222 + return String(value ?? '').replace(/[&<>'"]/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' })[character]);
223 +}
\ No newline at end of file
frontend/js/pages/circulos.js
+189 -24
@@ -1,9 +1,10 @@
1 /**
2 * Círculos de estudio.
3 * Contrato esperado de GET /circulos:
4 - * { sesiones, misGrupos } con sesiones [{ id, titulo, materia, fecha, enVivo,
5 - * moderador, plataforma, descripcion, asistentes }] y misGrupos [{ id, nombre,
6 - * descripcion, miembros }].
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');
@@ -16,31 +17,32 @@ document.addEventListener('DOMContentLoaded', async () => {
17 });
18
19 async function loadCircles(content, search = '') {
19 - let response = {};
20 + let sessions = [];
21 + let groups = [];
22 try {
21 - response = await API.request(`/circulos${search ? `?busqueda=${encodeURIComponent(search)}` : ''}`);
22 - } catch {
23 - // El endpoint se conectará cuando el módulo esté disponible en backend.
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 }
25 -
26 - const sessions = Array.isArray(response.sesiones) ? response.sesiones : [];
27 - const groups = Array.isArray(response.misGrupos) ? response.misGrupos : [];
28 - renderCircles(content, sessions, groups);
29 + renderCircles(content, sessions, groups, search);
30 }
31
31 -function renderCircles(content, sessions, groups) {
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>
37 - <input class="search-input" id="circle-search" name="busqueda" type="search" placeholder="Buscar círculo o sesión por materia...">
38 - <button class="btn-solid" type="button" id="create-circle">+ Crear nuevo círculo</button>
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
@@ -48,32 +50,195 @@ function renderCircles(content, sessions, groups) {
50 event.preventDefault();
51 loadCircles(content, new FormData(event.currentTarget).get('busqueda')?.trim());
52 });
51 - document.getElementById('create-circle')?.addEventListener('click', () => {
52 - // La pantalla de creación se conectará cuando esté disponible el endpoint POST /circulos.
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) {
57 - const live = Boolean(session.enVivo);
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 `
59 - <article class="card session-card ${live ? 'live-session' : ''}">
60 - <div class="session-header"><div class="badges-wrap">${live ? '<span class="badge badge-live">● En vivo ahora</span>' : session.fecha ? `<span class="badge badge-time">${escapeHtml(session.fecha)}</span>` : ''}${session.materia ? `<span class="badge badge-materia">${escapeHtml(session.materia)}</span>` : ''}</div>${session.sala ? `<span class="attendees-count">${escapeHtml(session.sala)}</span>` : ''}</div>
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>
62 - ${(session.moderador || session.plataforma) ? `<p class="session-info">${session.moderador ? `Moderador: <strong>${escapeHtml(session.moderador)}</strong>` : ''}${session.moderador && session.plataforma ? ' · ' : ''}${session.plataforma ? `Plataforma: <strong>${escapeHtml(session.plataforma)}</strong>` : ''}</p>` : ''}
107 + <p class="session-info">${info}</p>
108 ${session.descripcion ? `<p class="session-desc">${escapeHtml(session.descripcion)}</p>` : ''}
64 - <div class="session-footer">${Number.isFinite(Number(session.asistentes)) ? `<span class="attendees-count">👥 ${Number(session.asistentes)} ${live ? 'estudiantes conectados' : 'confirmados'}</span>` : ''}<div class="session-actions">${session.materialDisponible ? '<button class="btn-clean" type="button">Ver material de apoyo</button>' : ''}<button class="btn-solid" type="button">${live ? 'Entrar a sala' : 'Confirmar asistencia'}</button></div></div>
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) {
70 - 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">Ver grupo →</button></div></article>`;
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() {
74 - return '<section class="card empty-circles"><h2>Aún no hay sesiones programadas</h2><p>Las sesiones de repaso y los círculos disponibles aparecerán aquí cuando sean creados.</p></section>';
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]);
79 -}
244 +}
\ No newline at end of file
frontend/js/pages/comunidad.js
+611 -17
@@ -8,22 +8,75 @@ const Comunidad = {
8 foro: [],
9 apuntes: [],
10 tips: [],
11 + materiasModal: [],
12 loaded: new Set(),
13 activeTab: 'foro-dudas'
14 },
15
15 - init() {
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() {
@@ -42,6 +95,14 @@ const Comunidad = {
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
@@ -59,9 +120,9 @@ const Comunidad = {
120
121 async loadTab(tabId) {
122 const map = {
62 - 'foro-dudas': { key: 'foro', loader: () => this.loadForo() },
63 - 'apuntes': { key: 'apuntes', loader: () => this.loadApuntes() },
64 - 'tips': { key: 'tips', loader: () => this.loadTips() }
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;
@@ -128,6 +189,53 @@ const Comunidad = {
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;
@@ -150,6 +258,7 @@ const Comunidad = {
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) {
@@ -163,34 +272,56 @@ const Comunidad = {
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>` : '',
168 - resuelto ? `<span class="hub-badge badge-solved">✓ Solución Aceptada</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
175 - const metaTiempo = tiempo ? `Por: ${this.esc(autor)} • ${this.esc(tiempo)}` : `Por: ${this.esc(autor)}`;
301 const accionRespuesta = resuelto
177 - ? `<button class="btn-link">💬 ${respuestas} Respuestas</button>`
178 - : `<button class="btn-link" style="color:#005a2b;font-weight:bold;">✍ Aportar Respuesta</button>`;
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>
184 - <span class="post-meta">${this.esc(metaTiempo)}</span>
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">
191 - <button class="btn-link">▲ ${votos} Votos</button>
322 + <button class="btn-link foro-action" data-action="voto">${votos} Votos</button>
323 ${accionRespuesta}
193 - <button class="btn-link">🔖 Guardar</button>
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>
@@ -198,14 +329,32 @@ const Comunidad = {
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';
205 - const tamano = this.field(item, 'tamano') || this.field(item, 'tamanio') || '';
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">
@@ -214,27 +363,29 @@ const Comunidad = {
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>
217 - <p style="margin-top:4px;color:#888;">${tamano ? `Tamaño: ${this.esc(tamano)} • ` : ''}${descargas} descargas${rating ? ` • ${this.esc(rating)} ★` : ''}</p>
366 + <p style="margin-top:4px;color:#888;">${infoExtra}</p>
367 </div>
368 </div>
220 - <button class="btn-solid" type="button" data-href="${this.esc(url)}">⬇ Descargar Documento</button>
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
233 - ? `<span class="hub-badge badge-verified">✓ Verificado por Docente</span>`
384 + ? `<span class="hub-badge badge-verified">Verificado por Docente</span>`
385 : '';
386
387 return `
237 - <article class="card tip-card">
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>
@@ -244,12 +395,446 @@ const Comunidad = {
395 <p class="tip-text">${this.esc(texto)}</p>
396 </div>
397 <div class="post-footer">
247 - <span class="vote-badge">▲ ${votos} votos de utilidad</span>
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 '';
@@ -266,6 +851,7 @@ const Comunidad = {
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 },
@@ -286,6 +872,14 @@ const Comunidad = {
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;')
frontend/js/pages/docente.js new
+253
@@ -0,0 +1,253 @@
1 +/**
2 + * Panel Docente - Gestion y Acompanamiento Academico.
3 + * Contrato de GET /docente/stats:
4 + * { perfil: { nombre, nomina, area, horario, enlace, rol },
5 + * kpis: { dudasPorValidar, circulosACargo, alumnosAsesorados, descargasApuntes },
6 + * dudas: [{ id, titulo, contenido, materia, autor }],
7 + * asesorias: [{ id, titulo, materia, fecha, hora, inscritos, cupoMax, enlace }] }
8 + */
9 +document.addEventListener('DOMContentLoaded', async () => {
10 + Layout.init('docente.html');
11 + Layout.setPageTitle('Gestion Docente');
12 +
13 + const content = document.getElementById('main-content');
14 + if (!content) return;
15 +
16 + content.innerHTML = '<div class="docente-container"><p class="loading-message">Cargando panel docente...</p></div>';
17 +
18 + let data = {};
19 + try {
20 + data = await API.request('/docente/stats');
21 + } catch (e) {
22 + content.innerHTML = `<div class="docente-container"><div class="card docente-empty">No se pudo cargar el panel: ${Utils.esc(e.message || 'sin permisos de profesor')}</div></div>`;
23 + return;
24 + }
25 +
26 + renderDocente(content, data || {});
27 +});
28 +
29 +function renderDocente(content, data) {
30 + const perfil = data.perfil || {};
31 + const kpis = data.kpis || {};
32 + const dudas = Array.isArray(data.dudas) ? data.dudas : [];
33 + const asesorias = Array.isArray(data.asesorias) ? data.asesorias : [];
34 +
35 + content.innerHTML = `
36 + <div class="docente-container">
37 + <header class="card docente-header">
38 + <div class="header-info">
39 + <h2>Espacio de Acompanamiento Academico</h2>
40 + <p>
41 + Profesor: <strong>${Utils.esc(perfil.nombre || '—')}</strong>
42 + &bull; Nomina: <strong>${Utils.esc(perfil.nomina || '—')}</strong>
43 + &bull; ${Utils.esc(perfil.area || '—')}
44 + </p>
45 + </div>
46 + <div>
47 + <span class="docente-badge">Rol: ${Utils.esc(perfil.rol || 'Asesor Docente')}</span>
48 + </div>
49 + </header>
50 +
51 + <div class="docente-kpi-grid">
52 + <div class="docente-kpi">
53 + <div class="val">${Number(kpis.dudasPorValidar) || 0}</div>
54 + <div class="lbl">Dudas por Validar</div>
55 + </div>
56 + <div class="docente-kpi">
57 + <div class="val">${Number(kpis.circulosACargo) || 0}</div>
58 + <div class="lbl">Circulos a mi Cargo</div>
59 + </div>
60 + <div class="docente-kpi">
61 + <div class="val">${Number(kpis.alumnosAsesorados) || 0}</div>
62 + <div class="lbl">Alumnos Asesorados</div>
63 + </div>
64 + <div class="docente-kpi">
65 + <div class="val">${Number(kpis.descargasApuntes) || 0}</div>
66 + <div class="lbl">Descargas de mis Apuntes</div>
67 + </div>
68 + </div>
69 +
70 + <section class="card docente-section">
71 + <div class="docente-section-header">
72 + <h3>Dudas de tus Grupos que Esperan Visto Bueno</h3>
73 + </div>
74 + ${dudas.length ? `
75 + <table class="docente-table">
76 + <thead>
77 + <tr>
78 + <th>Materia</th>
79 + <th>Pregunta del Alumno</th>
80 + <th>Autor</th>
81 + <th>Accion Docente</th>
82 + </tr>
83 + </thead>
84 + <tbody>
85 + ${dudas.map(d => `
86 + <tr>
87 + <td><strong>${Utils.esc(d.materia || 'General')}</strong></td>
88 + <td>${Utils.esc(d.titulo || d.contenido || '')}</td>
89 + <td>${Utils.esc(d.autor || '—')}</td>
90 + <td><button class="btn-solid docente-btn-validar" data-id="${d.id}" style="padding: 5px 10px; font-size: 0.8rem;">Validar y Cerrar</button></td>
91 + </tr>`).join('')}
92 + </tbody>
93 + </table>` : '<p class="docente-empty">No hay dudas pendientes por validar.</p>'}
94 + </section>
95 +
96 + <section class="card docente-section">
97 + <div class="docente-section-header">
98 + <h3>Mis Salases de Asesoria Programadas</h3>
99 + <button class="btn-outline" id="btn-agendar">+ Agendar Nueva Asesoria</button>
100 + </div>
101 + <p class="docente-meta">
102 + Horario configurado: <strong>${Utils.esc(perfil.horario || 'Sin horario configurado')}</strong>
103 + ${perfil.enlace ? ` &bull; Sala virtual: <a class="docente-link" href="${Utils.esc(perfil.enlace)}" target="_blank">${Utils.esc(perfil.enlace)}</a>` : ''}
104 + </p>
105 + ${asesorias.length ? `
106 + <table class="docente-table">
107 + <thead>
108 + <tr>
109 + <th>Fecha y Hora</th>
110 + <th>Materia / Tema</th>
111 + <th>Inscritos</th>
112 + <th>Enlace</th>
113 + </tr>
114 + </thead>
115 + <tbody>
116 + ${asesorias.map(a => `
117 + <tr>
118 + <td>${Utils.esc(a.fecha || '')} ${Utils.esc(a.hora || '')}</td>
119 + <td>${Utils.esc(a.materia || '')} &mdash; ${Utils.esc(a.titulo || '')}</td>
120 + <td>${a.inscritos} / ${a.cupoMax}</td>
121 + <td>${a.enlace ? `<a class="docente-link" href="${Utils.esc(a.enlace)}" target="_blank">Abrir sala</a>` : '<span style="color:#999">—</span>'}</td>
122 + </tr>`).join('')}
123 + </tbody>
124 + </table>` : '<p class="docente-empty">No hay sesiones de asesoria programadas.</p>'}
125 + </section>
126 + </div>
127 + `;
128 +
129 + document.querySelectorAll('.docente-btn-validar').forEach(btn => {
130 + btn.addEventListener('click', async () => {
131 + const id = btn.getAttribute('data-id');
132 + if (!id) return;
133 + btn.disabled = true;
134 + btn.textContent = 'Cerrando...';
135 + try {
136 + await API.request(`/docente/dudas/${id}/validar`, { method: 'POST' });
137 + Utils.toast('Duda validada y cerrada.', 'success');
138 + refresh();
139 + } catch (e) {
140 + Utils.toast(e.message || 'No se pudo validar la duda.', 'error');
141 + btn.disabled = false;
142 + btn.textContent = 'Validar y Cerrar';
143 + }
144 + });
145 + });
146 +
147 + document.getElementById('btn-agendar')?.addEventListener('click', () => {
148 + showAgendarForm(content);
149 + });
150 +}
151 +
152 +function refresh() {
153 + const content = document.getElementById('main-content');
154 + if (!content) return;
155 + content.innerHTML = '<div class="docente-container"><p class="loading-message">Cargando panel docente...</p></div>';
156 + API.request('/docente/stats')
157 + .then(data => renderDocente(content, data || {}))
158 + .catch(e => {
159 + content.innerHTML = `<div class="docente-container"><div class="card docente-empty">No se pudo cargar el panel: ${Utils.esc(e.message || 'error')}</div></div>`;
160 + });
161 +}
162 +
163 +function showAgendarForm(content) {
164 + const overlay = document.createElement('div');
165 + overlay.className = 'modal-overlay';
166 + overlay.innerHTML = `
167 + <div class="modal docente-asistencia-modal">
168 + <h3>Agendar Nueva Asesoria</h3>
169 + <form id="agendar-form">
170 + <label>Titulo / Tema</label>
171 + <input type="text" id="ag-titulo" required placeholder="Ej. Repaso de Patrones de Diseño">
172 +
173 + <label>Materia</label>
174 + <input type="text" id="ag-materia" required placeholder="Ej. Arquitectura de Software">
175 +
176 + <label>Descripcion</label>
177 + <textarea id="ag-descripcion" rows="3" placeholder="Temas que se cubriran..."></textarea>
178 +
179 + <div class="ag-row">
180 + <div>
181 + <label>Fecha</label>
182 + <input type="date" id="ag-fecha" required>
183 + </div>
184 + <div>
185 + <label>Hora</label>
186 + <input type="time" id="ag-hora" required>
187 + </div>
188 + </div>
189 +
190 + <div class="ag-row">
191 + <div>
192 + <label>Modalidad</label>
193 + <select id="ag-modalidad">
194 + <option value="Grupal">Grupal</option>
195 + <option value="Individual">Individual</option>
196 + <option value="Virtual">Virtual</option>
197 + </select>
198 + </div>
199 + <div>
200 + <label>Ubicacion</label>
201 + <input type="text" id="ag-ubicacion" placeholder="Salon o enlace de sala">
202 + </div>
203 + </div>
204 +
205 + <div class="ag-row">
206 + <div>
207 + <label>Duracion (min)</label>
208 + <input type="number" id="ag-duracion" value="90" min="15">
209 + </div>
210 + <div>
211 + <label>Cupo maximo</label>
212 + <input type="number" id="ag-cupo" value="30" min="1">
213 + </div>
214 + </div>
215 +
216 + <div class="modal-actions">
217 + <button type="button" class="btn-outline" id="ag-cancelar">Cancelar</button>
218 + <button type="submit" class="btn-solid">Programar Asesoria</button>
219 + </div>
220 + </form>
221 + </div>
222 + `;
223 + document.body.appendChild(overlay);
224 +
225 + overlay.querySelector('#ag-cancelar').addEventListener('click', () => overlay.remove());
226 + overlay.querySelector('#agendar-form').addEventListener('submit', async (e) => {
227 + e.preventDefault();
228 + const payload = {
229 + titulo: overlay.querySelector('#ag-titulo').value.trim(),
230 + materia: overlay.querySelector('#ag-materia').value.trim(),
231 + descripcion: overlay.querySelector('#ag-descripcion').value.trim(),
232 + fecha: overlay.querySelector('#ag-fecha').value,
233 + hora: overlay.querySelector('#ag-hora').value,
234 + modalidad: overlay.querySelector('#ag-modalidad').value,
235 + ubicacion: overlay.querySelector('#ag-ubicacion').value.trim() || null,
236 + duracionMin: parseInt(overlay.querySelector('#ag-duracion').value, 10) || 90,
237 + cupoMax: parseInt(overlay.querySelector('#ag-cupo').value, 10) || 30
238 + };
239 + const submitBtn = overlay.querySelector('button[type="submit"]');
240 + submitBtn.disabled = true;
241 + submitBtn.textContent = 'Programando...';
242 + try {
243 + await API.request('/docente/asesorias', { method: 'POST', body: JSON.stringify(payload) });
244 + Utils.toast('Asesoria programada.', 'success');
245 + overlay.remove();
246 + refresh();
247 + } catch (err) {
248 + Utils.toast(err.message || 'No se pudo programar la asesoria.', 'error');
249 + submitBtn.disabled = false;
250 + submitBtn.textContent = 'Programar Asesoria';
251 + }
252 + });
253 +}
frontend/js/pages/empresarial.js
+324 -16
@@ -1,8 +1,9 @@
1 /**
2 * Directorio de Semestre Empresarial.
3 * Contrato esperado de GET /empresarial:
4 - * { empresas: [{ id, nombre, ciudad, estado, modalidad, calificacion,
5 - * totalResenas, descripcion, convenioActivo, tecnologias, experienciaDestacada }] }
4 + * { empresas: [{ id, nombre, sector, calificacion,
5 + * totalResenas, descripcion, experienciaDestacada }] }
6 + * Reseñas: GET/POST/PUT/DELETE /empresarial/{id}/resenas.
7 */
8 document.addEventListener('DOMContentLoaded', async () => {
9 Layout.init('empresarial.html');
@@ -12,13 +13,24 @@ document.addEventListener('DOMContentLoaded', async () => {
13 if (!content) return;
14
15 content.innerHTML = '<div class="empresarial-container"><p class="loading-message">Cargando empresas vinculadas…</p></div>';
16 + content.addEventListener('click', event => {
17 + const target = event.target.closest('button[data-action]');
18 + if (!target) return;
19 + if (target.dataset.action === 'experiencias' || target.dataset.action === 'resenar') {
20 + const id = target.dataset.companyId;
21 + if (id) abrirModalEmpresa(id, target.dataset.action === 'resenar');
22 + } else if (target.dataset.action === 'requisitos') {
23 + Utils.toast('Los requisitos de vinculación se habilitan cuando el backend de convenios esté integrado.', 'info');
24 + }
25 + });
26 await loadCompanies(content);
27 });
28
29 +let empresasActuales = [];
30 +
31 async function loadCompanies(content, filters = {}) {
32 const params = new URLSearchParams();
33 if (filters.busqueda) params.set('busqueda', filters.busqueda);
21 - if (filters.modalidad) params.set('modalidad', filters.modalidad);
34 if (filters.calificacionMinima) params.set('calificacionMinima', filters.calificacionMinima);
35
36 let response = {};
@@ -29,21 +41,35 @@ async function loadCompanies(content, filters = {}) {
41 }
42
43 const companies = Array.isArray(response) ? response : (Array.isArray(response.empresas) ? response.empresas : []);
32 - renderCompanies(content, companies);
44 + const carreraFiltrada = response && !Array.isArray(response) ? response.carreraFiltrada || '' : '';
45 + empresasActuales = companies;
46 + renderCompanies(content, companies, filters.sector || '', carreraFiltrada);
47 }
48
35 -function renderCompanies(content, companies) {
49 +function renderCompanies(content, companies, sectorSeleccionado = '', carreraFiltrada = '') {
50 + const sectores = [...new Set((companies || [])
51 + .map(c => (c.sector || '').trim())
52 + .filter(Boolean))]
53 + .sort((a, b) => a.localeCompare(b, 'es'));
54 + const sectorOptions = sectores
55 + .map(s => `<option value="${escapeHtml(s)}"${s === sectorSeleccionado ? ' selected' : ''}>${escapeHtml(s)}</option>`)
56 + .join('');
57 +
58 + const visibles = sectorSeleccionado
59 + ? companies.filter(c => (c.sector || '').trim() === sectorSeleccionado)
60 + : companies;
61 +
62 content.innerHTML = `
63 <div class="empresarial-container">
38 - <header class="module-heading"><h1>Semestre Empresarial</h1><p>Explora empresas vinculadas y experiencias compartidas por la comunidad.</p></header>
64 + <header class="module-heading"><h1>Semestre Empresarial</h1><p>Explora empresas vinculadas y experiencias compartidas por la comunidad.</p>${carreraFiltrada ? `<p class="filter-note">Mostrando empresas afines a tu carrera: <strong>${escapeHtml(carreraFiltrada)}</strong></p>` : ''}</header>
65 <form class="filter-bar" id="company-filter-form">
66 <label class="visually-hidden" for="company-search">Buscar empresa</label>
67 <input class="input-search" id="company-search" name="busqueda" type="search" placeholder="Buscar por empresa, tecnología o proyecto...">
42 - <select class="select-filter" name="modalidad" aria-label="Filtrar por modalidad"><option value="">Todas las modalidades</option><option value="HIBRIDO">Híbrido</option><option value="REMOTO">Remoto</option><option value="PRESENCIAL">Presencial</option></select>
68 + <select class="select-filter" name="sector" aria-label="Filtrar por sector"><option value="">Todos los sectores</option>${sectorOptions}</select>
69 <select class="select-filter" name="calificacionMinima" aria-label="Filtrar por calificación"><option value="">Calificación: todas</option><option value="4.5">4.5 estrellas o más</option><option value="4.0">4.0 estrellas o más</option></select>
70 <button class="btn-solid" type="submit">Buscar</button>
71 </form>
46 - <section id="company-results" aria-live="polite">${companies.length ? companies.map(renderCompany).join('') : renderEmptyCompanies()}</section>
72 + <section id="company-results" aria-live="polite">${visibles.length ? visibles.map(renderCompany).join('') : renderEmptyCompanies()}</section>
73 </div>
74 `;
75
@@ -58,20 +84,24 @@ function renderCompany(company) {
84 const rating = Number(company.calificacion);
85 const hasRating = Number.isFinite(rating) && rating > 0;
86 const location = [company.ciudad, company.estado].filter(Boolean).join(', ');
61 - const locationAndMode = [location, company.modalidad && `Modalidad: ${formatMode(company.modalidad)}`].filter(Boolean).join(' · ');
87 const technologies = Array.isArray(company.tecnologias) ? company.tecnologias : [];
88 const experience = company.experienciaDestacada;
89
90 + const tags = [];
91 + if (company.sector) tags.push(`<span class="badge badge-blue">Sector: ${escapeHtml(company.sector)}</span>`);
92 + if (company.convenioActivo) tags.push('<span class="badge badge-green">Convenio oficial activo</span>');
93 + tags.push(...technologies.map(item => `<span class="badge badge-blue">${escapeHtml(item)}</span>`));
94 +
95 return `
96 <article class="card company-card">
97 <header class="company-header">
68 - <div><h2 class="company-name">${escapeHtml(company.nombre || 'Empresa')}</h2>${locationAndMode ? `<span class="company-location">${escapeHtml(locationAndMode)}</span>` : ''}</div>
98 + <div><h2 class="company-name">${escapeHtml(company.nombre || 'Empresa')}</h2>${location ? `<span class="company-location">${escapeHtml(location)}</span>` : ''}</div>
99 ${hasRating ? `<div class="rating-box"><span class="stars" aria-label="${rating} de 5 estrellas">${renderStars(rating)}</span><strong class="rating-number">${rating.toFixed(1)}</strong>${Number.isFinite(Number(company.totalResenas)) ? `<span class="rating-count">(${Number(company.totalResenas)} reseñas)</span>` : ''}</div>` : ''}
100 </header>
101 ${company.descripcion ? `<p class="company-desc"><strong>¿Qué hacen?</strong> ${escapeHtml(company.descripcion)}</p>` : ''}
72 - ${(company.convenioActivo || technologies.length) ? `<div class="tags-group">${company.convenioActivo ? '<span class="badge badge-green">Convenio oficial activo</span>' : ''}${technologies.map(item => `<span class="badge badge-blue">${escapeHtml(item)}</span>`).join('')}</div>` : ''}
102 + ${tags.length ? `<div class="tags-group">${tags.join('')}</div>` : ''}
103 ${experience ? renderExperience(experience) : ''}
74 - <footer class="company-footer"><button class="btn-clean" type="button" data-company-id="${escapeHtml(company.id ?? '')}">Ver experiencias${Number.isFinite(Number(company.totalResenas)) ? ` (${Number(company.totalResenas)})` : ''}</button><button class="btn-solid" type="button" data-company-id="${escapeHtml(company.id ?? '')}">Ver requisitos de vinculación</button></footer>
104 + <footer class="company-footer"><button class="btn-clean" type="button" data-action="experiencias" data-company-id="${escapeHtml(company.id ?? '')}">Ver experiencias${Number.isFinite(Number(company.totalResenas)) ? ` (${Number(company.totalResenas)})` : ''}</button><button class="btn-solid" type="button" data-action="resenar" data-company-id="${escapeHtml(company.id ?? '')}">Compartir tu experiencia</button></footer>
105 </article>
106 `;
107 }
@@ -90,10 +120,288 @@ function renderStars(rating) {
120 return '★'.repeat(Math.min(5, filled)) + '☆'.repeat(Math.max(0, 5 - filled));
121 }
122
93 -function formatMode(mode) {
94 - return String(mode).toLowerCase().replace(/^./, character => character.toUpperCase());
95 -}
96 -
123 function escapeHtml(value) {
124 return String(value ?? '').replace(/[&<>'"]/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' })[character]);
125 }
126 +
127 +// ------- Modal de experiencias y reseñas -------
128 +
129 +let modalBackdrop = null;
130 +let modalEmpresaId = null;
131 +let modalEmpresa = null;
132 +let calificacionSeleccionada = 0;
133 +let resenaEnviandose = false;
134 +
135 +function abrirModalEmpresa(companyId, enfocarFormulario) {
136 + calificacionSeleccionada = 0;
137 + modalEmpresaId = String(companyId);
138 + modalEmpresa = empresasActuales.find(empresa => String(empresa.id) === modalEmpresaId) || null;
139 +
140 + if (modalBackdrop) modalBackdrop.remove();
141 +
142 + const rating = Number(modalEmpresa?.calificacion);
143 + const headerSub = Number.isFinite(rating) && rating > 0
144 + ? `Calificación promedio ${rating.toFixed(1)} ★ · ${modalEmpresa.totalResenas ?? 0} reseñas`
145 + : 'Aún sin calificaciones';
146 +
147 + modalBackdrop = document.createElement('div');
148 + modalBackdrop.className = 'empresa-modal-backdrop';
149 + modalBackdrop.innerHTML = `
150 + <div class="empresa-modal" role="dialog" aria-modal="true" aria-label="Experiencias de ${escapeHtml(modalEmpresa?.nombre || 'la empresa')}">
151 + <header class="empresa-modal-header">
152 + <div>
153 + <h2 class="empresa-modal-title">${escapeHtml(modalEmpresa?.nombre || 'Empresa')}</h2>
154 + <span class="empresa-modal-sub">${escapeHtml(headerSub)}</span>
155 + </div>
156 + <button class="empresa-modal-close" type="button" aria-label="Cerrar">&times;</button>
157 + </header>
158 + <div class="empresa-modal-body">
159 + <div id="experiencias-list"><p class="loading-message">Cargando experiencias…</p></div>
160 + <div id="resena-form-section"></div>
161 + </div>
162 + <footer class="empresa-modal-footer"><button class="btn-clean" type="button" data-modal-close>Cerrar</button></footer>
163 + </div>
164 + `;
165 + document.body.appendChild(modalBackdrop);
166 + document.body.classList.add('empresa-modal-open');
167 +
168 + modalBackdrop.addEventListener('click', event => {
169 + if (event.target === modalBackdrop || event.target.closest('[data-modal-close]')) {
170 + cerrarModalEmpresa();
171 + return;
172 + }
173 +
174 + const btn = event.target.closest('button[data-action="editar-resena"], button[data-action="eliminar-resena"]');
175 + if (!btn) return;
176 +
177 + if (btn.dataset.action === 'editar-resena') {
178 + abrirEdicionResena();
179 + return;
180 + }
181 +
182 + if (Number(btn.dataset.step) === 0) {
183 + btn.dataset.step = '1';
184 + btn.textContent = '¿Seguro?';
185 + setTimeout(() => {
186 + btn.dataset.step = '0';
187 + btn.textContent = 'Eliminar';
188 + }, 3500);
189 + return;
190 + }
191 + btn.disabled = true;
192 + eliminarMiResena(btn);
193 + });
194 +
195 + cargarExperiencias();
196 + if (enfocarFormulario) {
197 + setTimeout(() => document.getElementById('resena-form-section')?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 150);
198 + }
199 +}
200 +
201 +function cerrarModalEmpresa() {
202 + modalBackdrop?.remove();
203 + modalBackdrop = null;
204 + modalEmpresaId = null;
205 + modalEmpresa = null;
206 + document.body.classList.remove('empresa-modal-open');
207 +}
208 +
209 +let modalResenas = [];
210 +let modalMiResena = null;
211 +
212 +async function cargarExperiencias() {
213 + const listContainer = modalBackdrop?.querySelector('#experiencias-list');
214 + const formContainer = modalBackdrop?.querySelector('#resena-form-section');
215 + if (!listContainer || !modalEmpresaId) return;
216 +
217 + listContainer.innerHTML = '<p class="loading-message">Cargando experiencias…</p>';
218 + formContainer.innerHTML = '';
219 +
220 + let reviews = [];
221 + try {
222 + const response = await API.request(`/empresarial/${modalEmpresaId}/resenas`);
223 + reviews = Array.isArray(response) ? response : [];
224 + } catch {
225 + listContainer.innerHTML = '<p class="loading-message">No se pudieron cargar las experiencias. Inténtalo de nuevo.</p>';
226 + return;
227 + }
228 +
229 + modalResenas = reviews;
230 + modalMiResena = reviews.find(review => review.miResena) || null;
231 +
232 + listContainer.innerHTML = reviews.length
233 + ? reviews.map(renderReview).join('')
234 + : '<p class="empty-experiences">Aún no hay experiencias compartidas en esta empresa. Sé la primera persona en compartir la tuya.</p>';
235 +
236 + formContainer.innerHTML = modalMiResena
237 + ? `<div class="experiencias-section"><p class="my-experience-note">Ya compartiste tu experiencia en esta empresa. Puedes <button type="button" class="link-action" data-action="editar-resena">editar</button> o <button type="button" class="link-action" data-action="eliminar-resena" data-step="0">eliminar</button> tu aporte.</p><div id="resena-edit-wrap"></div></div>`
238 + : renderResenaForm();
239 + if (!modalMiResena) vincularFormulario('crear', null);
240 +}
241 +
242 +function renderReview(review) {
243 + const destacada = review.miResena ? ' review-mine' : '';
244 + const fecha = formatFecha(review.fecha);
245 + const acciones = review.miResena
246 + ? `<div class="review-actions"><button class="btn-clean" type="button" data-action="editar-resena">Editar</button><button class="btn-clean btn-danger" type="button" data-action="eliminar-resena" data-step="0">Eliminar</button></div>`
247 + : '';
248 + return `
249 + <article class="review-item${destacada}">
250 + <header class="review-head">
251 + <div class="review-stars" aria-label="${review.calificacion} de 5 estrellas">${renderStars(review.calificacion)}</div>
252 + <div class="review-meta"><strong class="review-author">${escapeHtml(review.autor || 'Estudiante')}</strong>${fecha ? `<span class="review-date">${fecha}</span>` : ''}${review.miResena ? '<span class="badge badge-green">Tu experiencia</span>' : ''}</div>
253 + </header>
254 + ${review.proyectoDesarrollado ? `<p class="review-field"><strong>Proyecto desarrollado:</strong> ${escapeHtml(review.proyectoDesarrollado)}</p>` : ''}
255 + ${review.aprendizajes ? `<p class="review-field"><strong>¿Qué aprendió?</strong> ${escapeHtml(review.aprendizajes)}</p>` : ''}
256 + ${review.recomendaciones ? `<p class="review-field"><strong>Recomendaciones:</strong> ${escapeHtml(review.recomendaciones)}</p>` : ''}
257 + ${acciones}
258 + </article>
259 + `;
260 +}
261 +
262 +function abrirEdicionResena() {
263 + const wrap = modalBackdrop?.querySelector('#resena-edit-wrap');
264 + if (!wrap || !modalMiResena) return;
265 + const base = modalMiResena;
266 + wrap.innerHTML = renderResenaForm(base, 'editar');
267 + vincularFormulario('editar', base);
268 + wrap.scrollIntoView({ behavior: 'smooth', block: 'neutral' });
269 +}
270 +
271 +function cancelarEdicionResena() {
272 + const wrap = modalBackdrop?.querySelector('#resena-edit-wrap');
273 + if (wrap) wrap.innerHTML = '';
274 +}
275 +
276 +function renderResenaForm(valores = null, modo = 'crear') {
277 + const stars = [1, 2, 3, 4, 5].map(value => `<button type="button" class="star-btn${valores?.calificacion && Number(valores.calificacion) >= value ? ' is-selected' : ''}" data-value="${value}" aria-label="${value} estrellas" aria-pressed="${valores?.calificacion && Number(valores.calificacion) >= value ? 'true' : 'false'}">★</button>`).join('');
278 + const titulo = modo === 'editar' ? 'Edita tu experiencia' : 'Comparte tu experiencia';
279 + const textoSubmit = modo === 'editar' ? 'Guardar cambios' : 'Compartir experiencia';
280 + return `
281 + <div class="experiencias-section resena-form-wrap">
282 + <h3 class="resena-title">${titulo}</h3>
283 + <form class="resena-form" id="${modo === 'editar' ? 'resena-form-editar' : 'resena-form'}" novalidate>
284 + <div class="field-row">
285 + <span class="resena-label">Tu calificación</span>
286 + <div class="star-picker" role="radiogroup" aria-label="Calificación">
287 + ${stars}
288 + </div>
289 + <span class="star-picker-label" id="star-picker-label">${valores?.calificacion ? (String(valores.calificacion) === '1' ? '1 estrella' : `${valores.calificacion} estrellas`) : 'Selecciona de 1 a 5 estrellas'}</span>
290 + </div>
291 + <div class="field-row">
292 + <label class="resena-label" for="resena-proyecto">Proyecto desarrollado</label>
293 + <textarea class="resena-input" id="resena-proyecto" rows="2" maxlength="2000" placeholder="Describe brevemente el proyecto o actividad que desarrollaste...">${escapeHtml(valores?.proyectoDesarrollado || '')}</textarea>
294 + </div>
295 + <div class="field-row">
296 + <label class="resena-label" for="resena-aprendizajes">¿Qué aprendiste?</label>
297 + <textarea class="resena-input" id="resena-aprendizajes" rows="3" maxlength="2000" placeholder="Comparte los aprendizajes más valiosos de tu experiencia...">${escapeHtml(valores?.aprendizajes || '')}</textarea>
298 + </div>
299 + <div class="field-row">
300 + <label class="resena-label" for="resena-recomendaciones">Recomendaciones para otros estudiantes</label>
301 + <textarea class="resena-input" id="resena-recomendaciones" rows="2" maxlength="2000" placeholder="Consejos para quien viva el Semestre Empresarial aquí...">${escapeHtml(valores?.recomendaciones || '')}</textarea>
302 + </div>
303 + <div class="field-row resena-actions">
304 + ${modo === 'editar' ? '<button class="btn-clean" type="button" id="resena-cancelar">Cancelar</button>' : ''}
305 + <button class="btn-solid" type="submit" id="resena-submit">${textoSubmit}</button>
306 + </div>
307 + </form>
308 + </div>
309 + `;
310 +}
311 +
312 +function vincularFormulario(modo, valores) {
313 + const formId = modo === 'editar' ? '#resena-form-editar' : '#resena-form';
314 + const form = modalBackdrop?.querySelector(formId);
315 + if (!form) return;
316 +
317 + if (modo === 'editar') {
318 + calificacionSeleccionada = Number(valores?.calificacion) || 0;
319 + } else {
320 + calificacionSeleccionada = 0;
321 + }
322 +
323 + form.querySelectorAll('.star-btn').forEach(btn => {
324 + btn.addEventListener('click', () => {
325 + calificacionSeleccionada = Number(btn.dataset.value);
326 + form.querySelectorAll('.star-btn').forEach(star => {
327 + const activo = Number(star.dataset.value) <= calificacionSeleccionada;
328 + star.classList.toggle('is-selected', activo);
329 + star.setAttribute('aria-pressed', String(activo));
330 + });
331 + const label = form.querySelector('#star-picker-label');
332 + if (label) {
333 + label.textContent = calificacionSeleccionada === 1
334 + ? '1 estrella'
335 + : `${calificacionSeleccionada} estrellas`;
336 + }
337 + });
338 + });
339 +
340 + form.querySelector('#resena-cancelar')?.addEventListener('click', () => cancelarEdicionResena());
341 +
342 + form.addEventListener('submit', async event => {
343 + event.preventDefault();
344 + if (resenaEnviandose) return;
345 + if (!calificacionSeleccionada) {
346 + Utils.toast('Selecciona una calificación de 1 a 5 estrellas.', 'error');
347 + return;
348 + }
349 +
350 + resenaEnviandose = true;
351 + const submitBtn = form.querySelector('#resena-submit');
352 + const originalText = submitBtn.textContent;
353 + submitBtn.disabled = true;
354 +
355 + const body = {
356 + calificacion: calificacionSeleccionada,
357 + proyectoDesarrollado: form.querySelector('#resena-proyecto').value.trim(),
358 + aprendizajes: form.querySelector('#resena-aprendizajes').value.trim(),
359 + recomendaciones: form.querySelector('#resena-recomendaciones').value.trim()
360 + };
361 +
362 + try {
363 + await API.request(`/empresarial/${modalEmpresaId}/resenas`, {
364 + method: modo === 'editar' ? 'PUT' : 'POST',
365 + body: JSON.stringify(body)
366 + });
367 + Utils.toast(modo === 'editar'
368 + ? 'Tu experiencia fue actualizada correctamente.'
369 + : 'Experiencia compartida correctamente. Gracias por aportar a la comunidad.', 'success');
370 + await cargarExperiencias();
371 + recargarEmpresas();
372 + } catch (error) {
373 + Utils.toast(error.message || 'No se pudo guardar tu experiencia. Inténtalo de nuevo.', 'error');
374 + } finally {
375 + submitBtn.disabled = false;
376 + submitBtn.textContent = originalText;
377 + resenaEnviandose = false;
378 + }
379 + });
380 +}
381 +
382 +async function eliminarMiResena(btn) {
383 + try {
384 + await API.request(`/empresarial/${modalEmpresaId}/resenas`, { method: 'DELETE' });
385 + Utils.toast('Tu experiencia fue eliminada.', 'success');
386 + await cargarExperiencias();
387 + recargarEmpresas();
388 + } catch (error) {
389 + btn.disabled = false;
390 + btn.dataset.step = '0';
391 + btn.textContent = 'Eliminar';
392 + Utils.toast(error.message || 'No se pudo eliminar tu experiencia.', 'error');
393 + }
394 +}
395 +
396 +async function recargarEmpresas() {
397 + const content = document.getElementById('main-content');
398 + if (content) await loadCompanies(content);
399 +}
400 +
401 +function formatFecha(value) {
402 + if (!value) return '';
403 + const cleaned = String(value).replace('T', ' ');
404 + const match = cleaned.match(/^(\d{4})-(\d{2})-(\d{2})/);
405 + if (!match) return '';
406 + return `${match[3]}/${match[2]}/${match[1]}`;
407 +}
\ No newline at end of file
frontend/js/pages/inicio.js
+126 -16
@@ -20,20 +20,69 @@ document.addEventListener('DOMContentLoaded', async () => {
20 }
21
22 renderDashboard(content, dashboard || {});
23 +
24 + mostrarBienvenida();
25 });
26
27 +function mostrarBienvenida() {
28 + // Limpia cualquier flag viejo que pudiera haber quedado de versiones anteriores.
29 + try { localStorage.removeItem('bienvenida-pendiente'); } catch (e) {}
30 +
31 + let pendiente = false;
32 + try {
33 + pendiente =
34 + localStorage.getItem('bienvenida-pendiente') === '1' ||
35 + sessionStorage.getItem('bienvenida-pendiente') === '1';
36 + } catch (e) {}
37 + if (!pendiente) return;
38 +
39 + try {
40 + sessionStorage.removeItem('bienvenida-pendiente');
41 + localStorage.removeItem('bienvenida-pendiente');
42 + } catch (e) {}
43 +
44 + const nombre = (Auth.getUser() && Auth.getUser().nombre) || '';
45 +
46 + const overlay = document.createElement('div');
47 + overlay.className = 'welcome-overlay';
48 + overlay.id = 'welcomeOverlay';
49 + overlay.setAttribute('aria-hidden', 'true');
50 + overlay.innerHTML = `
51 + <div class="welcome-popup">
52 + <div class="popup-tag">
53 + <span class="pulse-mini"></span>
54 + <span>MAPS CONNECT // ACCESS GRANTED</span>
55 + </div>
56 + <h1 class="popup-title">BIENVENIDO</h1>
57 + <div class="popup-username" id="popupUserName">${escapeHtml(nombre || 'Usuario').toUpperCase()}</div>
58 + <p class="popup-status">SESIÓN INICIADA • CARGANDO ENTORNO HUD...</p>
59 + </div>
60 + `;
61 + document.body.appendChild(overlay);
62 +
63 + setTimeout(() => {
64 + overlay.classList.add('fade-out');
65 + // Elimina el overlay del DOM al terminar la transición para que nunca se quede pegado.
66 + setTimeout(() => {
67 + if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
68 + }, 550);
69 + }, 1500);
70 +}
71 +
72 function renderDashboard(content, data) {
73 const perfil = data.perfil || null;
74 + const esDocente = perfil?.rol === 'PROFESOR';
75 const materias = Array.isArray(data.materias) ? data.materias : [];
76 const publicaciones = Array.isArray(data.publicaciones) ? data.publicaciones : [];
77 const accesos = renderAccessCards(data);
78
79 content.innerHTML = `
80 <div class="inicio-container">
33 - ${perfil ? renderBanner(perfil) : ''}
34 - <div class="inicio-layout">
81 + ${perfil ? (esDocente ? renderDocenteBanner(perfil) : renderBanner(perfil)) : ''}
82 + ${data.carreraFiltrada && !esDocente ? `<p class="filter-note">Mostrando solo contenido de tu carrera: <strong>${escapeHtml(data.carreraFiltrada)}</strong></p>` : ''}
83 + <div class="main-layout">
84 <section class="feed-column" aria-label="Actividad reciente">
36 - ${materias.length ? renderQuickQuestion(materias) : ''}
85 + ${!esDocente && materias.length ? renderQuickQuestion(materias) : ''}
86 ${publicaciones.length ? publicaciones.map(renderPost).join('') : renderEmptyFeed()}
87 </section>
88 ${accesos ? `<aside class="sidebar-section" aria-label="Accesos directos">${accesos}</aside>` : ''}
@@ -45,6 +94,29 @@ function renderDashboard(content, data) {
94 event.preventDefault();
95 window.location.href = 'comunidad.html#foro-dudas';
96 });
97 +
98 + content.addEventListener('click', event => {
99 + const followBtn = event.target.closest('.follow-btn');
100 + if (!followBtn) return;
101 + event.preventDefault();
102 + const id = followBtn.getAttribute('data-id');
103 + if (!id) return;
104 + Utils.alternarSeguir(followBtn, id);
105 + });
106 +}
107 +
108 +function renderDocenteBanner(perfil) {
109 + return `
110 + <section class="card maps-banner prof-card" aria-labelledby="prof-title">
111 + <div class="banner-content">
112 + <div>
113 + <h2 id="prof-title">Bienvenido, Docente</h2>
114 + <p>Panel de docencia MAPS. Aquí aparecerán los avisos y materiales que compartes con tu comunidad.</p>
115 + </div>
116 + <span class="badge badge-verified">Perfil Docente</span>
117 + </div>
118 + </section>
119 + `;
120 }
121
122 function renderBanner(perfil) {
@@ -56,12 +128,12 @@ function renderBanner(perfil) {
128 <section class="card maps-banner" aria-labelledby="maps-title">
129 <div class="banner-content">
130 <div>
59 - <h1 id="maps-title">${escapeHtml(perfil.carrera || 'Mi trayectoria MAPS')}</h1>
60 - <p>${perfil.certificado ? `Certificado activo: <strong>${escapeHtml(perfil.certificado)}</strong>` : 'Sin certificado activo'}${semestre ? ` · Semestre vigente: <strong>${semestre}.º</strong>` : ''}</p>
131 + <h2 id="maps-title">${escapeHtml(perfil.carrera || 'Mi trayectoria MAPS')}</h2>
132 + <p>${perfil.certificado ? `Certificado Activo: <strong>${escapeHtml(perfil.certificado)}</strong>` : 'Sin certificado activo'}${semestre ? ` • Semestre Vigente: <strong>${semestre}°</strong>` : ''}</p>
133 </div>
62 - <span class="badge badge-materia">Plan MAPS oficial</span>
134 + <span class="badge badge-materia">Plan MAPS Oficial</span>
135 </div>
64 - ${totalSemestres ? `<div class="progress-container"><div class="progress-labels"><span>Avance de carrera</span><span>Semestre ${semestre} de ${totalSemestres} · ${avance} %</span></div><div class="progress-bar" role="progressbar" aria-label="Avance de carrera" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${avance}"><div class="progress-fill" style="width: ${avance}%"></div></div></div>` : ''}
136 + ${totalSemestres ? `<div class="progress-container"><div class="progress-labels"><span>Avance de Carrera</span><span>Semestre ${semestre} de ${totalSemestres} (${avance}%)</span></div><div class="progress-bar" role="progressbar" aria-label="Avance de carrera" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${avance}"><div class="progress-fill" style="width: ${avance}%"></div></div></div>` : ''}
137 </section>
138 `;
139 }
@@ -78,16 +150,54 @@ function renderQuickQuestion(materias) {
150 }
151
152 function renderPost(post) {
81 - const author = post.autor || {};
82 - const initials = (author.iniciales || author.nombre || '?').trim().split(/\s+/).map(word => word[0]).slice(0, 2).join('').toUpperCase();
83 - const tags = [post.materia && `<span class="badge badge-materia">${escapeHtml(post.materia)}</span>`, post.solucionAceptada && '<span class="badge badge-solved">✓ Solución aceptada</span>', post.verificadoDocente && '<span class="badge badge-verified">Verificado por docente</span>'].filter(Boolean).join('');
153 + const author = (post.autor && typeof post.autor === 'object') ? post.autor : {};
154 + const esDocente = author.tipo === 'profesor' || author.tipo === 'docente' || Boolean(post.verificadoDocente);
155 + const esMaterial = post.tipoContenido === 'material';
156 + const nombre = author.nombre || 'Usuario';
157 + const meta = esMaterial
158 + ? ['Docente', post.materia ? `de ${post.materia}` : ''].filter(Boolean).join(' ')
159 + : (author.semestre ? `${author.semestre}° Semestre` : 'Estudiante');
160 + const tiempo = post.fechaRelativa ? ` • ${post.fechaRelativa}` : '';
161 + const meId = Auth.getUser() && Auth.getUser().id;
162 + const clickeable = author.id && author.id !== meId;
163 + const hrefAutor = `usuario.html?id=${encodeURIComponent(author.id)}`;
164 + const avatarWrap = clickeable
165 + ? `<a class="user-link" href="${hrefAutor}" aria-label="Ver perfil de ${escapeHtml(nombre)}">${Utils.avatarHtml(nombre, author.foto, `avatar ${esDocente ? 'prof' : ''}`, nombre)}</a>`
166 + : Utils.avatarHtml(nombre, author.foto, `avatar ${esDocente ? 'prof' : ''}`, nombre);
167 + const nombreWrap = clickeable
168 + ? `<a class="user-link" href="${hrefAutor}"><strong>${escapeHtml(nombre)}</strong></a>`
169 + : `<strong>${escapeHtml(nombre)}</strong>`;
170 + const seguirBoton = (author.id && author.id !== meId)
171 + ? `<button class="follow-btn${author.seguido ? ' siguiendo' : ''}" type="button" data-id="${author.id}" data-siguiendo="${author.seguido ? 'true' : 'false'}">${author.seguido ? 'Siguiendo' : 'Seguir'}</button>`
172 + : '';
173 +
174 + const tags = [
175 + post.materia && `<span class="badge badge-materia">${escapeHtml(post.materia)}</span>`,
176 + post.participado && '<span class="badge badge-interacted">Has participado</span>',
177 + post.solucionAceptada && '<span class="badge badge-solved">Solución Aceptada</span>',
178 + esDocente && '<span class="badge badge-verified">Aviso Docente</span>',
179 + ].filter(Boolean).join('');
180 +
181 + const embed = esMaterial && post.archivo
182 + ? `<div class="embed-box prof-box"><strong style="color:#0284c7;">Archivo disponible:</strong><p>${escapeHtml(post.archivo.nombre)} (${escapeHtml(post.archivo.peso)}) • ${escapeHtml(post.archivo.descargas)} descargas de tu carrera</p></div>`
183 + : (post.respuestaUsuario ? `<div class="embed-box"><strong>Tu respuesta:</strong><p>${escapeHtml(post.respuestaUsuario)}</p></div>` : '');
184 +
185 + const votos = Number.isFinite(Number(post.votos)) ? Number(post.votos) : 0;
186 + const botonVotos = post.participado
187 + ? `<button class="btn-action voted" type="button">Votado (${votos})</button>`
188 + : `<button class="btn-action" type="button">${votos} Votos</button>`;
189 + const tercerBoton = esMaterial
190 + ? '<button class="btn-action" type="button">Descargar</button>'
191 + : '<button class="btn-action" type="button">Guardar</button>';
192 + const status = esMaterial ? 'Material Oficial' : (post.solucionAceptada ? 'Resuelto' : '');
193
194 return `
195 <article class="card post-card">
87 - <div class="post-header"><div class="user-block"><div class="avatar ${post.verificadoDocente ? 'prof' : ''}">${escapeHtml(initials)}</div><div class="user-data"><strong>${escapeHtml(author.nombre || 'Usuario')}</strong><span>${escapeHtml(post.fechaRelativa || '')}</span></div></div><div class="badges-wrap">${tags}</div></div>
88 - <h2 class="post-title">${escapeHtml(post.titulo || '')}</h2>
196 + <div class="post-header"><div class="user-block">${avatarWrap}<div class="user-data"><div class="user-data-line">${nombreWrap}${seguirBoton}</div><span>${escapeHtml(meta + tiempo)}</span></div></div><div class="badges-wrap">${tags}</div></div>
197 + <h3 class="post-title">${escapeHtml(post.titulo || '')}</h3>
198 ${post.contenido ? `<p class="post-body">${escapeHtml(post.contenido)}</p>` : ''}
90 - <div class="post-footer"><div class="action-links"><a class="btn-action" href="comunidad.html#foro-dudas">💬 ${Number(post.respuestas) || 0} respuestas</a></div><span class="post-status">${post.solucionAceptada ? 'Resuelto' : ''}</span></div>
199 + ${embed}
200 + <div class="post-footer"><div class="action-links">${botonVotos}<button class="btn-action" type="button">${Number(post.respuestas) || 0} ${esMaterial ? 'Comentarios' : 'Respuestas'}</button>${tercerBoton}</div>${status ? `<span class="post-status">${escapeHtml(status)}</span>` : ''}</div>
201 </article>
202 `;
203 }
@@ -98,9 +208,9 @@ function renderAccessCards(data) {
208 const asesoria = data.asesoria;
209 const empresa = data.empresaDestacada;
210
101 - if (circulo) cards.push(`<section class="card side-card"><span class="badge badge-materia">${escapeHtml(circulo.fecha || 'Próximamente')}</span><h3>${escapeHtml(circulo.nombre || 'Círculo de estudio')}</h3>${circulo.descripcion ? `<p>${escapeHtml(circulo.descripcion)}</p>` : ''}<a class="btn-solid" href="circulos.html">Ver círculo</a></section>`);
102 - if (asesoria) cards.push(`<section class="card side-card prof-card"><span class="badge badge-verified">Asesoría disponible</span><h3>${escapeHtml(asesoria.docente || 'Docente')}</h3>${asesoria.horario ? `<p>${escapeHtml(asesoria.horario)}</p>` : ''}<a class="btn-clean" href="mensajes.html">Enviar mensaje privado</a></section>`);
103 - if (empresa) cards.push(`<section class="card side-card"><h3>Radar Semestre Empresarial</h3><p><strong>${escapeHtml(empresa.nombre || '')}${empresa.calificacion ? ` · ${escapeHtml(empresa.calificacion)} ★` : ''}</strong>${empresa.resena ? `<br>${escapeHtml(empresa.resena)}` : ''}</p><a class="side-link" href="empresarial.html">Ver directorio de empresas →</a></section>`);
211 + if (circulo) cards.push(`<section class="card side-card"><span class="badge badge-materia">${escapeHtml(circulo.fecha || 'Próximamente')}</span><h4>${escapeHtml(circulo.nombre || 'Círculo de estudio')}</h4>${circulo.descripcion ? `<p>${escapeHtml(circulo.descripcion)}</p>` : ''}<a class="btn-solid" style="width:100%;" href="circulos.html">${circulo.plataforma ? `Entrar a Sala (${escapeHtml(circulo.plataforma)})` : 'Ver círculo'}</a></section>`);
212 + if (asesoria) cards.push(`<section class="card side-card prof-card"><span class="badge badge-verified">Asesoría Disponible</span><h4>${escapeHtml(asesoria.docente || 'Docente')}</h4>${asesoria.horario ? `<p>Horario de atención para dudas del certificado: <strong>${escapeHtml(asesoria.horario)}</strong></p>` : ''}<a class="btn-clean" href="mensajes.html">Enviar Mensaje Privado</a></section>`);
213 + if (empresa) cards.push(`<section class="card side-card"><h4>Radar Semestre Empresarial</h4><p><strong>${escapeHtml(empresa.nombre || '')}${empresa.calificacion ? ` • ${escapeHtml(empresa.calificacion)}` : ''}</strong>${empresa.resena ? `<br>${escapeHtml(empresa.resena)}` : ''}</p><a class="side-link" href="empresarial.html">Ver directorio de empresas →</a></section>`);
214
215 return cards.join('');
216 }
frontend/js/pages/login.js
+28 -18
@@ -1,30 +1,40 @@
1 -/** Acceso provisional solo para revisar la interfaz (sin backend). */
2 -const DEV_LOGIN = {
3 - email: 'demo@tecmilenio.mx',
4 - password: 'Demo1234',
5 - user: {
6 - nombre: 'Usuario Demo',
7 - email: 'demo@tecmilenio.mx',
8 - rol: 'ESTUDIANTE'
9 - }
10 -};
1 +document.addEventListener('DOMContentLoaded', async () => {
2 + await Auth.redirectIfAuthenticated();
3
12 -document.addEventListener('DOMContentLoaded', () => {
13 - Auth.redirectIfAuthenticated();
4 + document.getElementById('link-aviso-login')?.addEventListener('click', () => AvisoPrivacidad.mostrar('Aviso de Privacidad'));
5
15 - document.getElementById('login-form').addEventListener('submit', (e) => {
6 + document.getElementById('login-form').addEventListener('submit', async (e) => {
7 e.preventDefault();
8 Utils.clearAlert('alert-container');
9
10 const email = document.getElementById('email').value.trim();
11 const password = document.getElementById('password').value;
12
22 - if (email === DEV_LOGIN.email && password === DEV_LOGIN.password) {
23 - Auth.saveSession('token-provisional-dev', DEV_LOGIN.user);
24 - window.location.href = 'pages/inicio.html';
13 + if (!email || !password) {
14 + Utils.showAlert('alert-container', 'Ingresa tu correo y contraseña.');
15 return;
16 }
17
28 - Utils.showAlert('alert-container', 'Correo o contraseña incorrectos. Usa las credenciales provisionales.');
18 + const submitBtn = e.target.querySelector('button[type="submit"]');
19 + submitBtn.disabled = true;
20 + submitBtn.textContent = 'Iniciando sesión...';
21 +
22 + try {
23 + const response = await API.request('/auth/login', {
24 + method: 'POST',
25 + body: JSON.stringify({ email, contrasena: password })
26 + });
27 +
28 + Auth.saveSession(response.token, response.usuario);
29 + try {
30 + localStorage.removeItem('bienvenida-pendiente');
31 + sessionStorage.setItem('bienvenida-pendiente', '1');
32 + } catch (e) {}
33 + window.location.href = Auth.resolvePath('pages/inicio.html');
34 + } catch (error) {
35 + Utils.showAlert('alert-container', error.message || 'No se pudo iniciar sesión.');
36 + submitBtn.disabled = false;
37 + submitBtn.textContent = 'Entrar';
38 + }
39 });
30 -});
40 +});
\ No newline at end of file
frontend/js/pages/mensajes.js
+205 -26
@@ -7,7 +7,9 @@
7 const Mensajes = {
8 state: {
9 conversaciones: [],
10 - seleccionada: null
10 + seleccionada: null,
11 + eventSource: null,
12 + adjunto: null
13 },
14
15 async init() {
@@ -16,6 +18,13 @@ const Mensajes = {
18
19 this.bindEventos();
20 await this.loadConversaciones();
21 +
22 + // Si llegamos desde una notificación, abre la conversación indicada.
23 + const params = new URLSearchParams(window.location.search);
24 + const conv = params.get('conv');
25 + if (conv && this.state.conversaciones.some(c => String(this.field(c, 'id')) === String(conv))) {
26 + this.seleccionar(conv);
27 + }
28 },
29
30 bindEventos() {
@@ -28,10 +37,47 @@ const Mensajes = {
37 const search = document.getElementById('chat-search');
38 search?.addEventListener('input', () => this.renderConversaciones());
39
40 + const body = document.getElementById('chat-body');
41 + body?.addEventListener('click', e => {
42 + const btn = e.target.closest('.adjunto-descargar');
43 + if (!btn) return;
44 + this.descargarAdjunto(btn.dataset.id, btn.dataset.nombre, btn.dataset.tipo);
45 + });
46 +
47 document.getElementById('enviar-mensaje')?.addEventListener('click', () => this.enviar());
48 document.getElementById('mensaje-input')?.addEventListener('keydown', e => {
49 if (e.key === 'Enter') this.enviar();
50 });
51 + document.getElementById('btn-adjuntar')?.addEventListener('click', () => {
52 + document.getElementById('adjunto-input')?.click();
53 + });
54 +
55 + const fileInput = document.getElementById('adjunto-input');
56 + fileInput?.addEventListener('change', () => {
57 + const archivo = fileInput.files && fileInput.files[0];
58 + if (!archivo) return;
59 + this.state.adjunto = archivo;
60 + this.renderAdjuntoPendiente();
61 + fileInput.value = '';
62 + });
63 +
64 + document.getElementById('adjunto-quitar')?.addEventListener('click',
65 + () => this.limpiarAdjunto());
66 + },
67 +
68 + renderAdjuntoPendiente() {
69 + const chip = document.getElementById('adjunto-pendiente');
70 + const nombre = document.getElementById('adjunto-pendiente-nombre');
71 + const archivo = this.state.adjunto;
72 + if (!chip || !nombre || !archivo) return;
73 + nombre.textContent = `${archivo.name} (${this.formatBytes(archivo.size)})`;
74 + chip.hidden = false;
75 + },
76 +
77 + limpiarAdjunto() {
78 + this.state.adjunto = null;
79 + const chip = document.getElementById('adjunto-pendiente');
80 + if (chip) chip.hidden = true;
81 },
82
83 async loadConversaciones() {
@@ -60,16 +106,16 @@ const Mensajes = {
106 host.innerHTML = items.map(c => {
107 const id = this.field(c, 'id');
108 const active = this.state.seleccionada && String(this.state.seleccionada) === String(id) ? ' active' : '';
63 - const initials = this.initials(this.nombreConv(c));
109 const profCls = this.esProf(c) ? ' prof' : '';
110 const tiempo = this.field(c, 'tiempo') || this.field(c, 'fechaRelativa');
111 const preview = this.field(c, 'preview');
112 + const nombre = this.nombreConv(c);
113 return `
114 <div class="chat-item${active}" data-id="${this.esc(id)}">
69 - <div class="avatar${profCls}">${this.esc(initials)}</div>
115 + ${Utils.avatarHtml(nombre, c.foto, `avatar${profCls}`, nombre)}
116 <div class="chat-item-info">
117 <div class="chat-item-header">
72 - <strong>${this.esc(this.nombreConv(c))}</strong>
118 + <strong>${this.esc(nombre)}</strong>
119 ${tiempo ? `<span>${this.esc(tiempo)}</span>` : ''}
120 </div>
121 <p class="chat-item-preview">${this.esc(preview)}</p>
@@ -80,6 +126,7 @@ const Mensajes = {
126
127 async seleccionar(id) {
128 this.state.seleccionada = id;
129 + this.cerrarStream();
130 this.renderConversaciones();
131
132 // Cabecera provisional con el dato de la lista; el detalle viene del backend.
@@ -91,7 +138,8 @@ const Mensajes = {
138
139 let data;
140 try {
94 - data = await API.request(`/mensajes/${encodeURIComponent(id)}`);
141 + const res = await API.request(`/mensajes/${encodeURIComponent(id)}`);
142 + data = res && res.data != null ? res.data : res;
143 } catch (e) {
144 // El endpoint se implementará en el backend. La UI mantiene estados vacíos.
145 data = null;
@@ -104,27 +152,72 @@ const Mensajes = {
152 const mensajes = Array.isArray(data.mensajes) ? data.mensajes : (Array.isArray(data) ? data : []);
153 this.renderMensajes(mensajes);
154 this.setEntradaHabilitada(true);
155 +
156 + // Marca la conversación como leída para limpiar el badge de la campana.
157 + this.marcarLeido(id);
158 +
159 + // Conexión en tiempo real (SSE) a la conversación abierta.
160 + this.abrirStream(id);
161 + },
162 +
163 + cerrarStream() {
164 + if (this.state.eventSource) {
165 + this.state.eventSource.close();
166 + this.state.eventSource = null;
167 + }
168 + },
169 +
170 + abrirStream(id) {
171 + const token = localStorage.getItem('token');
172 + if (!token) return;
173 +
174 + const source = new EventSource(`${API_BASE_URL}/mensajes/${encodeURIComponent(id)}/stream?token=${encodeURIComponent(token)}`);
175 + source.addEventListener('mensaje', () => {
176 + if (String(this.state.seleccionada) !== String(id)) return;
177 + this.refrescarConversacion(id);
178 + this.marcarLeido(id);
179 + this.loadConversaciones();
180 + if (window.Layout && Layout.refreshNotificaciones) Layout.refreshNotificaciones();
181 + });
182 + // Sin cierre manual: EventSource reconecta automáticamente ante fallos transitorios.
183 + this.state.eventSource = source;
184 + },
185 +
186 + async refrescarConversacion(id) {
187 + if (String(this.state.seleccionada) !== String(id)) return;
188 + try {
189 + const res = await API.request(`/mensajes/${encodeURIComponent(id)}`);
190 + const data = res && res.data != null ? res.data : res;
191 + if (!data) return;
192 + this.renderCabecera(data, false);
193 + const mensajes = Array.isArray(data.mensajes) ? data.mensajes : [];
194 + this.renderMensajes(mensajes);
195 + } catch (e) {
196 + // Se ignora; el stream sigue activo y reintentará en el siguiente evento.
197 + }
198 },
199
200 renderCabecera(data, fromList) {
201 const host = document.getElementById('chat-header');
202 if (!host) return;
203 const nombre = this.nombreConv(data);
113 - const initials = this.initials(nombre);
204 const profCls = this.esProf(data) ? ' prof' : '';
205 const sub = this.field(data, 'subtitulo') || (fromList ? '' : '');
206 + const meId = Auth.getUser() && Auth.getUser().id;
207 + const idUsuario = data.idUsuario;
208 + const verPerfil = (idUsuario && String(idUsuario) !== String(meId))
209 + ? `<a class="btn-clean" href="usuario.html?id=${encodeURIComponent(idUsuario)}">Ver Perfil Académico</a>`
210 + : '';
211
212 host.innerHTML = `
213 <div class="chat-header-user">
119 - <div class="avatar${profCls}">${this.esc(initials)}</div>
214 + ${Utils.avatarHtml(nombre, data.foto, `avatar${profCls}`, nombre)}
215 <div>
216 <h3>${this.esc(nombre)}</h3>
217 ${sub ? `<span>${this.esc(sub)}</span>` : ''}
218 </div>
219 </div>
125 - <div>
126 - <button class="btn-clean" type="button">Ver Perfil Académico</button>
127 - </div>
220 + <div>${verPerfil}</div>
221 `;
222 },
223
@@ -149,9 +242,27 @@ const Mensajes = {
242 const tiempo = this.field(m, 'tiempo') || this.field(m, 'fechaRelativa');
243 const enviado = m.enviado === true || m.enviado === 'true' || m.propio === true;
244 const cls = enviado ? 'sent' : 'received';
245 + const adjunto = m.adjuntoNombre
246 + ? `
247 + <div class="message-attachment">
248 + <div class="attachment-info">
249 + <span class="attachment-icon">${this.iconoAdjunto(m.adjuntoNombre)}</span>
250 + <span>
251 + <strong class="attachment-nombre">${this.esc(m.adjuntoNombre)}</strong>
252 + <span class="attachment-peso">${m.adjuntoTamano ? this.formatBytes(m.adjuntoTamano) : ''}</span>
253 + </span>
254 + </div>
255 + <button type="button" class="adjunto-descargar"
256 + data-id="${m.id}" data-nombre="${this.esc(m.adjuntoNombre)}"
257 + data-tipo="${this.esc(m.adjuntoTipo || '')}">
258 + Descargar
259 + </button>
260 + </div>`
261 + : '';
262 return `
263 <div class="message ${cls}">
154 - ${this.esc(texto)}
264 + ${adjunto}
265 + ${texto ? `<p class="message-texto">${this.esc(texto)}</p>` : ''}
266 ${tiempo ? `<span class="message-time">${this.esc(tiempo)}</span>` : ''}
267 </div>`;
268 },
@@ -160,18 +271,76 @@ const Mensajes = {
271 const input = document.getElementById('mensaje-input');
272 if (!input) return;
273 const texto = input.value.trim();
163 - if (!texto || !this.state.seleccionada) return;
274 + if ((!texto && !this.state.adjunto) || !this.state.seleccionada) return;
275
276 try {
166 - await API.request(`/mensajes/${encodeURIComponent(this.state.seleccionada)}`, {
167 - method: 'POST',
168 - body: JSON.stringify({ texto })
169 - });
277 + if (this.state.adjunto) {
278 + await this.enviarAdjunto(texto);
279 + } else {
280 + await API.request(`/mensajes/${encodeURIComponent(this.state.seleccionada)}`, {
281 + method: 'POST',
282 + body: JSON.stringify({ texto })
283 + });
284 + }
285 input.value = '';
171 - // Refrescar la conversación para obtener el mensaje persistido.
286 + this.limpiarAdjunto();
287 await this.seleccionar(this.state.seleccionada);
288 } catch (e) {
174 - // El endpoint aún no existe en el backend. No se limpia el input.
289 + const msg = (e && e.data && e.data.message) || (e && e.message) || 'No se pudo enviar el mensaje';
290 + Utils.toast(msg, 'error');
291 + }
292 + },
293 +
294 + async enviarAdjunto(texto) {
295 + const token = localStorage.getItem('token');
296 + const formData = new FormData();
297 + formData.append('archivo', this.state.adjunto);
298 + if (texto) formData.append('texto', texto);
299 +
300 + const response = await fetch(`${API_BASE_URL}/mensajes/${encodeURIComponent(this.state.seleccionada)}/adjunto`, {
301 + method: 'POST',
302 + headers: token ? { Authorization: `Bearer ${token}` } : {},
303 + body: formData
304 + });
305 + const body = await response.json().catch(() => null);
306 + if (!response.ok) {
307 + const error = new Error((body && body.message) || `Error ${response.status}`);
308 + error.status = response.status;
309 + error.data = body;
310 + throw error;
311 + }
312 + return (body && body.data !== undefined) ? body.data : body;
313 + },
314 +
315 + marcarLeido(id) {
316 + if (!id) return;
317 + API.request(`/mensajes/${encodeURIComponent(id)}/leido`, { method: 'POST' }).catch(() => {
318 + // Si falla, el siguiente evento o selección lo reintentará.
319 + });
320 + if (window.Layout && Layout.refreshNotificaciones) Layout.refreshNotificaciones();
321 + },
322 +
323 + async descargarAdjunto(id, nombre, tipo) {
324 + const token = localStorage.getItem('token');
325 + try {
326 + const response = await fetch(`${API_BASE_URL}/mensajes/${encodeURIComponent(id)}/adjunto`, {
327 + headers: token ? { Authorization: `Bearer ${token}` } : {}
328 + });
329 + if (!response.ok) {
330 + Utils.toast('No se pudo descargar el archivo', 'error');
331 + return;
332 + }
333 + const blob = await response.blob();
334 + const url = URL.createObjectURL(blob);
335 + const a = document.createElement('a');
336 + a.href = url;
337 + a.download = nombre || 'adjunto';
338 + document.body.appendChild(a);
339 + a.click();
340 + a.remove();
341 + URL.revokeObjectURL(url);
342 + } catch (e) {
343 + Utils.toast('No se pudo descargar el archivo', 'error');
344 }
345 },
346
@@ -199,14 +368,6 @@ const Mensajes = {
368 return obj[key] != null ? String(obj[key]) : '';
369 },
370
202 - initials(name) {
203 - if (!name) return '?';
204 - const parts = String(name).trim().split(/\s+/);
205 - const first = parts[0] ? parts[0][0] : '';
206 - const second = parts[1] ? parts[1][0] : '';
207 - return (first + second).toUpperCase() || '?';
208 - },
209 -
371 toArray(res) {
372 if (!res) return [];
373 if (Array.isArray(res)) return res;
@@ -222,6 +383,24 @@ const Mensajes = {
383 .replace(/>/g, '&gt;')
384 .replace(/"/g, '&quot;')
385 .replace(/'/g, '&#39;');
386 + },
387 +
388 + formatBytes(bytes) {
389 + const n = Number(bytes);
390 + if (!isFinite(n) || n <= 0) return '';
391 + if (n < 1024) return `${n} B`;
392 + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
393 + return `${(n / (1024 * 1024)).toFixed(1)} MB`;
394 + },
395 +
396 + iconoAdjunto(nombre) {
397 + const ext = String(nombre || '').split('.').pop().toLowerCase();
398 + if (['pdf'].includes(ext)) return 'PDF';
399 + if (['doc', 'docx'].includes(ext)) return 'DOC';
400 + if (['xls', 'xlsx', 'csv'].includes(ext)) return 'XLS';
401 + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return 'IMG';
402 + if (['zip', 'rar', '7z'].includes(ext)) return 'ZIP';
403 + return 'FILE';
404 }
405 };
406
frontend/js/pages/onboarding.js new
+273
@@ -0,0 +1,273 @@
1 +(function () {
2 + let rolActual = null;
3 +
4 + document.addEventListener('DOMContentLoaded', () => {
5 + if (!Auth.requireAuth()) return;
6 +
7 + const user = Auth.getUser();
8 + rolActual = user?.rol || '';
9 + document.getElementById('ono-titulo').textContent =
10 + rolActual === 'PROFESOR' ? 'Perfil del Maestro' : 'Bienvenido, estudiante';
11 +
12 + if (rolActual === 'ESTUDIANTE') {
13 + document.getElementById('ono-subtitulo').textContent =
14 + 'Cuéntanos tu carrera y semestre para mostrar tus materias y empezar a navegar.';
15 + renderFormularioEstudiante();
16 + } else if (rolActual === 'PROFESOR') {
17 + document.getElementById('ono-subtitulo').textContent =
18 + 'Define tu asignación académica y tus datos de asesoría para aparecer en el directorio.';
19 + renderFormularioProfesor();
20 + } else {
21 + window.location.href = Auth.resolvePath('pages/inicio.html');
22 + }
23 + });
24 +
25 + function cargarOpciones(url, textoVacio) {
26 + return API.request(url).then(res => res || []);
27 + }
28 +
29 + /* ------------------------- ESTUDIANTE ------------------------- */
30 +
31 + async function renderFormularioEstudiante() {
32 + const cont = document.getElementById('ono-contenido');
33 + const matricula = sessionStorage.getItem('pending-matricula') || '';
34 + cont.innerHTML = `
35 + <form id="ono-form-estudiante">
36 + <div class="form-group">
37 + <label for="ono-matricula">Matrícula</label>
38 + <input type="text" id="ono-matricula" class="form-control" required
39 + value="${escapeHtml(matricula)}" placeholder="A12345678">
40 + </div>
41 + <div class="form-group">
42 + <label for="ono-carrera">Carrera</label>
43 + <select id="ono-carrera" class="form-control" required>
44 + <option value="">Selecciona tu carrera...</option>
45 + </select>
46 + </div>
47 + <div class="form-group">
48 + <label>Semestre</label>
49 + <div class="ono-grid" id="ono-semestres"></div>
50 + </div>
51 + <div class="ono-section">
52 + <h3>Materias de tu semestre</h3>
53 + <div id="ono-materias">${spinnerHtml()}</div>
54 + </div>
55 + <div class="form-group" style="margin-top:1.25rem">
56 + <button type="submit" class="btn btn-primary" style="width:100%">Confirmar y empezar a navegar</button>
57 + </div>
58 + </form>`;
59 +
60 + const selCarrera = document.getElementById('ono-carrera');
61 + const selSemestre = document.getElementById('ono-semestres');
62 +
63 + try {
64 + const carreras = await cargarOpciones('/carreras', 'Sin carreras');
65 + selCarrera.innerHTML =
66 + '<option value="">Selecciona tu carrera...</option>' +
67 + carreras.map(c => `<option value="${c.id}">${escapeHtml(c.nombre)}</option>`).join('');
68 +
69 + selSemestre.innerHTML = Array.from({ length: 12 }, (_, i) => i + 1).map(s =>
70 + `<label class="${s === 1 ? 'sem-activo' : ''}">
71 + <input type="radio" name="ono-semestre" value="${s}" ${s === 1 ? 'checked' : ''}>
72 + ${s}°
73 + </label>`).join('');
74 +
75 + selSemestre.addEventListener('change', (e) => {
76 + if (e.target.name === 'ono-semestre') {
77 + selSemestre.querySelectorAll('label').forEach(l => l.classList.remove('sem-activo'));
78 + e.target.closest('label').classList.add('sem-activo');
79 + cargarMateriasEstudiante();
80 + }
81 + });
82 + selCarrera.addEventListener('change', cargarMateriasEstudiante);
83 +
84 + if (carreras.length) cargarMateriasEstudiante();
85 +
86 + document.getElementById('ono-form-estudiante').addEventListener('submit', async (ev) => {
87 + ev.preventDefault();
88 + Utils.clearAlert('alert-container');
89 + const btn = ev.target.querySelector('button[type="submit"]');
90 +
91 + if (!selCarrera.value) {
92 + Utils.showAlert('alert-container', 'Selecciona tu carrera.');
93 + return;
94 + }
95 + const semestre = Number(document.querySelector('input[name="ono-semestre"]:checked')?.value || 1);
96 +
97 + try {
98 + btn.disabled = true;
99 + btn.textContent = 'Guardando...';
100 + const materias = [...document.querySelectorAll('#ono-materias input[type="checkbox"]:checked')]
101 + .map(cb => Number(cb.value));
102 + await API.request('/auth/onboarding/estudiante', {
103 + method: 'POST',
104 + body: JSON.stringify({
105 + matricula: document.getElementById('ono-matricula').value.trim(),
106 + idCarrera: Number(selCarrera.value),
107 + semestre,
108 + materias
109 + })
110 + });
111 + sessionStorage.removeItem('pending-matricula');
112 + window.location.href = Auth.resolvePath('pages/inicio.html');
113 + } catch (error) {
114 + Utils.showAlert('alert-container', error.message || 'No se pudo completar el perfil.');
115 + btn.disabled = false;
116 + btn.textContent = 'Confirmar y empezar a navegar';
117 + }
118 + });
119 + } catch (error) {
120 + cont.innerHTML = `<div class="alert alert-error">Error al cargar el catálogo: ${escapeHtml(error.message)}</div>`;
121 + }
122 + }
123 +
124 + async function cargarMateriasEstudiante() {
125 + const idCarrera = document.getElementById('ono-carrera')?.value;
126 + const semestre = Number(document.querySelector('input[name="ono-semestre"]:checked')?.value || 1);
127 + const contMaterias = document.getElementById('ono-materias');
128 + if (!idCarrera) {
129 + contMaterias.innerHTML = '<div class="ono-espera">Selecciona tu carrera para ver las materias.</div>';
130 + return;
131 + }
132 + contMaterias.innerHTML = spinnerHtml();
133 + try {
134 + const materias = await cargarOpciones(`/materias/plan/${idCarrera}/${semestre}`, 'Sin materias');
135 + contMaterias.innerHTML = materias.length
136 + ? `<div class="ono-grid">${materias.map(m => `
137 + <label>
138 + <input type="checkbox" name="ono-materia" value="${m.id}"> ${escapeHtml(m.nombre)}
139 + </label>`).join('')}
140 + </div>
141 + <div class="form-hint">Marca las materias que cursas este semestre.</div>`
142 + : '<div class="ono-espera">No hay materias registradas para ese semestre.</div>';
143 + } catch (error) {
144 + contMaterias.innerHTML = `<div class="alert alert-error">${escapeHtml(error.message)}</div>`;
145 + }
146 + }
147 +
148 + /* ------------------------- PROFESOR ------------------------- */
149 +
150 + async function renderFormularioProfesor() {
151 + const cont = document.getElementById('ono-contenido');
152 + const nomina = sessionStorage.getItem('pending-numeroNomina') || '';
153 + cont.innerHTML = `
154 + <form id="ono-form-profesor">
155 + <div class="form-group">
156 + <label for="ono-nomina">Nómina / Matrícula Docente</label>
157 + <input type="text" id="ono-nomina" class="form-control" required
158 + value="${escapeHtml(nomina)}" placeholder="L01234567">
159 + </div>
160 + <div class="ono-section">
161 + <h3>Asignación Académica y Especialidad (Ruta MAPS)</h3>
162 + <div class="form-group">
163 + <label for="ono-area">Área / Carrera principal</label>
164 + <select id="ono-area" class="form-control">
165 + <option value="">Selecciona el departamento...</option>
166 + </select>
167 + </div>
168 + <div class="form-group">
169 + <label>Certificados a cargo</label>
170 + <div class="ono-grid" id="ono-certificados"><div class="ono-espera">${spinnerHtml()}</div></div>
171 + </div>
172 + <div class="form-group">
173 + <label>Materias que imparte</label>
174 + <div class="ono-grid" id="ono-materias-prof"><div class="ono-espera">${spinnerHtml()}</div></div>
175 + </div>
176 + <div class="form-group">
177 + <label for="ono-semestres">Semestres asignados</label>
178 + <input type="text" id="ono-semestres" class="form-control" placeholder="ej. 4°, 5°, 6°">
179 + <div class="form-hint">Ciclos académicos donde tiene grupos activos.</div>
180 + </div>
181 + </div>
182 + <div class="ono-section">
183 + <h3>Datos de Asesoría y Contacto</h3>
184 + <div class="form-group">
185 + <label for="ono-horario">Horario de asesoría semanal</label>
186 + <input type="text" id="ono-horario" class="form-control" placeholder="ej. Lunes y Miércoles 4:00 PM - 6:00 PM">
187 + </div>
188 + <div class="form-group">
189 + <label for="ono-enlace">Enlace de sala virtual</label>
190 + <input type="url" id="ono-enlace" class="form-control" placeholder="https://teams.microsoft.com/...">
191 + </div>
192 + <div class="form-group">
193 + <label for="ono-semblanza">Semblanza / Especialidad técnica</label>
194 + <textarea id="ono-semblanza" class="form-control" rows="3"
195 + placeholder="ej. Especialista en arquitectura backend Java y modelado relacional en MySQL."></textarea>
196 + </div>
197 + </div>
198 + <div class="form-group" style="margin-top:1.25rem">
199 + <button type="submit" class="btn btn-primary" style="width:100%">Guardar y empezar a navegar</button>
200 + </div>
201 + </form>`;
202 +
203 + const selArea = document.getElementById('ono-area');
204 + const contCert = document.getElementById('ono-certificados');
205 + const contMat = document.getElementById('ono-materias-prof');
206 +
207 + const [carreras, certificados, materias] = await Promise.all([
208 + cargarOpciones('/carreras', 'Sin carreras'),
209 + cargarOpciones('/certificados', 'Sin certificados'),
210 + cargarOpciones('/materias', 'Sin materias')
211 + ]);
212 +
213 + selArea.innerHTML =
214 + '<option value="">Selecciona el departamento...</option>' +
215 + carreras.map(c => `<option value="${escapeHtml(c.nombre)}">${escapeHtml(c.nombre)}</option>`).join('');
216 +
217 + contCert.innerHTML = certificados.length
218 + ? certificados.map(c => `<label>
219 + <input type="checkbox" name="ono-certificado" value="${c.id}"> ${escapeHtml(c.nombre)}
220 + </label>`).join('')
221 + : `<div class="ono-espera" style="grid-column:1/-1">Aún no hay certificados registrados en el catálogo.</div>`;
222 +
223 + contMat.innerHTML = materias.length
224 + ? materias.map(m => `<label>
225 + <input type="checkbox" name="ono-materia-prof" value="${m.id}"> ${escapeHtml(m.nombre)}
226 + </label>`).join('')
227 + : `<div class="ono-espera" style="grid-column:1/-1">Aún no hay materias en el catálogo.</div>`;
228 +
229 + document.getElementById('ono-form-profesor').addEventListener('submit', async (ev) => {
230 + ev.preventDefault();
231 + Utils.clearAlert('alert-container');
232 + const btn = ev.target.querySelector('button[type="submit"]');
233 + const certIds = [...document.querySelectorAll('input[name="ono-certificado"]:checked')].map(i => Number(i.value));
234 + const matIds = [...document.querySelectorAll('input[name="ono-materia-prof"]:checked')].map(i => Number(i.value));
235 +
236 + try {
237 + btn.disabled = true;
238 + btn.textContent = 'Guardando...';
239 + await API.request('/auth/onboarding/profesor', {
240 + method: 'POST',
241 + body: JSON.stringify({
242 + numeroNomina: document.getElementById('ono-nomina').value.trim(),
243 + areaEspecialidad: selArea.value,
244 + semestresAsignados: document.getElementById('ono-semestres').value.trim(),
245 + horarioAsesorias: document.getElementById('ono-horario').value.trim(),
246 + enlaceSalaVirtual: document.getElementById('ono-enlace').value.trim(),
247 + semblanza: document.getElementById('ono-semblanza').value.trim(),
248 + certificados: certIds,
249 + materias: matIds
250 + })
251 + });
252 + sessionStorage.removeItem('pending-numeroNomina');
253 + window.location.href = Auth.resolvePath('pages/inicio.html');
254 + } catch (error) {
255 + Utils.showAlert('alert-container', error.message || 'No se pudo completar el perfil.');
256 + btn.disabled = false;
257 + btn.textContent = 'Guardar y empezar a navegar';
258 + }
259 + });
260 + }
261 +
262 + /* ------------------------- UTILIDADES ------------------------- */
263 +
264 + function escapeHtml(texto) {
265 + return String(texto || '').replace(/[&<>"']/g, c => ({
266 + '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
267 + }[c]));
268 + }
269 +
270 + function spinnerHtml() {
271 + return '<div class="ono-espera">Cargando...</div>';
272 + }
273 +})();
\ No newline at end of file
frontend/js/pages/perfil.js
+262 -11
@@ -6,12 +6,18 @@
6 */
7
8 const Perfil = {
9 + state: {
10 + proposito: '',
11 + semestre: null
12 + },
13 +
14 async init() {
15 Layout.init('perfil.html');
16 Layout.setPageTitle('Mi Perfil');
17
18 // La cabecera puede pintarse con la sesión real del usuario.
19 this.renderHeader(Auth.getUser());
20 + this.bindEventos();
21
22 try {
23 const data = await API.request('/estudiantes/perfil');
@@ -21,6 +27,48 @@ const Perfil = {
27 }
28 },
29
30 + bindEventos() {
31 + const btn = document.getElementById('btn-editar-proposito');
32 + btn?.addEventListener('click', () => this.abrirModalProposito());
33 +
34 + const btnSemestre = document.getElementById('btn-cambiar-semestre');
35 + btnSemestre?.addEventListener('click', () => this.abrirModalSemestre());
36 +
37 + const btnFoto = document.getElementById('btn-cambiar-foto');
38 + const input = document.getElementById('foto-input');
39 + btnFoto?.addEventListener('click', () => input?.click());
40 + input?.addEventListener('change', () => this.cambiarFoto(input));
41 +
42 + // Delegación para los botones Seguir/Siguiendo de "Descubrir personas".
43 + document.getElementById('discover-list')?.addEventListener('click', e => {
44 + const followBtn = e.target.closest('.follow-btn');
45 + if (!followBtn) return;
46 + e.preventDefault();
47 + const id = followBtn.getAttribute('data-id');
48 + if (!id) return;
49 + Utils.alternarSeguir(followBtn, id);
50 + });
51 + },
52 +
53 + async cambiarFoto(input) {
54 + const archivo = input && input.files && input.files[0];
55 + if (!archivo) return;
56 + try {
57 + const dataUrl = await Utils.fotoDataUrl(archivo);
58 + await API.request('/usuarios/foto', {
59 + method: 'PUT',
60 + body: JSON.stringify({ foto: dataUrl })
61 + });
62 + Auth.foto(dataUrl);
63 + this.renderHeader(Auth.getUser());
64 + Utils.toast('Foto de perfil actualizada.', 'success');
65 + } catch (e) {
66 + Utils.toast(e.message || 'No se pudo actualizar la foto.', 'error');
67 + } finally {
68 + input.value = '';
69 + }
70 + },
71 +
72 renderHeader(user) {
73 const host = document.getElementById('profile-header-info');
74 if (!host) return;
@@ -32,10 +80,10 @@ const Perfil = {
80 return;
81 }
82
35 - const initials = this.initials(nombre || email);
83 + const foto = (user && user.foto) || null;
84
85 host.innerHTML = `
38 - <div class="profile-avatar">${this.esc(initials)}</div>
86 + <div>${Utils.avatarHtml(nombre, foto, 'avatar profile-avatar', nombre)}</div>
87 <div>
88 <h2 class="profile-name">${this.esc(nombre)}</h2>
89 ${email ? `<p class="profile-sub">${this.esc(email)}</p>` : ''}
@@ -45,22 +93,32 @@ const Perfil = {
93
94 renderProfile(data) {
95 // Cabecera con datos completos si el backend los trae.
96 + if (data.foto) Auth.foto(data.foto);
97 if (data.nombre || data.matricula || data.carrera) {
98 this.renderHeaderData(data);
99 }
100
101 + // Red
102 + this.renderRed(data);
103 +
104 // Propósito
105 const purposeBox = document.getElementById('profile-purpose');
106 const purposeText = document.getElementById('profile-purpose-text');
107 if (purposeBox && purposeText) {
108 if (data.proposito) {
109 + this.state.proposito = data.proposito;
110 purposeText.textContent = data.proposito;
111 purposeBox.hidden = false;
112 } else {
113 + this.state.proposito = '';
114 purposeBox.hidden = true;
115 }
116 }
117
118 + if (data.semestre != null) {
119 + this.state.semestre = Number(data.semestre);
120 + }
121 +
122 // Certificados
123 const certs = Array.isArray(data.certificados) ? data.certificados : [];
124 this.renderCertificados(certs);
@@ -78,7 +136,7 @@ const Perfil = {
136 if (!host) return;
137
138 const nombre = data.nombre || (Auth.getUser() && Auth.getUser().nombre) || '';
81 - const initials = this.initials(nombre);
139 + const foto = data.foto || null;
140
141 const subParts = [];
142 if (data.matricula) subParts.push(`Matrícula: <strong>${this.esc(data.matricula)}</strong>`);
@@ -91,7 +149,7 @@ const Perfil = {
149 const sub2 = subParts2.length ? `<p class="profile-sub" style="margin-top:2px;">${subParts2.join(' • ')}</p>` : '';
150
151 host.innerHTML = `
94 - <div class="profile-avatar">${this.esc(initials)}</div>
152 + <div>${Utils.avatarHtml(nombre, foto, 'avatar profile-avatar', nombre)}</div>
153 <div>
154 <h2 class="profile-name">${this.esc(nombre)}</h2>
155 ${sub1}
@@ -100,6 +158,66 @@ const Perfil = {
158 `;
159 },
160
161 + renderRed(data) {
162 + const card = document.getElementById('profile-red');
163 + if (!card) return;
164 + const siguiendo = data.siguiendo;
165 + const seguidores = data.seguidores;
166 + if (siguiendo == null && seguidores == null) {
167 + card.hidden = true;
168 + return;
169 + }
170 + card.hidden = false;
171 +
172 + const stats = document.getElementById('red-stats');
173 + if (stats) {
174 + stats.innerHTML = `
175 + <div class="red-stat"><span class="number">${this.esc(seguidores != null ? seguidores : '—')}</span><span class="label">Seguidores</span></div>
176 + <div class="red-stat"><span class="number">${this.esc(siguiendo != null ? siguiendo : '—')}</span><span class="label">Siguiendo</span></div>
177 + `;
178 + }
179 +
180 + this.cargarDescubrir();
181 + },
182 +
183 + async cargarDescubrir() {
184 + const host = document.getElementById('discover-list');
185 + if (!host) return;
186 + try {
187 + const res = await API.request('/usuarios/descubrir');
188 + const lista = Array.isArray(res) ? res : (res && Array.isArray(res.data) ? res.data : []);
189 + this.renderDescubrir(lista);
190 + } catch (e) {
191 + host.innerHTML = '<p class="discover-empty">No se pudo cargar la red en este momento.</p>';
192 + }
193 + },
194 +
195 + renderDescubrir(lista) {
196 + const host = document.getElementById('discover-list');
197 + if (!host) return;
198 + if (!lista.length) {
199 + host.innerHTML = '<p class="discover-empty">Ya sigues a todas las personas de tu comunidad.</p>';
200 + return;
201 + }
202 +
203 + host.innerHTML = lista.map(p => {
204 + const esDocente = p.rol === 'PROFESOR' || p.rol === 'profesor';
205 + const rol = esDocente ? 'Docente' : (p.carrera ? p.carrera : 'Estudiante');
206 + const seguidores = Number(p.seguidores) || 0;
207 + const loSigo = p.loSigo === true || p.loSigo === 'true';
208 + return `
209 + <div class="discover-person">
210 + <a class="user-link" href="usuario.html?id=${this.esc(p.id)}" aria-label="Ver perfil de ${this.esc(p.nombre)}">${Utils.avatarHtml(p.nombre, p.foto, 'avatar', p.nombre)}</a>
211 + <div class="discover-info">
212 + <a class="user-link" href="usuario.html?id=${this.esc(p.id)}"><strong>${this.esc(p.nombre)}</strong></a>
213 + <span>${this.esc(rol)} • ${seguidores} seguidores</span>
214 + </div>
215 + <button class="follow-btn${loSigo ? ' siguiendo' : ''}" type="button"
216 + data-id="${this.esc(p.id)}" data-siguiendo="${loSigo ? 'true' : 'false'}">${loSigo ? 'Siguiendo' : 'Seguir'}</button>
217 + </div>`;
218 + }).join('');
219 + },
220 +
221 renderCertificados(certs) {
222 const host = document.getElementById('profile-certificados');
223 if (!host) return;
@@ -168,12 +286,145 @@ const Perfil = {
286 `).join('');
287 },
288
171 - initials(name) {
172 - if (!name) return '?';
173 - const parts = String(name).trim().split(/\s+/);
174 - const first = parts[0] ? parts[0][0] : '';
175 - const second = parts[1] ? parts[1][0] : '';
176 - return (first + second).toUpperCase() || '?';
289 + abrirModalProposito() {
290 + let modal = document.getElementById('proposito-modal');
291 + if (!modal) {
292 + modal = document.createElement('div');
293 + modal.id = 'proposito-modal';
294 + modal.className = 'proposito-modal-overlay';
295 + document.body.appendChild(modal);
296 + }
297 +
298 + modal.innerHTML = `
299 + <div class="proposito-modal" role="dialog" aria-modal="true" aria-label="Editar propósito de vida">
300 + <h3>Propósito de Vida</h3>
301 + <p class="proposito-modal-sub">Describe tu propósito personal. Aparecerá en tu identidad y Ruta MAPS.</p>
302 + <textarea id="proposito-input" maxlength="1000" rows="4" placeholder="Escribe tu propósito de vida...">${this.esc(this.state.proposito)}</textarea>
303 + <div class="proposito-modal-msg" id="proposito-msg"></div>
304 + <div class="proposito-modal-actions">
305 + <button class="btn-clean" type="button" id="proposito-cancel">Cancelar</button>
306 + <button class="btn-solid" type="button" id="proposito-save">Guardar</button>
307 + </div>
308 + </div>`;
309 +
310 + modal.addEventListener('click', e => {
311 + if (e.target === modal) this.cerrarModalProposito();
312 + });
313 + document.getElementById('proposito-cancel').addEventListener('click', () => this.cerrarModalProposito());
314 + document.getElementById('proposito-save').addEventListener('click', () => this.guardarProposito());
315 + document.getElementById('proposito-input').focus();
316 +
317 + const input = document.getElementById('proposito-input');
318 + input.addEventListener('keydown', e => {
319 + if (e.key === 'Escape') this.cerrarModalProposito();
320 + });
321 + },
322 +
323 + async guardarProposito() {
324 + const input = document.getElementById('proposito-input');
325 + const msg = document.getElementById('proposito-msg');
326 + if (!input) return;
327 +
328 + const proposito = input.value.trim();
329 + const btn = document.getElementById('proposito-save');
330 + btn.disabled = true;
331 + btn.textContent = 'Guardando...';
332 +
333 + try {
334 + await API.request('/estudiantes/proposito', {
335 + method: 'PUT',
336 + body: JSON.stringify({ proposito })
337 + });
338 +
339 + this.state.proposito = proposito;
340 + const purposeBox = document.getElementById('profile-purpose');
341 + const purposeText = document.getElementById('profile-purpose-text');
342 + if (purposeText) purposeText.textContent = proposito;
343 + if (purposeBox) purposeBox.hidden = !proposito;
344 +
345 + this.cerrarModalProposito();
346 + Utils.toast(proposito ? 'Propósito de vida actualizado.' : 'Propósito de vida eliminado.', 'success');
347 + } catch (e) {
348 + if (msg) msg.textContent = e.message || 'No se pudo guardar el propósito.';
349 + btn.disabled = false;
350 + btn.textContent = 'Guardar';
351 + }
352 + },
353 +
354 + cerrarModalProposito() {
355 + const modal = document.getElementById('proposito-modal');
356 + if (modal) modal.remove();
357 + },
358 +
359 + abrirModalSemestre() {
360 + let modal = document.getElementById('semestre-modal');
361 + if (!modal) {
362 + modal = document.createElement('div');
363 + modal.id = 'semestre-modal';
364 + modal.className = 'proposito-modal-overlay';
365 + document.body.appendChild(modal);
366 + }
367 +
368 + const actual = this.state.semestre != null ? Number(this.state.semestre) : '';
369 + const opciones = Array.from({ length: 12 }, (_, i) => i + 1)
370 + .map(n => `<option value="${n}"${String(n) === String(actual) ? ' selected' : ''}>${n}° Semestre</option>`)
371 + .join('');
372 +
373 + modal.innerHTML = `
374 + <div class="proposito-modal" role="dialog" aria-modal="true" aria-label="Cambiar semestre">
375 + <h3>Cambiar de Semestre</h3>
376 + <p class="proposito-modal-sub">Al avanzar de semestre se actualizarán tus materias del plan de estudios y la información de la comunidad corresponderá a tu nuevo semestre.</p>
377 + <label for="semestre-select" style="display:block;margin-bottom:6px;font-weight:bold;">Semestre actualmente vigente</label>
378 + <select id="semestre-select" class="input-search">${opciones}</select>
379 + <div class="proposito-modal-msg" id="semestre-msg"></div>
380 + <div class="proposito-modal-actions">
381 + <button class="btn-clean" type="button" id="semestre-cancel">Cancelar</button>
382 + <button class="btn-solid" type="button" id="semestre-save">Guardar</button>
383 + </div>
384 + </div>`;
385 +
386 + modal.addEventListener('click', e => {
387 + if (e.target === modal) this.cerrarModalSemestre();
388 + });
389 + document.getElementById('semestre-cancel').addEventListener('click', () => this.cerrarModalSemestre());
390 + document.getElementById('semestre-save').addEventListener('click', () => this.guardarSemestre());
391 + },
392 +
393 + async guardarSemestre() {
394 + const select = document.getElementById('semestre-select');
395 + const msg = document.getElementById('semestre-msg');
396 + if (!select) return;
397 +
398 + const semestre = Number(select.value);
399 + const btn = document.getElementById('semestre-save');
400 + btn.disabled = true;
401 + btn.textContent = 'Guardando...';
402 +
403 + try {
404 + await API.request('/estudiantes/semestre', {
405 + method: 'PUT',
406 + body: JSON.stringify({ semestre })
407 + });
408 + this.cerrarModalSemestre();
409 + this.state.semestre = semestre;
410 + Utils.toast(`Avanzaste al ${semestre}° semestre.`, 'success');
411 +
412 + try {
413 + const data = await API.request('/estudiantes/perfil');
414 + this.renderProfile(data || {});
415 + } catch (e) {
416 + // La cabecera ya se re-renderiza si es posible; el resto se mantiene.
417 + }
418 + } catch (e) {
419 + if (msg) msg.textContent = e.message || 'No se pudo actualizar el semestre.';
420 + btn.disabled = false;
421 + btn.textContent = 'Guardar';
422 + }
423 + },
424 +
425 + cerrarModalSemestre() {
426 + const modal = document.getElementById('semestre-modal');
427 + if (modal) modal.remove();
428 },
429
430 esc(str) {
@@ -183,7 +434,7 @@ const Perfil = {
434 .replace(/>/g, '&gt;')
435 .replace(/"/g, '&quot;')
436 .replace(/'/g, '&#39;');
186 - }
437 + },
438 };
439
440 document.addEventListener('DOMContentLoaded', () => Perfil.init());
frontend/js/pages/registro.js
+29 -9
@@ -1,7 +1,10 @@
1 const EMAIL_INSTITUCIONAL_REGEX = /^[^\s@]+@(tecmilenio\.mx|servicios\.tecmilenio\.mx)$/i;
2
3 -document.addEventListener('DOMContentLoaded', () => {
4 - Auth.redirectIfAuthenticated();
3 +document.addEventListener('DOMContentLoaded', async () => {
4 + await Auth.redirectIfAuthenticated();
5 +
6 + document.getElementById('link-aviso-registro')?.addEventListener('click', () => AvisoPrivacidad.mostrar('Aviso de Privacidad'));
7 + document.getElementById('link-terminos-registro')?.addEventListener('click', () => AvisoPrivacidad.mostrar('Términos y Condiciones'));
8
9 document.getElementById('registro-form').addEventListener('submit', async (e) => {
10 e.preventDefault();
@@ -12,19 +15,26 @@ document.addEventListener('DOMContentLoaded', () => {
15 const email = document.getElementById('email').value.trim();
16 const contrasena = document.getElementById('contrasena').value;
17 const confirmarContrasena = document.getElementById('confirmar-contrasena').value;
18 + const identificador = document.getElementById('matricula').value.trim();
19
16 - if (!nombre || !apellido || !email || !contrasena || !confirmarContrasena) {
20 + if (!nombre || !apellido || !email || !contrasena || !confirmarContrasena || !identificador) {
21 Utils.showAlert('alert-container', 'Completa todos los campos.');
22 return;
23 }
24
25 + const acepTerminos = document.getElementById('acepta-terminos');
26 + if (!acepTerminos || !acepTerminos.checked) {
27 + Utils.showAlert('alert-container', 'Debes aceptar el Aviso de Privacidad y los Términos y Condiciones para crear tu cuenta.');
28 + return;
29 + }
30 +
31 if (!EMAIL_INSTITUCIONAL_REGEX.test(email)) {
32 Utils.showAlert('alert-container', 'Usa tu correo institucional (@tecmilenio.mx o @servicios.tecmilenio.mx).');
33 return;
34 }
35
26 - if (contrasena.length < 6) {
27 - Utils.showAlert('alert-container', 'La contraseña debe tener al menos 6 caracteres.');
36 + if (contrasena.length < 8 || !/[A-Z]/.test(contrasena) || !/[a-z]/.test(contrasena) || !/\d/.test(contrasena)) {
37 + Utils.showAlert('alert-container', 'La contraseña debe tener al menos 8 caracteres y combinar mayúscula, minúscula y número.');
38 return;
39 }
40
@@ -38,17 +48,27 @@ document.addEventListener('DOMContentLoaded', () => {
48 submitBtn.textContent = 'Creando cuenta...';
49
50 try {
51 + const payload = {
52 + nombre,
53 + apellido,
54 + email,
55 + contrasena,
56 + rol: 'ESTUDIANTE',
57 + matricula: identificador
58 + };
59 +
60 const response = await API.request('/auth/registrar', {
61 method: 'POST',
43 - body: JSON.stringify({ nombre, apellido, email, contrasena })
62 + body: JSON.stringify(payload)
63 });
64
46 - Auth.saveSession(response.data.token, response.data.usuario);
47 - window.location.href = Auth.resolvePath('pages/inicio.html');
65 + Auth.saveSession(response.token, response.usuario);
66 + sessionStorage.setItem('pending-matricula', identificador);
67 + window.location.href = Auth.resolvePath('onboarding.html');
68 } catch (error) {
69 Utils.showAlert('alert-container', error.message || 'No se pudo completar el registro.');
70 submitBtn.disabled = false;
71 submitBtn.textContent = 'Crear cuenta';
72 }
73 });
54 -});
74 +});
\ No newline at end of file
frontend/js/pages/usuario.js new
+247
@@ -0,0 +1,247 @@
1 +/**
2 + * MAPS Connect - Perfil público de otro usuario (alumno o docente).
3 + * Contrato esperado de GET /usuarios/{id}:
4 + * { id, nombre, foto, rol, esYo, reputacion, seguidores, siguiendo, loSigo }
5 + * ESTUDIANTE: { carrera, semestre, proposito, certificados[{titulo,descripcion}],
6 + * dudas, respuestas, aportes[{titulo,detalle,tipo,meta}] }
7 + * PROFESOR: { especialidad, biografia, horarioAsesorias, disponibleChat,
8 + * materias[], respuestas, aportes[{titulo,detalle,tipo,meta}] }
9 + */
10 +
11 +const UsuarioPub = {
12 + data: null,
13 +
14 + async init() {
15 + Layout.init('usuario.html');
16 + Layout.setPageTitle('Perfil');
17 +
18 + const root = document.getElementById('usuario-root');
19 + if (!root) return;
20 +
21 + const id = new URLSearchParams(window.location.search).get('id');
22 + if (!id) {
23 + this.renderError(root, 'No se indicó un usuario.');
24 + return;
25 + }
26 +
27 + try {
28 + const data = await API.request(`/usuarios/${encodeURIComponent(id)}`);
29 + if (!data) {
30 + this.renderError(root, 'No se pudo cargar el perfil.');
31 + return;
32 + }
33 + this.data = data;
34 + this.render();
35 + } catch (e) {
36 + this.renderError(root, (e && e.message) || 'No se pudo cargar el perfil.');
37 + }
38 + },
39 +
40 + num(v) {
41 + const n = Number(v);
42 + return Number.isFinite(n) ? n : null;
43 + },
44 +
45 + /* ---------- Render principal ---------- */
46 +
47 + render() {
48 + const d = this.data;
49 + const esDocente = d.rol === 'PROFESOR';
50 +
51 + document.getElementById('usuario-nombre').textContent = d.nombre || 'Usuario';
52 + const trigger = document.getElementById('avatarTrigger');
53 + trigger.innerHTML = Utils.avatarHtml(d.nombre, d.foto, 'profile-avatar trigger', d.nombre);
54 +
55 + const sub = document.getElementById('usuario-sub');
56 + const sub2 = document.getElementById('usuario-sub2');
57 + if (esDocente) {
58 + sub.innerHTML = d.especialidad
59 + ? `Docente &bull; <strong>${Utils.esc(d.especialidad)}</strong>`
60 + : 'Docente';
61 + sub2.innerHTML = `Reputaci&oacute;n: <strong>${this.neto(d.reputacion)}</strong>`;
62 + } else {
63 + sub.innerHTML = d.carrera
64 + ? `Estudiante &bull; <strong>${Utils.esc(d.carrera)}</strong>`
65 + : 'Estudiante';
66 + sub2.innerHTML = (d.semestre
67 + ? `Semestre Vigente: <strong>${this.neto(d.semestre)}&deg; Semestre</strong> &bull; `
68 + : '')
69 + + `Reputaci&oacute;n: <strong>${this.neto(d.reputacion)}</strong>`;
70 + }
71 +
72 + this.renderActions(d);
73 + this.renderIzquierda(d);
74 + this.renderDerecha(d);
75 + },
76 +
77 + neto(v) {
78 + const n = this.num(v);
79 + return n == null ? '—' : n;
80 + },
81 +
82 + renderActions(d) {
83 + const actions = document.getElementById('usuario-actions');
84 + if (!actions) return;
85 +
86 + if (d.esYo) {
87 + actions.innerHTML = '<a class="btn-clean" href="perfil.html">Este es tu perfil</a>';
88 + return;
89 + }
90 +
91 + const loSigo = d.loSigo === true || d.loSigo === 'true';
92 + actions.innerHTML = `
93 + <button class="follow-btn${loSigo ? ' siguiendo' : ''}" type="button" id="usuario-follow" data-siguiendo="${loSigo}">${loSigo ? 'Siguiendo' : 'Seguir'}</button>
94 + <button class="btn-clean" type="button" id="usuario-invitar">Invitar a C&iacute;rculo</button>
95 + <button class="btn-solid" type="button" id="usuario-mensaje">Enviar Mensaje</button>
96 + `;
97 +
98 + document.getElementById('usuario-follow')?.addEventListener('click', async e => {
99 + const siguiente = await Utils.alternarSeguir(e.target, d.id);
100 + const box = document.getElementById('stat-seg');
101 + if (box) {
102 + const base = this.num(box.dataset.base);
103 + if (base != null) box.textContent = String(base + (siguiente ? 1 : -1));
104 + }
105 + });
106 +
107 + document.getElementById('usuario-invitar')?.addEventListener('click', () => {
108 + Utils.toast('La invitación a círculos de estudio se habilita próximamente.', 'info');
109 + });
110 +
111 + document.getElementById('usuario-mensaje')?.addEventListener('click', async () => {
112 + try {
113 + await API.request(`/mensajes/nuevo/${encodeURIComponent(d.id)}`, { method: 'POST' });
114 + } catch (e) {
115 + // La conversación suele existir; se navega igual.
116 + }
117 + window.location.href = 'mensajes.html';
118 + });
119 + },
120 +
121 + renderIzquierda(d) {
122 + const host = document.getElementById('col-izquierda');
123 + if (!host) return;
124 +
125 + if (d.rol === 'ESTUDIANTE') {
126 + const certs = Array.isArray(d.certificados) ? d.certificados : [];
127 + const proposito = d.proposito
128 + ? `<div class="purpose-box"><strong>Prop&oacute;sito Declarado</strong><p>${Utils.esc(d.proposito)}</p></div>`
129 + : '';
130 + const listaCerts = certs.length ? certs.map((c, i) => `
131 + <div class="certificate-item${i === 0 ? ' active' : ''}">
132 + <div class="cert-header">
133 + <span class="cert-title">${Utils.esc(c.titulo || 'Certificado MAPS')}</span>
134 + <span class="badge badge-green">En Curso</span>
135 + </div>
136 + <p class="cert-desc">${Utils.esc(c.descripcion)}</p>
137 + </div>`).join('')
138 + : '<p class="empty-note">Aún no ha seleccionado certificados MAPS.</p>';
139 +
140 + host.innerHTML = `
141 + <h3 class="section-heading">Ruta Acad&eacute;mica MAPS</h3>
142 + ${proposito}
143 + <h4 class="subsection-heading">Certificados en Curso y Obtenidos</h4>
144 + ${listaCerts}`;
145 + return;
146 + }
147 +
148 + if (d.rol === 'PROFESOR') {
149 + const bio = d.biografia
150 + ? `<div class="purpose-box"><strong>Sobre ${Utils.esc(d.nombre)}</strong><p>${Utils.esc(d.biografia)}</p></div>`
151 + : '';
152 + const materias = (Array.isArray(d.materias) && d.materias.length)
153 + ? `<li><strong>Materias asignadas:</strong><span style="display:block;margin-top:4px;">${d.materias.map(m => `<span class="materia-tag">${Utils.esc(m)}</span>`).join('')}</span></li>`
154 + : '';
155 +
156 + host.innerHTML = `
157 + <h3 class="section-heading">Perfil Docente</h3>
158 + ${bio}
159 + <ul class="prof-detail-list">
160 + ${d.especialidad ? `<li><strong>Especialidad:</strong> ${Utils.esc(d.especialidad)}</li>` : ''}
161 + ${materias}
162 + ${d.horarioAsesorias ? `<li><strong>Horario de asesor&iacute;as:</strong> ${Utils.esc(d.horarioAsesorias)}</li>` : ''}
163 + <li><strong>Disponible en chat:</strong> <span class="${d.disponibleChat ? 'ok' : 'off'}">${d.disponibleChat ? 'S&iacute;' : 'No'}</span></li>
164 + </ul>`;
165 + return;
166 + }
167 +
168 + host.innerHTML = '<p class="empty-note">Perfil sin información académica adicional.</p>';
169 + },
170 +
171 + renderDerecha(d) {
172 + const esDocente = d.rol === 'PROFESOR';
173 + const stats = esDocente ? [
174 + { key: 'seg', label: 'Seguidores', val: d.seguidores },
175 + { key: 'sig', label: 'Siguiendo', val: d.siguiendo },
176 + { key: 'res', label: 'Respuestas Aportadas', val: d.respuestas },
177 + { key: 'mat', label: 'Materias', val: Array.isArray(d.materias) ? d.materias.length : 0 }
178 + ] : [
179 + { key: 'seg', label: 'Seguidores', val: d.seguidores },
180 + { key: 'sig', label: 'Siguiendo', val: d.siguiendo },
181 + { key: 'dud', label: 'Dudas en el Foro', val: d.dudas },
182 + { key: 'res', label: 'Respuestas Aportadas', val: d.respuestas }
183 + ];
184 +
185 + const statsHost = document.getElementById('usuario-stats');
186 + if (statsHost) {
187 + statsHost.innerHTML = stats.map(s => `
188 + <div class="stat-box">
189 + <span class="number" id="stat-${s.key}"${s.key === 'seg' ? ` data-base="${this.neto(s.val)}"` : ''}>${this.neto(s.val)}</span>
190 + <span class="label">${Utils.esc(s.label)}</span>
191 + </div>`).join('');
192 + }
193 +
194 + const aportes = Array.isArray(d.aportes) ? d.aportes : [];
195 + const aportesHost = document.getElementById('usuario-aportes');
196 + if (aportesHost) {
197 + aportesHost.innerHTML = aportes.length ? aportes.map(a => `
198 + <div class="public-item">
199 + <strong>${Utils.esc(a.titulo || 'Aporte')}</strong>
200 + <p>${Utils.esc(a.detalle)}</p>
201 + <div class="item-meta">
202 + <span>${a.tipo === 'respuesta' ? 'Respuesta en foro' : 'Duda publicada'}</span>
203 + <span>${Utils.esc(a.meta)}</span>
204 + </div>
205 + </div>`).join('')
206 + : '<p class="empty-note">Aún no hay aportaciones públicas.</p>';
207 + }
208 + },
209 +
210 + /* ---------- Modal para ampliar avatar ---------- */
211 +
212 + renderError(root, message) {
213 + root.innerHTML = `<div class="card empty-state"><h2>Perfil no disponible</h2><p>${Utils.esc(message)}</p><a class="side-link" href="inicio.html">Volver al inicio →</a></div>`;
214 + }
215 +};
216 +
217 +document.addEventListener('DOMContentLoaded', () => {
218 +
219 + const backdrop = document.getElementById('avatarModal');
220 + const trigger = document.getElementById('avatarTrigger');
221 + const closeBtn = document.getElementById('closeModalBtn');
222 + const expanded = document.getElementById('avatarExpanded');
223 +
224 + function abrirModal() {
225 + const d = UsuarioPub.data;
226 + if (!d || !backdrop || !expanded) return;
227 + if (d.foto) {
228 + expanded.innerHTML = `<img class="avatar-expanded-box" src="${Utils.esc(d.foto)}" alt="Foto de ${Utils.esc(d.nombre)}">`;
229 + } else {
230 + expanded.innerHTML = `<span class="avatar-expanded-box">${Utils.esc(Utils.iniciales(d.nombre))}</span>`;
231 + }
232 + backdrop.classList.add('show');
233 + }
234 +
235 + function cerrarModal() {
236 + if (backdrop) backdrop.classList.remove('show');
237 + }
238 +
239 + trigger?.addEventListener('click', abrirModal);
240 + closeBtn?.addEventListener('click', cerrarModal);
241 + backdrop?.addEventListener('click', e => { if (e.target === backdrop) cerrarModal(); });
242 + document.addEventListener('keydown', e => {
243 + if (e.key === 'Escape' && backdrop.classList.contains('show')) cerrarModal();
244 + });
245 +
246 + UsuarioPub.init();
247 +});
\ No newline at end of file
frontend/js/redirect-index.js new
+1
@@ -0,0 +1 @@
1 +window.location.replace('index.html');
frontend/js/tema.js new
+44
@@ -0,0 +1,44 @@
1 +/**
2 + * MAPS Connect - Modo oscuro / claro
3 + * Guarda la preferencia en localStorage y aplica la clase "dark-mode"
4 + * sobre el elemento <html>. Cualquier página puede leer/alternar usando Tema.
5 + */
6 +const Tema = {
7 + KEY: 'tema-maps',
8 + OSCURO: 'oscuro',
9 +
10 + activo() {
11 + return document.documentElement.classList.contains('dark-mode');
12 + },
13 +
14 + /** Aplica el tema guardado al <html>. Devuelve true si quedó oscuro.
15 + * Por defecto el tema es claro; el modo oscuro solo se activa
16 + * cuando el usuario lo elige explícitamente. */
17 + aplicar() {
18 + const guardado = localStorage.getItem(this.KEY);
19 + const oscuro = guardado === this.OSCURO;
20 + this.set(oscuro);
21 + return oscuro;
22 + },
23 +
24 + set(oscuro) {
25 + document.documentElement.classList.toggle('dark-mode', oscuro);
26 + const icono = document.documentElement.querySelector('[data-tema-icono]');
27 + if (icono) icono.textContent = oscuro ? '🌙' : '☀️';
28 + },
29 +
30 + /** Alterna y guarda. Devuelve el nuevo estado (true = oscuro). */
31 + alternar() {
32 + const nuevo = !this.activo();
33 + this.set(nuevo);
34 + try {
35 + localStorage.setItem(this.KEY, nuevo ? this.OSCURO : 'claro');
36 + } catch (e) {
37 + // almacenamiento no disponible; solo se aplica en esta sesión.
38 + }
39 + return nuevo;
40 + }
41 +};
42 +
43 +// Se aplica en cuanto se carga el script (evita parpadeo al pintar).
44 +(globalThis.__MAPS_TEMA_APLICADO__ || (() => { Tema.aplicar(); globalThis.__MAPS_TEMA_APLICADO__ = true; }))();
frontend/js/utils.js
+108
@@ -1,4 +1,88 @@
1 const Utils = {
2 + esc(str) {
3 + return String(str == null ? '' : str)
4 + .replace(/&/g, '&amp;')
5 + .replace(/</g, '&lt;')
6 + .replace(/>/g, '&gt;')
7 + .replace(/"/g, '&quot;')
8 + .replace(/'/g, '&#39;');
9 + },
10 +
11 + iniciales(nombre) {
12 + if (!nombre) return '?';
13 + const parts = String(nombre).trim().split(/\s+/);
14 + const first = parts[0] ? parts[0][0] : '';
15 + const second = parts[1] ? parts[1][0] : '';
16 + return (first + second).toUpperCase() || '?';
17 + },
18 +
19 + /** Avatar con foto si existe; si no, iniciales. clase = clases CSS del avatar. */
20 + avatarHtml(nombre, foto, clase = 'avatar', alt = '') {
21 + const safe = this.esc(alt || nombre || 'usuario');
22 + if (foto) {
23 + return `<img class="${this.esc(clase)} avatar-foto" src="${foto}" alt="${safe}">`;
24 + }
25 + return `<span class="${this.esc(clase)}" aria-hidden="true">${this.esc(this.iniciales(nombre))}</span>`;
26 + },
27 +
28 + /** Convierte una imagen seleccionada a data URL comprimida (para foto de perfil). */
29 + fotoDataUrl(archivo, max = 512, calidad = 0.85) {
30 + return new Promise((resolve, reject) => {
31 + if (!archivo) return reject(new Error('Selecciona una imagen.'));
32 + if (!/^image\/(png|jpe?g|webp)$/i.test(archivo.type)) {
33 + return reject(new Error('Formato no soportado. Usa PNG o JPG.'));
34 + }
35 + const reader = new FileReader();
36 + reader.onerror = () => reject(new Error('No se pudo leer la imagen.'));
37 + reader.onload = () => {
38 + const img = new Image();
39 + img.onerror = () => reject(new Error('La imagen no es válida.'));
40 + img.onload = () => {
41 + const escala = Math.min(1, max / Math.max(img.width, img.height));
42 + const w = Math.max(1, Math.round(img.width * escala));
43 + const h = Math.max(1, Math.round(img.height * escala));
44 + const canvas = document.createElement('canvas');
45 + canvas.width = w;
46 + canvas.height = h;
47 + const ctx = canvas.getContext('2d');
48 + ctx.drawImage(img, 0, 0, w, h);
49 + resolve(canvas.toDataURL('image/jpeg', calidad));
50 + };
51 + img.src = reader.result;
52 + };
53 + reader.readAsDataURL(archivo);
54 + });
55 + },
56 +
57 + /** Cambia el estado visual del botón Seguir/Siguiendo según dataset.siguiendo. */
58 + pintarBotonSeguir(btn, siguiendo) {
59 + if (!btn) return;
60 + btn.dataset.siguiendo = String(siguiendo);
61 + btn.textContent = siguiendo ? 'Siguiendo' : 'Seguir';
62 + btn.classList.toggle('siguiendo', siguiendo);
63 + },
64 +
65 + /** Alterna el seguimiento: llama POST/DELETE /usuarios/{id}/seguir. */
66 + async alternarSeguir(btn, idUsuario) {
67 + if (!btn || !idUsuario) return;
68 + const siguiendo = btn.dataset.siguiendo === 'true';
69 + const siguiente = !siguiendo;
70 + btn.disabled = true;
71 + try {
72 + await API.request(`/usuarios/${idUsuario}/seguir`, {
73 + method: siguiente ? 'POST' : 'DELETE'
74 + });
75 + this.pintarBotonSeguir(btn, siguiente);
76 + return siguiente;
77 + } catch (e) {
78 + this.toast(e.message || 'No se pudo actualizar el seguimiento.', 'error');
79 + this.pintarBotonSeguir(btn, siguiendo);
80 + return siguiendo;
81 + } finally {
82 + btn.disabled = false;
83 + }
84 + },
85 +
86 showAlert(containerId, message, type = 'error') {
87 const container = document.getElementById(containerId);
88 if (!container) return;
@@ -8,5 +92,29 @@ const Utils = {
92 clearAlert(containerId) {
93 const container = document.getElementById(containerId);
94 if (container) container.innerHTML = '';
95 + },
96 +
97 + /** Muestra una notificación flotante temporal (toast). */
98 + toast(message, type = 'info', duration = 3500) {
99 + let root = document.getElementById('toast-root');
100 + if (!root) {
101 + root = document.createElement('div');
102 + root.id = 'toast-root';
103 + root.setAttribute('aria-live', 'polite');
104 + document.body.appendChild(root);
105 + }
106 +
107 + const el = document.createElement('div');
108 + el.className = `toast toast-${type}`;
109 + el.textContent = message;
110 + root.appendChild(el);
111 +
112 + // Animar entrada.
113 + requestAnimationFrame(() => el.classList.add('show'));
114 +
115 + setTimeout(() => {
116 + el.classList.remove('show');
117 + setTimeout(() => el.remove(), 250);
118 + }, duration);
119 }
120 };
frontend/login.html
+1 -1
@@ -5,7 +5,7 @@
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Iniciar sesión | MAPS Connect</title>
7 <meta http-equiv="refresh" content="0; url=index.html">
8 - <script>window.location.replace('index.html');</script>
8 + <script src="js/redirect-index.js?v=5"></script>
9 </head>
10 <body>
11 <p>Redirigiendo a <a href="index.html">inicio de sesión</a>...</p>
frontend/onboarding.html new
+51
@@ -0,0 +1,51 @@
1 +<!DOCTYPE html>
2 +<html lang="es">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>Completa tu perfil | MAPS Connect</title>
7 + <script src="js/estilos.js?v=17"></script>
8 + <link rel="stylesheet" href="css/base.css?v=8">
9 + <link rel="stylesheet" href="css/layout.css?v=5">
10 + <link rel="stylesheet" href="css/components.css?v=8">
11 + <link rel="stylesheet" href="css/colores.css?v=1">
12 + <link rel="stylesheet" href="css/estilos.css?v=26">
13 + <style>
14 + .ono-section { margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid var(--gray-200); }
15 + .ono-section h3 { margin-bottom: 0.5rem; font-size: 1rem; }
16 + .ono-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
17 + .ono-grid label {
18 + display: flex; align-items: center; gap: 0.45rem;
19 + padding: 0.55rem 0.7rem;
20 + border: 1px solid var(--gray-300);
21 + border-radius: var(--border-radius);
22 + font-size: 0.88rem;
23 + cursor: pointer;
24 + }
25 + .ono-grid label.sem-activo { border-color: var(--primary-color); background: #eff6ff; }
26 + .ono-materias { margin-top: 0.5rem; }
27 + .ono-chip {
28 + display: inline-block;
29 + background: #f1f5f9; border: 1px solid var(--gray-200);
30 + border-radius: 999px; padding: 0.3rem 0.75rem;
31 + margin: 0.2rem 0.2rem 0 0; font-size: 0.82rem; color: var(--gray-800);
32 + }
33 + .ono-espera { color: var(--gray-600); font-size: 0.9rem; padding: 0.75rem 0; }
34 + </style>
35 +</head>
36 +<body class="register-page">
37 +<div class="auth-layout">
38 + <div class="auth-card card">
39 + <h1 class="page-title" id="ono-titulo">Completa tu perfil</h1>
40 + <p class="form-hint" id="ono-subtitulo"></p>
41 + <div id="alert-container"></div>
42 + <div id="ono-contenido"></div>
43 + </div>
44 +</div>
45 +<script src="js/api.js?v=5"></script>
46 +<script src="js/auth.js?v=7"></script>
47 +<script src="js/utils.js?v=5"></script>
48 +<script src="js/colores.js?v=1"></script>
49 +<script src="js/pages/onboarding.js?v=5"></script>
50 +</body>
51 +</html>
\ No newline at end of file
frontend/pages/admin.html new
+35
@@ -0,0 +1,35 @@
1 +<!DOCTYPE html>
2 +<html lang="es">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>Panel Administrativo | MAPS Connect</title>
7 + <script>try{if(localStorage.getItem('tema-maps')==='oscuro'){document.documentElement.classList.add('dark-mode')}}catch(e){}</script>
8 + <script src="../js/estilos.js?v=17"></script>
9 + <link rel="stylesheet" href="../css/base.css?v=8">
10 + <link rel="stylesheet" href="../css/layout.css?v=6">
11 + <link rel="stylesheet" href="../css/components.css?v=8">
12 + <link rel="stylesheet" href="../css/colores.css?v=1">
13 + <link rel="stylesheet" href="../css/admin.css?v=8">
14 + <link rel="stylesheet" href="../css/estilos.css?v=26">
15 +</head>
16 +<body>
17 + <div class="app-layout">
18 + <aside class="sidebar" id="sidebar"></aside>
19 + <div class="main-wrapper">
20 + <header class="topbar" id="topbar"></header>
21 + <main class="main-content" id="main-content">
22 +
23 + </main>
24 + </div>
25 + </div>
26 + <script src="../js/api.js?v=6"></script>
27 + <script src="../js/auth.js?v=7"></script>
28 + <script src="../js/utils.js?v=6"></script>
29 + <script src="../js/tema.js?v=2"></script>
30 + <script src="../js/colores.js?v=1"></script>
31 + <script src="../js/aviso-privacidad.js?v=1"></script>
32 + <script src="../js/layout.js?v=12"></script>
33 + <script src="../js/pages/admin.js?v=7"></script>
34 +</body>
35 +</html>
frontend/pages/ajustes.html new
+154
@@ -0,0 +1,154 @@
1 +<!DOCTYPE html>
2 +<html lang="es">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>Ajustes | MAPS Connect</title>
7 + <script>try{if(localStorage.getItem('tema-maps')==='oscuro'){document.documentElement.classList.add('dark-mode')}}catch(e){}</script>
8 + <script src="../js/estilos.js?v=17"></script>
9 + <link rel="stylesheet" href="../css/base.css?v=8">
10 + <link rel="stylesheet" href="../css/layout.css?v=6">
11 + <link rel="stylesheet" href="../css/components.css?v=8">
12 + <link rel="stylesheet" href="../css/colores.css?v=1">
13 + <link rel="stylesheet" href="../css/ajustes.css?v=4">
14 + <link rel="stylesheet" href="../css/estilos.css?v=26">
15 +</head>
16 +<body>
17 + <div class="app-layout">
18 + <aside class="sidebar" id="sidebar"></aside>
19 + <div class="main-wrapper">
20 + <header class="topbar" id="topbar"></header>
21 + <main class="main-content" id="main-content">
22 +
23 + <section class="ajustes-container">
24 + <h1 class="page-title">Ajustes</h1>
25 +
26 + <div class="ajustes-seccion">
27 + <h2 class="ajustes-seccion-titulo">Personalización</h2>
28 + <p class="ajustes-seccion-desc">
29 + Personaliza el aspecto visual de la plataforma: color de acento, estilo de la página
30 + y modo de apariencia.
31 + </p>
32 +
33 + <div class="card">
34 + <h2 class="section-heading">Color de la página</h2>
35 + <p class="page-description" style="margin-bottom:0.5rem;">
36 + Elige el color de acento para toda la aplicación. El cambio se guarda
37 + automáticamente y se aplica al instante.
38 + </p>
39 +
40 + <div class="personaliza-seccion">
41 + <label class="personaliza-label">Colores predefinidos</label>
42 + <div class="personaliza-swatches" id="swatches-colores" role="group" aria-label="Colores predefinidos">
43 + <!-- Swatches generados por ajustes.js -->
44 + </div>
45 +
46 + <label class="personaliza-label" for="color-custom">Color a medida</label>
47 + <div class="personaliza-custom">
48 + <input type="color" id="color-custom" value="#e1367f" aria-label="Elegir color a medida">
49 + <span class="personaliza-custom-texto" id="color-custom-texto">#e1367f</span>
50 + </div>
51 +
52 + <button type="button" class="btn btn-outline" id="btn-restaurar-color">
53 + Restablecer color por defecto
54 + </button>
55 + </div>
56 + </div>
57 +
58 + <div class="card">
59 + <h2 class="section-heading">Estilos de la página</h2>
60 + <p class="page-description" style="margin-bottom:0.75rem;">
61 + Elige un estilo visual para toda la aplicación: sidebar, barra superior,
62 + tarjetas, formularios y fondo. El cambio se aplica al instante.
63 + </p>
64 +
65 + <div class="estilos-seccion" id="estilos-lista" role="group" aria-label="Estilos de la página">
66 + <!-- Tarjetas de estilo generadas por ajustes.js -->
67 + </div>
68 + </div>
69 +
70 + <div class="card">
71 + <h2 class="section-heading">Apariencia</h2>
72 + <div class="ajustes-fila">
73 + <div class="ajustes-fila-texto">
74 + <strong>Modo oscuro</strong>
75 + <p>Reduce el brillo de la pantalla con colores oscuros en toda la aplicación.</p>
76 + </div>
77 + <label class="tema-switch" aria-label="Modo oscuro">
78 + <input type="checkbox" id="tema-toggle">
79 + <span class="slider" aria-hidden="true"></span>
80 + </label>
81 + </div>
82 + </div>
83 + </div>
84 +
85 + <div class="ajustes-seccion">
86 + <h2 class="ajustes-seccion-titulo">Mi cuenta</h2>
87 + <p class="ajustes-seccion-desc">
88 + Administra la información de tu cuenta: tu nombre y tu contraseña.
89 + </p>
90 +
91 + <div class="card">
92 + <h2 class="section-heading">Datos de la cuenta</h2>
93 + <div class="ajustes-fila" style="margin-bottom:0.25rem;">
94 + <div class="ajustes-fila-texto">
95 + <strong>Correo</strong>
96 + <p id="cuenta-email">—</p>
97 + </div>
98 + </div>
99 + <form id="form-nombre" class="cuenta-form" novalidate>
100 + <div class="form-group">
101 + <label for="input-nombre">Nombre completo</label>
102 + <input type="text" id="input-nombre" class="form-control" maxlength="150" required>
103 + </div>
104 + <button type="submit" class="btn btn-primary">Guardar</button>
105 + </form>
106 + </div>
107 +
108 + <div class="card">
109 + <h2 class="section-heading">Cambiar contraseña</h2>
110 + <form id="form-contrasena" class="cuenta-form" novalidate>
111 + <div class="form-group">
112 + <label for="input-contrasena-actual">Contraseña actual</label>
113 + <input type="password" id="input-contrasena-actual" class="form-control"
114 + autocomplete="current-password" required>
115 + </div>
116 + <div class="form-group">
117 + <label for="input-contrasena-nueva">Nueva contraseña</label>
118 + <input type="password" id="input-contrasena-nueva" class="form-control"
119 + autocomplete="new-password" required>
120 + <small class="form-hint">
121 + Mínimo 8 caracteres, con mayúscula, minúscula y número.
122 + </small>
123 + </div>
124 + <div class="form-group">
125 + <label for="input-contrasena-confirmar">Confirmar nueva contraseña</label>
126 + <input type="password" id="input-contrasena-confirmar" class="form-control"
127 + autocomplete="new-password" required>
128 + </div>
129 + <button type="submit" class="btn btn-primary">Guardar</button>
130 + </form>
131 + </div>
132 + </div>
133 +
134 + <div class="ajustes-seccion">
135 + <h2 class="ajustes-seccion-titulo">Legal</h2>
136 + <div class="card">
137 + <button type="button" class="btn btn-outline" id="btn-ver-aviso">Ver Aviso de Privacidad</button>
138 + </div>
139 + </div>
140 + </section>
141 +
142 + </main>
143 + </div>
144 + </div>
145 + <script src="../js/api.js?v=6"></script>
146 + <script src="../js/auth.js?v=7"></script>
147 + <script src="../js/utils.js?v=6"></script>
148 + <script src="../js/tema.js?v=2"></script>
149 + <script src="../js/colores.js?v=1"></script>
150 + <script src="../js/aviso-privacidad.js?v=1"></script>
151 + <script src="../js/layout.js?v=12"></script>
152 + <script src="../js/pages/ajustes.js?v=4"></script>
153 +</body>
154 +</html>
frontend/pages/certificados.html new
+40
@@ -0,0 +1,40 @@
1 +<!DOCTYPE html>
2 +<html lang="es">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>Mi Ruta MAPS | MAPS Connect</title>
7 + <script>try{if(localStorage.getItem('tema-maps')==='oscuro'){document.documentElement.classList.add('dark-mode')}}catch(e){}</script>
8 + <script src="../js/estilos.js?v=17"></script>
9 + <link rel="stylesheet" href="../css/base.css?v=8">
10 + <link rel="stylesheet" href="../css/layout.css?v=5">
11 + <link rel="stylesheet" href="../css/components.css?v=8">
12 + <link rel="stylesheet" href="../css/colores.css?v=1">
13 + <link rel="stylesheet" href="../css/certificados.css?v=6">
14 + <link rel="stylesheet" href="../css/estilos.css?v=26">
15 +</head>
16 +<body>
17 +
18 +
19 + <div class="app-layout">
20 + <aside class="sidebar" id="sidebar"></aside>
21 + <div class="main-wrapper">
22 + <header class="topbar" id="topbar"></header>
23 + <main class="main-content" id="main-content">
24 +
25 + </main>
26 + </div>
27 + </div>
28 + <script src="../js/api.js?v=5"></script>
29 + <script src="../js/auth.js?v=7"></script>
30 + <script src="../js/utils.js?v=5"></script>
31 + <script src="../js/tema.js?v=2"></script>
32 + <script src="../js/colores.js?v=1"></script>
33 + <script src="../js/aviso-privacidad.js?v=1"></script>
34 + <script src="../js/layout.js?v=12"></script>
35 + <script src="../js/pages/certificados.js?v=5"></script>
36 +
37 +
38 +
39 +</body>
40 +</html>
\ No newline at end of file
frontend/pages/circulos.html
+16 -9
@@ -4,10 +4,14 @@
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Círculos | MAPS Connect</title>
7 - <link rel="stylesheet" href="../css/base.css">
8 - <link rel="stylesheet" href="../css/layout.css">
9 - <link rel="stylesheet" href="../css/components.css">
10 - <link rel="stylesheet" href="../css/circulos.css">
7 + <script>try{if(localStorage.getItem('tema-maps')==='oscuro'){document.documentElement.classList.add('dark-mode')}}catch(e){}</script>
8 + <script src="../js/estilos.js?v=17"></script>
9 + <link rel="stylesheet" href="../css/base.css?v=8">
10 + <link rel="stylesheet" href="../css/layout.css?v=5">
11 + <link rel="stylesheet" href="../css/components.css?v=8">
12 + <link rel="stylesheet" href="../css/colores.css?v=1">
13 + <link rel="stylesheet" href="../css/circulos.css?v=6">
14 + <link rel="stylesheet" href="../css/estilos.css?v=26">
15 </head>
16 <body>
17 <div class="app-layout">
@@ -17,10 +21,13 @@
21 <main class="main-content" id="main-content"></main>
22 </div>
23 </div>
20 - <script src="../js/api.js"></script>
21 - <script src="../js/auth.js"></script>
22 - <script src="../js/utils.js"></script>
23 - <script src="../js/layout.js"></script>
24 - <script src="../js/pages/circulos.js"></script>
24 + <script src="../js/api.js?v=5"></script>
25 + <script src="../js/auth.js?v=7"></script>
26 + <script src="../js/utils.js?v=5"></script>
27 + <script src="../js/tema.js?v=2"></script>
28 + <script src="../js/colores.js?v=1"></script>
29 + <script src="../js/aviso-privacidad.js?v=1"></script>
30 + <script src="../js/layout.js?v=12"></script>
31 + <script src="../js/pages/circulos.js?v=5"></script>
32 </body>
33 </html>
frontend/pages/comunidad.html
+21 -10
@@ -4,10 +4,14 @@
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Comunidad y Recursos | MAPS Connect</title>
7 - <link rel="stylesheet" href="../css/base.css">
8 - <link rel="stylesheet" href="../css/layout.css">
9 - <link rel="stylesheet" href="../css/components.css">
10 - <link rel="stylesheet" href="../css/comunidad.css">
7 + <script>try{if(localStorage.getItem('tema-maps')==='oscuro'){document.documentElement.classList.add('dark-mode')}}catch(e){}</script>
8 + <script src="../js/estilos.js?v=17"></script>
9 + <link rel="stylesheet" href="../css/base.css?v=8">
10 + <link rel="stylesheet" href="../css/layout.css?v=5">
11 + <link rel="stylesheet" href="../css/components.css?v=8">
12 + <link rel="stylesheet" href="../css/colores.css?v=1">
13 + <link rel="stylesheet" href="../css/comunidad.css?v=6">
14 + <link rel="stylesheet" href="../css/estilos.css?v=26">
15 </head>
16 <body>
17 <div class="app-layout">
@@ -18,6 +22,9 @@
22
23 <section class="academic-hub-container">
24
25 + <!-- Aviso de filtro por carrera (se rellena desde /inicio) -->
26 + <p class="filter-note" id="career-note" hidden></p>
27 +
28 <!-- 1. BARRA SUPERIOR DE FILTROS Y ACCIÓN CONTEXTUAL -->
29 <div class="hub-filter-bar">
30 <input type="text" class="input-search" id="hub-search" placeholder="Buscar por tema, duda, apunte o concepto clave...">
@@ -37,7 +44,7 @@
44 Foro de Dudas
45 </button>
46 <button class="tab-button" type="button" role="tab"
40 - data-tab="apuntes" data-action="+ Subir Apunte (PDF)">
47 + data-tab="apuntes" data-action="+ Subir Apunte">
48 Repositorio de Apuntes
49 </button>
50 <button class="tab-button" type="button" role="tab"
@@ -66,10 +73,14 @@
73 </main>
74 </div>
75 </div>
69 - <script src="../js/api.js"></script>
70 - <script src="../js/auth.js"></script>
71 - <script src="../js/utils.js"></script>
72 - <script src="../js/layout.js"></script>
73 - <script src="../js/pages/comunidad.js"></script>
76 + <div id="modal-root"></div>
77 + <script src="../js/api.js?v=5"></script>
78 + <script src="../js/auth.js?v=7"></script>
79 + <script src="../js/utils.js?v=5"></script>
80 + <script src="../js/tema.js?v=2"></script>
81 + <script src="../js/colores.js?v=1"></script>
82 + <script src="../js/aviso-privacidad.js?v=1"></script>
83 + <script src="../js/layout.js?v=12"></script>
84 + <script src="../js/pages/comunidad.js?v=5"></script>
85 </body>
86 </html>
frontend/pages/docente.html new
+35
@@ -0,0 +1,35 @@
1 +<!DOCTYPE html>
2 +<html lang="es">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>Gestion Docente | MAPS Connect</title>
7 + <script>try{if(localStorage.getItem('tema-maps')==='oscuro'){document.documentElement.classList.add('dark-mode')}}catch(e){}</script>
8 + <script src="../js/estilos.js?v=17"></script>
9 + <link rel="stylesheet" href="../css/base.css?v=8">
10 + <link rel="stylesheet" href="../css/layout.css?v=6">
11 + <link rel="stylesheet" href="../css/components.css?v=8">
12 + <link rel="stylesheet" href="../css/colores.css?v=1">
13 + <link rel="stylesheet" href="../css/docente.css?v=8">
14 + <link rel="stylesheet" href="../css/estilos.css?v=26">
15 +</head>
16 +<body>
17 + <div class="app-layout">
18 + <aside class="sidebar" id="sidebar"></aside>
19 + <div class="main-wrapper">
20 + <header class="topbar" id="topbar"></header>
21 + <main class="main-content" id="main-content">
22 +
23 + </main>
24 + </div>
25 + </div>
26 + <script src="../js/api.js?v=6"></script>
27 + <script src="../js/auth.js?v=7"></script>
28 + <script src="../js/utils.js?v=6"></script>
29 + <script src="../js/tema.js?v=2"></script>
30 + <script src="../js/colores.js?v=1"></script>
31 + <script src="../js/aviso-privacidad.js?v=1"></script>
32 + <script src="../js/layout.js?v=12"></script>
33 + <script src="../js/pages/docente.js?v=7"></script>
34 +</body>
35 +</html>
\ No newline at end of file
frontend/pages/empresarial.html
+16 -9
@@ -4,10 +4,14 @@
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Semestre Empresarial | MAPS Connect</title>
7 - <link rel="stylesheet" href="../css/base.css">
8 - <link rel="stylesheet" href="../css/layout.css">
9 - <link rel="stylesheet" href="../css/components.css">
10 - <link rel="stylesheet" href="../css/empresarial.css">
7 + <script>try{if(localStorage.getItem('tema-maps')==='oscuro'){document.documentElement.classList.add('dark-mode')}}catch(e){}</script>
8 + <script src="../js/estilos.js?v=17"></script>
9 + <link rel="stylesheet" href="../css/base.css?v=8">
10 + <link rel="stylesheet" href="../css/layout.css?v=5">
11 + <link rel="stylesheet" href="../css/components.css?v=8">
12 + <link rel="stylesheet" href="../css/colores.css?v=1">
13 + <link rel="stylesheet" href="../css/empresarial.css?v=6">
14 + <link rel="stylesheet" href="../css/estilos.css?v=26">
15 </head>
16 <body>
17 <div class="app-layout">
@@ -21,10 +25,13 @@
25 </main>
26 </div>
27 </div>
24 - <script src="../js/api.js"></script>
25 - <script src="../js/auth.js"></script>
26 - <script src="../js/utils.js"></script>
27 - <script src="../js/layout.js"></script>
28 - <script src="../js/pages/empresarial.js"></script>
28 + <script src="../js/api.js?v=5"></script>
29 + <script src="../js/auth.js?v=7"></script>
30 + <script src="../js/utils.js?v=5"></script>
31 + <script src="../js/tema.js?v=2"></script>
32 + <script src="../js/colores.js?v=1"></script>
33 + <script src="../js/aviso-privacidad.js?v=1"></script>
34 + <script src="../js/layout.js?v=12"></script>
35 + <script src="../js/pages/empresarial.js?v=5"></script>
36 </body>
37 </html>
frontend/pages/inicio.html
+107 -9
@@ -4,13 +4,108 @@
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Inicio | MAPS Connect</title>
7 - <link rel="stylesheet" href="../css/base.css">
8 - <link rel="stylesheet" href="../css/layout.css">
9 - <link rel="stylesheet" href="../css/components.css">
7 + <script>try{if(localStorage.getItem('tema-maps')==='oscuro'){document.documentElement.classList.add('dark-mode')}}catch(e){}</script>
8 + <script src="../js/estilos.js?v=17"></script>
9 + <link rel="stylesheet" href="../css/base.css?v=8">
10 + <link rel="stylesheet" href="../css/layout.css?v=5">
11 + <link rel="stylesheet" href="../css/components.css?v=8">
12 + <link rel="stylesheet" href="../css/colores.css?v=1">
13 + <link rel="stylesheet" href="../css/inicio.css?v=6">
14 + <link rel="stylesheet" href="../css/estilos.css?v=26">
15 + <style>
16 + /* --- Animación de bienvenida HUD al entrar a la cuenta --- */
17 + .welcome-overlay {
18 + position: fixed;
19 + top: 0; left: 0;
20 + width: 100vw; height: 100vh;
21 + background-color: rgba(5, 12, 8, 0.96);
22 + backdrop-filter: blur(8px);
23 + z-index: 99999;
24 + display: flex;
25 + align-items: center; justify-content: center;
26 + transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), visibility 0.5s;
27 + }
28 + .welcome-overlay.fade-out {
29 + opacity: 0;
30 + visibility: hidden;
31 + pointer-events: none;
32 + }
33 + .welcome-popup {
34 + background: #161b22;
35 + border: 2px solid #2ea043;
36 + box-shadow: 0 0 35px rgba(46, 160, 67, 0.35);
37 + padding: 36px 44px;
38 + text-align: center;
39 + max-width: 520px;
40 + width: 90%;
41 + position: relative;
42 + transform: scale(0.92);
43 + animation: welcomePopupAppear 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
44 + }
45 + .welcome-popup::after {
46 + content: '';
47 + position: absolute;
48 + top: 0; left: 0;
49 + width: 100%; height: 3px;
50 + background: linear-gradient(90deg, transparent, #3fb950, transparent);
51 + animation: welcomeScanX 1.8s ease-in-out infinite;
52 + }
53 + .welcome-popup .popup-tag {
54 + font-family: "SF Mono", Consolas, monospace;
55 + font-size: 0.72rem;
56 + color: #3fb950;
57 + letter-spacing: 2px;
58 + text-transform: uppercase;
59 + margin-bottom: 12px;
60 + display: flex;
61 + align-items: center; justify-content: center;
62 + gap: 8px;
63 + text-align: center;
64 + }
65 + .popup-tag .pulse-mini {
66 + width: 6px; height: 6px;
67 + background-color: #3fb950;
68 + box-shadow: 0 0 6px #3fb950;
69 + }
70 + .welcome-popup .popup-title {
71 + font-size: 1.8rem;
72 + font-weight: 800;
73 + letter-spacing: -0.5px;
74 + color: #ffffff;
75 + margin-bottom: 8px;
76 + text-transform: uppercase;
77 + text-align: center;
78 + }
79 + .welcome-popup .popup-username {
80 + font-family: "SF Mono", Consolas, monospace;
81 + font-size: 1.15rem;
82 + color: #3fb950;
83 + font-weight: 700;
84 + padding: 6px 12px;
85 + background: rgba(46, 160, 67, 0.12);
86 + border: 1px solid rgba(46, 160, 67, 0.3);
87 + display: inline-block;
88 + margin: 10px 0 16px 0;
89 + text-align: center;
90 + }
91 + .welcome-popup .popup-status {
92 + font-size: 0.82rem;
93 + color: #8b949e;
94 + font-family: "SF Mono", Consolas, monospace;
95 + text-align: center;
96 + }
97 + @keyframes welcomePopupAppear {
98 + 0% { transform: scale(0.85); opacity: 0; }
99 + 100% { transform: scale(1); opacity: 1; }
100 + }
101 + @keyframes welcomeScanX {
102 + 0% { transform: translateX(-100%); }
103 + 100% { transform: translateX(100%); }
104 + }
105 + </style>
106 </head>
107 <body>
108
13 -
109 <div class="app-layout">
110 <aside class="sidebar" id="sidebar"></aside>
111 <div class="main-wrapper">
@@ -20,11 +115,14 @@
115 </main>
116 </div>
117 </div>
23 - <script src="../js/api.js"></script>
24 - <script src="../js/auth.js"></script>
25 - <script src="../js/utils.js"></script>
26 - <script src="../js/layout.js"></script>
27 - <script src="../js/pages/inicio.js"></script>
118 + <script src="../js/api.js?v=5"></script>
119 + <script src="../js/auth.js?v=7"></script>
120 + <script src="../js/utils.js?v=5"></script>
121 + <script src="../js/tema.js?v=2"></script>
122 + <script src="../js/colores.js?v=1"></script>
123 + <script src="../js/aviso-privacidad.js?v=1"></script>
124 + <script src="../js/layout.js?v=12"></script>
125 + <script src="../js/pages/inicio.js?v=8"></script>
126
127
128
frontend/pages/mensajes.html
+28 -14
@@ -4,10 +4,14 @@
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Mensajes | MAPS Connect</title>
7 - <link rel="stylesheet" href="../css/base.css">
8 - <link rel="stylesheet" href="../css/layout.css">
9 - <link rel="stylesheet" href="../css/components.css">
10 - <link rel="stylesheet" href="../css/mensajes.css">
7 + <script>try{if(localStorage.getItem('tema-maps')==='oscuro'){document.documentElement.classList.add('dark-mode')}}catch(e){}</script>
8 + <script src="../js/estilos.js?v=17"></script>
9 + <link rel="stylesheet" href="../css/base.css?v=8">
10 + <link rel="stylesheet" href="../css/layout.css?v=5">
11 + <link rel="stylesheet" href="../css/components.css?v=8">
12 + <link rel="stylesheet" href="../css/colores.css?v=1">
13 + <link rel="stylesheet" href="../css/mensajes.css?v=6">
14 + <link rel="stylesheet" href="../css/estilos.css?v=26">
15 </head>
16 <body>
17 <div class="app-layout">
@@ -40,11 +44,18 @@
44 <!-- Los mensajes se renderizan aquí solo cuando el backend responde -->
45 </div>
46
43 - <footer class="chat-input-bar">
44 - <button class="btn-clean" type="button" id="btn-adjuntar" title="Adjuntar archivo o apunte">📎</button>
45 - <input type="text" id="mensaje-input" placeholder="Escribe un mensaje..." disabled>
46 - <button class="btn-solid" type="button" id="enviar-mensaje" disabled>Enviar</button>
47 - </footer>
47 +<footer class="chat-input-bar">
48 +<div class="adjunto-area">
49 +<input type="file" id="adjunto-input" hidden>
50 +<button class="btn-clean" type="button" id="btn-adjuntar" title="Adjuntar archivo">Adjuntar</button>
51 +<div id="adjunto-pendiente" class="adjunto-pendiente" hidden>
52 +<span id="adjunto-pendiente-nombre"></span>
53 +<button type="button" class="adjunto-quitar" id="adjunto-quitar" title="Quitar archivo">x</button>
54 +</div>
55 +</div>
56 +<input type="text" id="mensaje-input" placeholder="Escribe un mensaje..." disabled>
57 +<button class="btn-solid" type="button" id="enviar-mensaje" disabled>Enviar</button>
58 +</footer>
59
60 </section>
61
@@ -53,10 +64,13 @@
64 </main>
65 </div>
66 </div>
56 - <script src="../js/api.js"></script>
57 - <script src="../js/auth.js"></script>
58 - <script src="../js/utils.js"></script>
59 - <script src="../js/layout.js"></script>
60 - <script src="../js/pages/mensajes.js"></script>
67 + <script src="../js/api.js?v=5"></script>
68 + <script src="../js/auth.js?v=7"></script>
69 + <script src="../js/utils.js?v=5"></script>
70 + <script src="../js/tema.js?v=2"></script>
71 + <script src="../js/colores.js?v=1"></script>
72 + <script src="../js/aviso-privacidad.js?v=1"></script>
73 + <script src="../js/layout.js?v=12"></script>
74 + <script src="../js/pages/mensajes.js?v=5"></script>
75 </body>
76 </html>
frontend/pages/perfil.html
+30 -10
@@ -4,10 +4,14 @@
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Mi Perfil | MAPS Connect</title>
7 - <link rel="stylesheet" href="../css/base.css">
8 - <link rel="stylesheet" href="../css/layout.css">
9 - <link rel="stylesheet" href="../css/components.css">
10 - <link rel="stylesheet" href="../css/perfil.css">
7 + <script>try{if(localStorage.getItem('tema-maps')==='oscuro'){document.documentElement.classList.add('dark-mode')}}catch(e){}</script>
8 + <script src="../js/estilos.js?v=17"></script>
9 + <link rel="stylesheet" href="../css/base.css?v=8">
10 + <link rel="stylesheet" href="../css/layout.css?v=5">
11 + <link rel="stylesheet" href="../css/components.css?v=8">
12 + <link rel="stylesheet" href="../css/colores.css?v=1">
13 + <link rel="stylesheet" href="../css/perfil.css?v=6">
14 + <link rel="stylesheet" href="../css/estilos.css?v=26">
15 </head>
16 <body>
17 <div class="app-layout">
@@ -23,8 +27,11 @@
27 <div class="profile-main-info" id="profile-header-info">
28 <!-- Avatar, nombre y datos se renderizan desde el backend (o sesión) -->
29 </div>
26 - <div>
30 + <div class="profile-header-actions">
31 + <button class="btn-clean" type="button" id="btn-cambiar-foto">Cambiar Foto</button>
32 + <button class="btn-clean" type="button" id="btn-cambiar-semestre">Cambiar Semestre</button>
33 <button class="btn-clean" type="button" id="btn-editar-proposito">Editar Propósito</button>
34 + <input type="file" id="foto-input" accept="image/png,image/jpeg,image/webp" hidden>
35 </div>
36 </div>
37
@@ -62,15 +69,28 @@
69
70 </div>
71
72 + <!-- 3. MI RED: SEGUIDORES Y DESCUBRIR PERSONAS -->
73 + <section class="card" id="profile-red" hidden>
74 + <h3 class="section-heading">Mi Red</h3>
75 + <div class="red-stats" id="red-stats"></div>
76 + <h4 class="sub-heading">Descubrir personas</h4>
77 + <div class="discover-grid" id="discover-list">
78 + <p class="discover-empty">Cargando personas...</p>
79 + </div>
80 + </section>
81 +
82 </section>
83
84 </main>
85 </div>
86 </div>
70 - <script src="../js/api.js"></script>
71 - <script src="../js/auth.js"></script>
72 - <script src="../js/utils.js"></script>
73 - <script src="../js/layout.js"></script>
74 - <script src="../js/pages/perfil.js"></script>
87 + <script src="../js/api.js?v=5"></script>
88 + <script src="../js/auth.js?v=7"></script>
89 + <script src="../js/utils.js?v=5"></script>
90 + <script src="../js/tema.js?v=2"></script>
91 + <script src="../js/colores.js?v=1"></script>
92 + <script src="../js/aviso-privacidad.js?v=1"></script>
93 + <script src="../js/layout.js?v=12"></script>
94 + <script src="../js/pages/perfil.js?v=7"></script>
95 </body>
96 </html>
frontend/pages/usuario.html new
+80
@@ -0,0 +1,80 @@
1 +<!DOCTYPE html>
2 +<html lang="es">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>Perfil | MAPS Connect</title>
7 + <script>try{if(localStorage.getItem('tema-maps')==='oscuro'){document.documentElement.classList.add('dark-mode')}}catch(e){}</script>
8 + <script src="../js/estilos.js?v=17"></script>
9 + <link rel="stylesheet" href="../css/base.css?v=8">
10 + <link rel="stylesheet" href="../css/layout.css?v=5">
11 + <link rel="stylesheet" href="../css/components.css?v=8">
12 + <link rel="stylesheet" href="../css/colores.css?v=1">
13 + <link rel="stylesheet" href="../css/usuario.css?v=6">
14 + <link rel="stylesheet" href="../css/estilos.css?v=26">
15 +</head>
16 +<body>
17 + <div class="app-layout">
18 + <aside class="sidebar" id="sidebar"></aside>
19 + <div class="main-wrapper">
20 + <header class="topbar" id="topbar"></header>
21 + <main class="main-content" id="main-content">
22 +
23 + <div class="perfil-container" id="usuario-root">
24 +
25 + <!-- CABECERA DE PERFIL PÚBLICO -->
26 + <div class="card profile-header-card">
27 + <div class="profile-main-info">
28 + <div class="profile-avatar trigger" id="avatarTrigger" title="Clic para ampliar imagen"></div>
29 + <div>
30 + <h2 class="profile-name" id="usuario-nombre"></h2>
31 + <p class="profile-sub" id="usuario-sub"></p>
32 + <p class="profile-sub" id="usuario-sub2"></p>
33 + </div>
34 + </div>
35 +
36 + <!-- Acciones de interacción directa -->
37 + <div class="header-actions" id="usuario-actions"></div>
38 + </div>
39 +
40 + <!-- LAYOUT PRINCIPAL -->
41 + <div class="profile-grid">
42 +
43 + <!-- COLUMNA IZQUIERDA: RUTA / PERFIL DOCENTE -->
44 + <section class="card" id="col-izquierda"></section>
45 +
46 + <!-- COLUMNA DERECHA: REPUTACIÓN Y APORTES -->
47 + <aside class="card">
48 + <h3 class="section-heading">Reputación en la Comunidad</h3>
49 + <div class="stats-summary" id="usuario-stats"></div>
50 +
51 + <h4 class="subsection-heading">Aportaciones Recientes</h4>
52 + <div id="usuario-aportes"></div>
53 + </aside>
54 +
55 + </div>
56 +
57 + </main>
58 +
59 + </main>
60 + </div>
61 + </div>
62 +
63 + <!-- Modal para visualización ampliada del avatar -->
64 + <div class="avatar-modal-backdrop" id="avatarModal">
65 + <div class="avatar-modal-content">
66 + <button class="btn-close-modal" id="closeModalBtn" type="button" aria-label="Cerrar">Cerrar X</button>
67 + <div class="avatar-expanded-box" id="avatarExpanded"></div>
68 + </div>
69 + </div>
70 +
71 + <script src="../js/api.js?v=5"></script>
72 + <script src="../js/auth.js?v=7"></script>
73 + <script src="../js/utils.js?v=5"></script>
74 + <script src="../js/tema.js?v=2"></script>
75 + <script src="../js/colores.js?v=1"></script>
76 + <script src="../js/aviso-privacidad.js?v=1"></script>
77 + <script src="../js/layout.js?v=12"></script>
78 + <script src="../js/pages/usuario.js?v=5"></script>
79 +</body>
80 +</html>
\ No newline at end of file
frontend/registro.html
+66 -10
@@ -4,9 +4,37 @@
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Registro | MAPS Connect</title>
7 - <link rel="stylesheet" href="css/base.css">
8 - <link rel="stylesheet" href="css/layout.css">
9 - <link rel="stylesheet" href="css/components.css">
7 + <script>try{if(localStorage.getItem('tema-maps')==='oscuro'){document.documentElement.classList.add('dark-mode')}}catch(e){}</script>
8 + <script src="js/estilos.js?v=17"></script>
9 + <link rel="stylesheet" href="css/base.css?v=8">
10 + <link rel="stylesheet" href="css/layout.css?v=5">
11 + <link rel="stylesheet" href="css/components.css?v=8">
12 + <link rel="stylesheet" href="css/colores.css?v=1">
13 + <link rel="stylesheet" href="css/estilos.css?v=26">
14 + <style>
15 + .rol-selector {
16 + display: grid;
17 + grid-template-columns: 1fr 1fr;
18 + gap: 0.5rem;
19 + }
20 + .rol-selector label {
21 + display: block;
22 + padding: 0.75rem 1rem;
23 + border: 1px solid var(--gray-300);
24 + border-radius: var(--border-radius);
25 + cursor: pointer;
26 + text-align: center;
27 + font-size: 0.95rem;
28 + margin-bottom: 0;
29 + }
30 + .rol-selector label input {
31 + margin-right: 0.4rem;
32 + }
33 + .rol-selector label.rol-activo {
34 + border-color: var(--primary-color);
35 + background: #eff6ff;
36 + }
37 + </style>
38 </head>
39 <body class="register-page">
40 <div class="auth-layout">
@@ -15,11 +43,25 @@
43 <div id="alert-container"></div>
44 <form id="registro-form">
45 <div class="form-group">
18 - <label for="nombre">Nombre</label>
46 + <label>Tipo de cuenta</label>
47 + <div class="rol-selector" id="rol-selector">
48 + <label id="rol-card-estudiante" class="rol-activo">
49 + <input type="radio" name="rol" value="estudiante" checked>
50 + Estudiante
51 + </label>
52 + </div>
53 + </div>
54 + <div class="form-group" id="group-identificador">
55 + <label for="matricula" id="label-identificador">Matrícula</label>
56 + <input type="text" id="matricula" class="form-control" required placeholder="A12345678">
57 + <div class="form-hint" id="hint-identificador">Identificador único institucional</div>
58 + </div>
59 + <div class="form-group">
60 + <label for="nombre">Nombre(s)</label>
61 <input type="text" id="nombre" class="form-control" required>
62 </div>
63 <div class="form-group">
22 - <label for="apellido">Apellido</label>
64 + <label for="apellido">Apellido(s)</label>
65 <input type="text" id="apellido" class="form-control" required>
66 </div>
67 <div class="form-group">
@@ -36,6 +78,17 @@
78 <label for="confirmar-contrasena">Confirmar contraseña</label>
79 <input type="password" id="confirmar-contrasena" class="form-control" required minlength="6">
80 </div>
81 + <div class="form-group">
82 + <div class="terminos-checkbox">
83 + <input type="checkbox" id="acepta-terminos" required>
84 + <label for="acepta-terminos">
85 + He leído y acepto el
86 + <a id="link-aviso-registro" role="button">Aviso de Privacidad</a>
87 + y los
88 + <a id="link-terminos-registro" role="button">Términos y Condiciones</a>.
89 + </label>
90 + </div>
91 + </div>
92 <button type="submit" class="btn btn-primary" style="width:100%">Crear cuenta</button>
93 </form>
94 <p style="margin-top:1rem;text-align:center">
@@ -43,9 +96,12 @@
96 </p>
97 </div>
98 </div>
46 -<script src="js/api.js"></script>
47 -<script src="js/auth.js"></script>
48 -<script src="js/utils.js"></script>
49 -<script src="js/pages/registro.js"></script>
99 +<script src="js/api.js?v=5"></script>
100 +<script src="js/auth.js?v=7"></script>
101 +<script src="js/utils.js?v=5"></script>
102 +<script src="js/tema.js?v=2"></script>
103 +<script src="js/colores.js?v=1"></script>
104 +<script src="js/aviso-privacidad.js?v=1"></script>
105 +<script src="js/pages/registro.js?v=6"></script>
106 </body>
51 -</html>
107 +</html>
\ No newline at end of file