main
js 120 lines 4.64 KB
Raw
1 const Utils = {
2 esc(str) {
3 return String(str == null ? '' : str)
4 .replace(/&/g, '&')
5 .replace(/</g, '&lt;')
6 .replace(/>/g, '&gt;')
7 .replace(/"/g, '&quot;')
8 .replace(/'/g, '&#39;');
9 },
10
11 iniciales(nombre) {
12 if (!nombre) return '?';
13 const parts = String(nombre).trim().split(/\s+/);
14 const first = parts[0] ? parts[0][0] : '';
15 const second = parts[1] ? parts[1][0] : '';
16 return (first + second).toUpperCase() || '?';
17 },
18
19 /** Avatar con foto si existe; si no, iniciales. clase = clases CSS del avatar. */
20 avatarHtml(nombre, foto, clase = 'avatar', alt = '') {
21 const safe = this.esc(alt || nombre || 'usuario');
22 if (foto) {
23 return `<img class="${this.esc(clase)} avatar-foto" src="${foto}" alt="${safe}">`;
24 }
25 return `<span class="${this.esc(clase)}" aria-hidden="true">${this.esc(this.iniciales(nombre))}</span>`;
26 },
27
28 /** Convierte una imagen seleccionada a data URL comprimida (para foto de perfil). */
29 fotoDataUrl(archivo, max = 512, calidad = 0.85) {
30 return new Promise((resolve, reject) => {
31 if (!archivo) return reject(new Error('Selecciona una imagen.'));
32 if (!/^image\/(png|jpe?g|webp)$/i.test(archivo.type)) {
33 return reject(new Error('Formato no soportado. Usa PNG o JPG.'));
34 }
35 const reader = new FileReader();
36 reader.onerror = () => reject(new Error('No se pudo leer la imagen.'));
37 reader.onload = () => {
38 const img = new Image();
39 img.onerror = () => reject(new Error('La imagen no es válida.'));
40 img.onload = () => {
41 const escala = Math.min(1, max / Math.max(img.width, img.height));
42 const w = Math.max(1, Math.round(img.width * escala));
43 const h = Math.max(1, Math.round(img.height * escala));
44 const canvas = document.createElement('canvas');
45 canvas.width = w;
46 canvas.height = h;
47 const ctx = canvas.getContext('2d');
48 ctx.drawImage(img, 0, 0, w, h);
49 resolve(canvas.toDataURL('image/jpeg', calidad));
50 };
51 img.src = reader.result;
52 };
53 reader.readAsDataURL(archivo);
54 });
55 },
56
57 /** Cambia el estado visual del botón Seguir/Siguiendo según dataset.siguiendo. */
58 pintarBotonSeguir(btn, siguiendo) {
59 if (!btn) return;
60 btn.dataset.siguiendo = String(siguiendo);
61 btn.textContent = siguiendo ? 'Siguiendo' : 'Seguir';
62 btn.classList.toggle('siguiendo', siguiendo);
63 },
64
65 /** Alterna el seguimiento: llama POST/DELETE /usuarios/{id}/seguir. */
66 async alternarSeguir(btn, idUsuario) {
67 if (!btn || !idUsuario) return;
68 const siguiendo = btn.dataset.siguiendo === 'true';
69 const siguiente = !siguiendo;
70 btn.disabled = true;
71 try {
72 await API.request(`/usuarios/${idUsuario}/seguir`, {
73 method: siguiente ? 'POST' : 'DELETE'
74 });
75 this.pintarBotonSeguir(btn, siguiente);
76 return siguiente;
77 } catch (e) {
78 this.toast(e.message || 'No se pudo actualizar el seguimiento.', 'error');
79 this.pintarBotonSeguir(btn, siguiendo);
80 return siguiendo;
81 } finally {
82 btn.disabled = false;
83 }
84 },
85
86 showAlert(containerId, message, type = 'error') {
87 const container = document.getElementById(containerId);
88 if (!container) return;
89 container.innerHTML = `<div class="alert alert-${type}">${message}</div>`;
90 },
91
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 };