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">×</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 => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[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 => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[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, '&')
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
+ • Nomina: <strong>${Utils.esc(perfil.nomina || '—')}</strong>
43
+ • ${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 ? ` • 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 || '')} — ${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 => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[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">×</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, '>')
384
.replace(/"/g, '"')
385
.replace(/'/g, ''');
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
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": '''
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, '>')
435
.replace(/"/g, '"')
436
.replace(/'/g, ''');
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 • <strong>${Utils.esc(d.especialidad)}</strong>`
60
+ : 'Docente';
61
+ sub2.innerHTML = `Reputación: <strong>${this.neto(d.reputacion)}</strong>`;
62
+ } else {
63
+ sub.innerHTML = d.carrera
64
+ ? `Estudiante • <strong>${Utils.esc(d.carrera)}</strong>`
65
+ : 'Estudiante';
66
+ sub2.innerHTML = (d.semestre
67
+ ? `Semestre Vigente: <strong>${this.neto(d.semestre)}° Semestre</strong> • `
68
+ : '')
69
+ + `Reputació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í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ó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é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ías:</strong> ${Utils.esc(d.horarioAsesorias)}</li>` : ''}
163
+ <li><strong>Disponible en chat:</strong> <span class="${d.disponibleChat ? 'ok' : 'off'}">${d.disponibleChat ? 'Sí' : '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, '&')
5
+ .replace(/</g, '<')
6
+ .replace(/>/g, '>')
7
+ .replace(/"/g, '"')
8
+ .replace(/'/g, ''');
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