1// --- CONFIGURACIÃN SUPABASE --- 2const SUPABASE_URL = "https://fymhvyofobicqtzmknqj.supabase.co"; 3const SUPABASE_KEY = 4 "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZ5bWh2eW9mb2JpY3F0em1rbnFqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjM3NDQ3NjUsImV4cCI6MjA3OTMyMDc2NX0.VLwi69kX7lLTWdbsF2yFJRUrb3iamuWLT77aUOyRDl0"; 5const supabaseClient = window.supabase.createClient(SUPABASE_URL, SUPABASE_KEY); 6 7// --- CONSTANTES --- 8const DEV_EMAIL = "[email protected]"; // <--- TU CORREO DE DESARROLLADOR 9 10const COLS = [ 11 "#E57373", "#F06292", "#BA68C8", "#9575CD", "#64B5F6", "#4DB6AC", "#81C784", 12 "#FFD54F", "#FF8A65", "#f2f6f0", "#D32F2F", "#C2185B", "#7B1FA2", "#000000", 13 "#1976D2", "#388E3C", "#F57C00", 14]; 15const AVATARS = [ 16 "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Brian", 17 "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Nolan", 18 "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Wyatt", 19 "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Vivian", 20 "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Oliver", 21 "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Andrea", 22 "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Adrian", 23 "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Emery", 24 "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Jack", 25 "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Sara", 26]; 27 28// --- FORMATEADOR DE FECHAS INTELIGENTE (UX PREMIUM) --- 29function formatearFechaLatam(fechaDB) { 30 if (!fechaDB) return ''; 31 32 const partes = fechaDB.split('-'); 33 if (partes.length !== 3) return fechaDB; 34 35 const año = parseInt(partes[0]); 36 const mes = parseInt(partes[1]) - 1; // JS cuenta los meses del 0 al 11 37 const dia = parseInt(partes[2]); 38 39 const fechaTarea = new Date(año, mes, dia); 40 41 // Obtenemos la fecha de hoy, eliminando las horas para comparar exacto 42 const hoy = new Date(); 43 hoy.setHours(0, 0, 0, 0); 44 45 const mañana = new Date(hoy); 46 mañana.setDate(mañana.getDate() + 1); 47 48 const ayer = new Date(hoy); 49 ayer.setDate(ayer.getDate() - 1); 50 51 // Nombres cortos para máxima elegancia visual 52 const dias = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb']; 53 const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']; 54 55 const nombreDia = dias[fechaTarea.getDay()]; 56 const nombreMes = meses[fechaTarea.getMonth()]; 57 58 // Comparamos usando el "tiempo" matemático 59 const tTarea = fechaTarea.getTime(); 60 61 if (tTarea === hoy.getTime()) { 62 return "Hoy"; 63 } else if (tTarea === mañana.getTime()) { 64 return "Mañana"; 65 } else if (tTarea === ayer.getTime()) { 66 return "Ayer"; 67 } else { 68 // Si es de este mismo año, no mostramos el año (Ej: "Lun, 12 Mar") 69 if (año === hoy.getFullYear()) { 70 return `${nombreDia}, ${dia} ${nombreMes}`; 71 } else { 72 // Si es de otro año, sà lo mostramos (Ej: "Lun, 12 Mar 2027") 73 return `${nombreDia}, ${dia} ${nombreMes} ${año}`; 74 } 75 } 76} 77 78// Función para abrir y cerrar el Panel Lateral (Sirve para el botón de las rayitas y la flecha) 79function toggleSidePanel() { 80 const panel = document.getElementById('sidePanel'); 81 const overlay = document.getElementById('panelOverlay'); 82 83 // Toggle para el Celular 84 panel.classList.toggle('active'); 85 if (overlay) overlay.classList.toggle('active'); 86 87 // ð¥ MAGIA PARA LA PC: Añade una clase al body para empujar las materias 88 document.body.classList.toggle('panel-closed'); 89} 90 91// ð¥ NUEVO: Función inteligente que evalúa si estás en PC o Celular ð¥ 92function closePanelMobile() { 93 // Si la pantalla es menor a 1024px (Celular o Tablet pequeña), cierra el panel. 94 // Si es más grande (PC), no hace absolutamente nada. 95 if (window.innerWidth < 1024) { 96 toggleSidePanel(); 97 } 98} 99 100const app = { 101 enableCafe: false, 102 user: null, 103 data: [], 104 sCol: COLS[0], 105 pend: false, 106 cropper: null, 107 drag: null, 108 sAv: AVATARS[0], 109 delId: null, 110 111 // ð¥ AQUà EMPIEZA LO NUEVO ð¥ 112 filterView: 'all', 113 sortView: 'date', 114 115 setFilter: (type) => { 116 app.filterView = type; 117 app.pend = (type === 'pending'); 118 119 document.getElementById('chip-all').classList.remove('active'); 120 document.getElementById('chip-pending').classList.remove('active'); 121 document.getElementById('chip-' + type).classList.add('active'); 122 }, 123 124 setSort: (type) => { 125 app.sortView = type; 126 127 document.getElementById('chip-sort-date').classList.remove('active'); 128 document.getElementById('chip-sort-alpha').classList.remove('active'); 129 document.getElementById('chip-sort-new').classList.remove('active'); 130 document.getElementById('chip-sort-' + type).classList.add('active'); 131 }, 132 // ð¥ AQUà TERMINA LO NUEVO ð¥ 133 134 // ========================================= 135 // ð SISTEMA DE VISTAS (MATERIAS VS CRONOLÃGICO) 136 // ========================================= 137 viewMode: 'subjects', 138 139 // Esta función ahora recibe el input del switch (onchange="app.toggleViewMode(this)") 140 toggleViewMode: () => { 141 const container = document.querySelector(".fluye-switch"); 142 const optSubjects = document.getElementById("optSubjects"); 143 const optChrono = document.getElementById("optChrono"); 144 145 const viewMaterias = document.getElementById("mainList"); 146 const viewCronologico = document.getElementById("chronoContainer"); 147 const searchContainer = document.querySelector(".search-container"); 148 149 if (app.viewMode === 'subjects') { 150 app.viewMode = 'chrono'; 151 container.classList.add("is-chrono"); 152 153 // UI Updates 154 optSubjects.classList.remove("active"); 155 optChrono.classList.add("active"); 156 157 if(searchContainer) searchContainer.classList.add("hidden"); 158 viewMaterias.classList.add("hidden"); 159 viewCronologico.classList.remove("hidden"); 160 161 app.renderChronoView(); 162 } else { 163 app.viewMode = 'subjects'; 164 container.classList.remove("is-chrono"); 165 166 // UI Updates 167 optSubjects.classList.add("active"); 168 optChrono.classList.remove("active"); 169 170 viewCronologico.classList.add("hidden"); 171 viewMaterias.classList.remove("hidden"); 172 if(searchContainer) searchContainer.classList.remove("hidden"); 173 } 174}, 175 176 renderChronoView: () => { 177 const container = document.getElementById("chronoContainer"); 178 container.innerHTML = ""; 179 180 let todasLasTareas = []; 181
182 // 1. Extraer todas las tareas pendientes 183 app.data.forEach(materia => { 184 const tareasSeguras = materia.tasks || []; 185 tareasSeguras.forEach(tarea => { 186 if (!tarea.is_done && !tarea.is_hidden) { 187 todasLasTareas.push({ 188 ...tarea, 189 materiaNombre: materia.name, 190 materiaColor: materia.color 191 }); 192 } 193 }); 194 }); 195 196 // 2. Ordenar por fecha 197 todasLasTareas.sort((a, b) => { 198 if (!a.due_date) return 1; 199 if (!b.due_date) return -1; 200 return new Date(a.due_date) - new Date(b.due_date); 201 }); 202 203 // 3. Estado vacÃo 204 if (todasLasTareas.length === 0) { 205 container.innerHTML = ` 206 <div class="empty-state"> 207 <i class="ph-duotone ph-coffee" style="font-size: 3rem; color: var(--accent); opacity: 0.5; margin-bottom: 15px;"></i> 208 <p>¡Todo al dÃa! No tienes entregas próximas.</p> 209 </div>`; 210 return; 211 } 212 213 // 4. PINTAR CADA TAREA (Diseño de Cápsula Uniforme) 214 let htmlTareas = ""; 215 todasLasTareas.forEach(tarea => { 216 217 // Lógica de colores (solo fondo sutil, sin lÃnea) 218 const todayStr = new Date().toISOString().split('T')[0]; 219 let row = ""; 220 if (tarea.due_date) { 221 if (tarea.due_date < todayStr) row = "state-overdue"; 222 else if (tarea.due_date === todayStr) row = "state-warning"; 223 } 224 225 // ð¥ Borde izquierdo forzado a ser normal para quitar la lÃnea de color gruesa ð¥ 226 htmlTareas += ` 227 <div class="subject-card ${row}" style="padding: 16px; margin-bottom: 15px; display: flex; flex-direction: column; border-left: 1px solid var(--border) !important;"> 228 229 ${tarea.due_date ? ` 230 <div style="margin-bottom: 12px;"> 231 <span class="task-date" style="margin: 0;"><i class="ph-light ph-calendar-blank"></i> ${formatearFechaLatam(tarea.due_date)}</span> 232 </div>` : ""} 233 234 <div class="task-title" style="font-size: 1.05rem; margin-bottom: 4px;"> 235 ${tarea.title} 236 </div> 237 238 ${tarea.description ? `<div class="task-desc" style="margin-bottom: 12px;">${tarea.description}</div>` : `<div style="margin-bottom: 12px;"></div>`} 239 240 <div style="margin-top: auto; padding-top: 10px; border-top: 1px dashed var(--border); display: flex; align-items: center; gap: 8px; font-size: 0.8rem; font-weight: 600; opacity: 0.75; overflow: hidden;"> 241 <div style="width: 8px; height: 8px; border-radius: 50%; background: ${tarea.materiaColor}; flex-shrink: 0;"></div> 242 <span style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;">${tarea.materiaNombre}</span> 243 </div> 244 245 </div> 246 `; 247 }); 248 249 container.innerHTML = htmlTareas; 250 }, 251 252// ========================================= 253 // ð LÃGICA DE NOTAS RÃPIDAS (SUPABASE + LOCAL) ð 254 // ========================================= 255 notes: { 256 timeout: null, 257 258 open: async () => { 259 const cajaTexto = document.getElementById("quickNotesText"); 260 261 // 1. Mostrar lo que hay en el teléfono al instante 262 const notaLocal = localStorage.getItem(`fluye_notes_${app.user ? app.user.id : 'guest'}`); 263 if (notaLocal !== null) { 264 cajaTexto.value = notaLocal; 265 } 266 267 app.openModal('notesModal'); 268 history.pushState({ modal: true, notes: true }, null, ""); 269 270 // 2. Sincronizar de fondo con Supabase 271 if (app.user) { 272 try { 273 // ð¥ AQUà ESTà LA CORRECCIÃN: supabaseClient ð¥ 274 const { data, error } = await supabaseClient 275 .from('notas') 276 .select('contenido') 277 .eq('user_id', app.user.id) 278 .single(); 279 280 if (!error && data && data.contenido !== null) { 281 cajaTexto.value = data.contenido; 282 localStorage.setItem(`fluye_notes_${app.user.id}`, data.contenido); 283 } 284 } catch (err) { 285 console.error("Error cargando notas de Supabase:", err); 286 } 287 } 288 }, 289 290 save: () => { 291 const cajaTexto = document.getElementById("quickNotesText"); 292 const texto = cajaTexto.value; 293 294 // Guardado local instantáneo 295 localStorage.setItem(`fluye_notes_${app.user ? app.user.id : 'guest'}`, texto); 296 297 // Guardado en la nube con "Debounce" 298 if (app.user) { 299 clearTimeout(app.notes.timeout); 300 301 app.notes.timeout = setTimeout(async () => { 302 try { 303 // ð¥ AQUà ESTà LA CORRECCIÃN: supabaseClient ð¥ 304 const { error } = await supabaseClient 305 .from('notas') 306 .upsert({ 307 user_id: app.user.id, 308 contenido: texto, 309 actualizado_en: new Date().toISOString() 310 }); 311 312 if (error) { 313 console.error("Error de Supabase:", error.message); 314 return; 315 } 316 console.log("âï¸ Notas sincronizadas con Supabase exitosamente"); 317 } catch (err) { 318 console.error("Error en la conexión:", err); 319 } 320 }, 1000); 321 } 322 }, 323 324 clear: () => { 325 const cajaTexto = document.getElementById("quickNotesText"); 326 cajaTexto.value = ""; 327 app.notes.save(); 328 app.showToast("Notas limpias ð§¹"); 329 }, 330 331 close: () => { 332 app.closeModal('notesModal'); 333 } 334 }, 335 336 init: async () => { 337 // ... el resto de tu código sigue igual 338 339 // Verificación segura para que NO falle en PC 340 if (typeof Capacitor !== 'undefined' && Capacitor.Plugins && Capacitor.Plugins.GoogleAuth) { 341 Capacitor.Plugins.GoogleAuth.initialize({ 342 clientId: '850228421844-k0nku60p0trao4pgoln7is0csf5safa1.apps.googleusercontent.com', 343 scopes: ['profile', 'email'], 344 grantOfflineAccess: true, 345 }); 346 } 347 348 // Iniciar el sistema de notificaciones 349 app.initPush();
350 351 // Estilos del Toast 352 const style = document.createElement("style"); 353 style.innerHTML = ` 354 .toast-container { position: fixed; bottom: 90px; left: 50%; transform: translateX(-50%); z-index: 3000; display: flex; flex-direction: column; gap: 10px; pointer-events: none; width: max-content; } 355 .toast { background: rgba(31, 41, 55, 0.95); color: white; padding: 12px 24px; border-radius: 50px; font-size: 0.95rem; font-weight: 500; opacity: 0; transform: translateY(20px); transition: all 0.3s; box-shadow: 0 10px 25px rgba(0,0,0,0.2); display: flex; align-items: center; gap: 10px; backdrop-filter: blur(6px); border: 1px solid rgba(255,255,255,0.1); } 356 .toast.show { opacity: 1; transform: translateY(0); } 357 .toast.error { background: rgba(220, 38, 38, 0.9); } 358 .toast.success { background: rgba(5, 150, 105, 0.9); } 359 .toast i { font-size: 1.2rem; } 360 `; 361 document.head.appendChild(style); 362 363 364 // --- CARGA DE TEMA INTELIGENTE (Incluye Navidad) --- 365 const savedTheme = localStorage.getItem("theme"); 366 if (savedTheme === "dark") { 367 document.body.classList.add("dark"); 368 } 369 app.updateLogos(); // <--- ESTO ASEGURA EL LOGO CORRECTO AL INICIAR 370 371 // --- NUEVO: CARGAR FRASE DEL DÃA --- 372 app.loadDailyQuote(); 373 374 // --- GESTIÃN BOTÃN ATRÃS (NUEVO) --- 375 window.addEventListener('popstate', (event) => { 376 // Busca si hay alguna ventana abierta 377 const activeModal = document.querySelector('.modal-wrap.active'); 378 if (activeModal) { 379 // Si hay una, la cierra visualmente 380 activeModal.classList.remove('active'); 381 } 382 }); 383 384 // --- MANEJO DEL BOTÃN ATRÃS (ANDROID) --- 385 if (typeof Capacitor !== 'undefined') { 386 const App = Capacitor.Plugins.App; 387 388 App.addListener('backButton', ({ canGoBack }) => { 389 // 1. Buscamos si hay alguna ventana abierta (active) 390 const openModal = document.querySelector('.modal-wrap.active'); 391 392 // 2. Buscamos si el menú lateral de notificaciones está abierto 393 const notifMenu = document.querySelector('.notif-menu:not(.hidden)'); 394 395 if (openModal) { 396 // Si hay modal, lo cerramos 397 openModal.classList.remove('active'); 398 } else if (notifMenu) { 399 // Si el menú de notificaciones está abierto, lo cerramos 400 app.toggleNotifMenu(); 401 } else { 402 // Si no hay nada abierto, salimos de la app 403 App.exitApp(); 404 } 405 }); 406 } 407 408 // Cargar preferencia de notificaciones 409 const savedNotif = localStorage.getItem("fluye_notif_days"); 410 if (savedNotif) { 411 app.notifDays = parseInt(savedNotif); 412 // Esperamos un poco a que el DOM esté listo para actualizar el icono 413 setTimeout(() => app.updateBellIcon(), 100); 414 } 415 416 // Listeners de inputs 417 document 418 .getElementById("r_pass") 419 .addEventListener("input", app.checkStr); 420 ["l_email", "l_pass"].forEach((id) => { 421 const el = document.getElementById(id); 422 if (el) 423 el.addEventListener("keyup", (e) => { 424 if (e.key === "Enter") app.login(); 425 }); 426 }); 427 428 const enterInputs = [ 429 { id: "subName", exec: () => app.saveSub() }, // Nueva Materia 430 { id: "ttitle", exec: () => app.saveT() }, // Tarea: TÃtulo 431 { id: "tdate", exec: () => app.saveT() }, // Tarea: Fecha 432 { id: "p_user", exec: () => app.saveProfile() }, // Perfil: Nombre 433 { id: "cafeRef", exec: () => app.sendDonation() },// Café: Referencia 434 { id: "cafeMsg", exec: () => app.sendDonation() } // Café: Mensaje 435 ]; 436 437 enterInputs.forEach(item => { 438 const el = document.getElementById(item.id); 439 if (el) { 440 el.addEventListener("keydown", (e) => { 441 if (e.key === "Enter") { 442 e.preventDefault(); // Evita comportamientos raros 443 item.exec(); // Ejecuta la acción (Guardar) 444 } 445 }); 446 } 447 });
448 449 // --- AUTH STATE LISTENER --- 450 const { 451 data: { session }, 452 } = await supabaseClient.auth.getSession(); 453 454 if (session) { 455 app.user = { 456 id: session.user.id, 457 email: session.user.email, 458 ...session.user.user_metadata, 459 }; 460 461 // El usuario existe, cargamos la app silenciosamente detrás de la cortina 462 app.showApp(false); 463 } else { 464 465 // No hay sesión, mostramos el Login 466 document.getElementById("auth-view").classList.remove("hidden"); 467 468 // Solo bajamos nuestra cortina web 469 const fluyeLoader = document.getElementById("fluye-loader"); 470 if (fluyeLoader) fluyeLoader.classList.add("oculto"); 471 } 472 473 // Configuración Drag & Drop Móvil 474 const list = document.getElementById("mainList"); 475 list.addEventListener("dragover", (e) => { 476 e.preventDefault(); 477 const draggingItem = app.drag; 478 if (!draggingItem) return; 479 const siblings = [ 480 ...list.querySelectorAll(".subject-card:not(.dragging)"), 481 ]; 482 const nextSibling = siblings.reduce( 483 (closest, child) => { 484 const box = child.getBoundingClientRect(); 485 const offset = e.clientY - box.top - box.height / 2; 486 if (offset < 0 && offset > closest.offset) { 487 return { offset: offset, element: child }; 488 } else { 489 return closest; 490 } 491 }, 492 { offset: Number.NEGATIVE_INFINITY } 493 ).element; 494 if (nextSibling) list.insertBefore(draggingItem, nextSibling); 495 else list.appendChild(draggingItem); 496 }); 497 list.addEventListener("dragenter", (e) => e.preventDefault()); 498 499 // LÃGICA DEL INTERRUPTOR DE CAFà 500 if (!app.enableCafe) { 501 const btn = document.getElementById("btnCafe"); 502 if (btn) btn.classList.add("hidden"); 503 } 504 }, 505 506 // --- INICIO SESIÃN GOOGLE (CORREGIDO Y MEJORADO) --- 507 googleLogin: async () => { 508 // --- ð¡ï¸ PROTECCIÃN PARA PC --- 509 if (typeof Capacitor === 'undefined') { 510 alert("ð» MODO PC: El botón de Google no funciona aquà porque falta el plugin web.\n\nð² ¡PRUÃBALO EN EL CELULAR! Allà sà funcionará."); 511 return; 512 } 513 // ----------------------------- 514 515 try { 516 const GoogleAuth = Capacitor.Plugins.GoogleAuth; 517 const googleUser = await GoogleAuth.signIn(); 518 const idToken = googleUser.authentication.idToken; 519 520 // 1. Login en Supabase 521 const { data, error } = await supabaseClient.auth.signInWithIdToken({ 522 provider: 'google', 523 token: idToken, 524 }); 525 526 if (error) throw error; 527 528 const user = data.session.user; 529 const meta = user.user_metadata; 530 531 // 2. OBTENER DATOS DE GOOGLE 532 // Google a veces da la foto en 'avatar_url' o 'picture'. 533 // A veces la URL de Google viene con parámetros raros, tomamos la pura. 534 let googleAvatar = meta.avatar_url || meta.picture || app.sAv; 535 const googleName = meta.full_name || meta.name || "Usuario Google"; 536 537 // 3. ð§ VERIFICAR SI YA EXISTE EN BASE DE DATOS (Para no borrar tu foto personalizada) 538 const { data: existingProfile } = await supabaseClient 539 .from('profiles') 540 .select('name, avatar') 541 .eq('id', user.id) 542 .single(); 543 544 // DECISIÃN INTELIGENTE: 545 // Si ya existe perfil y tiene avatar, USAMOS ESE. Si no, usamos el de Google. 546 let finalAvatar = googleAvatar; 547 let finalName = googleName; 548 549 if (existingProfile) { 550 if (existingProfile.avatar) finalAvatar = existingProfile.avatar; // Mantenemos tu foto 551 if (existingProfile.name) finalName = existingProfile.name; // Mantenemos tu nombre editado 552 } 553 554 // 4. GUARDAR EN MEMORIA APP 555 app.user = { 556 id: user.id, 557 email: user.email, 558 name: finalName, 559 avatar: finalAvatar, 560 ...meta 561 }; 562 563 // 5. ACTUALIZAR BASE DE DATOS (Sin dañar lo existente) 564 await supabaseClient.from('profiles').upsert({
565 id: app.user.id, 566 name: finalName, 567 avatar: finalAvatar, 568 // last_seen se actualiza en showApp 569 }); 570 571 // 6. Entrar 572 app.showToast("¡Bienvenido " + finalName + "!"); 573 app.showApp(true); 574 575 } catch (e) { 576 console.error(e); 577 app.showToast("Error: " + (e.message || "No se pudo iniciar"), "error"); 578 } 579 }, 580 581 // --- NOTIFICACIONES PUSH (VERSIÃN FINAL) --- 582 initPush: async () => { 583 // 1. Protección para PC 584 if (typeof Capacitor === 'undefined' || !Capacitor.Plugins.PushNotifications) { 585 return; 586 } 587 const PushNotifications = Capacitor.Plugins.PushNotifications; 588 589 // 2. Pedir permiso 590 let permStatus = await PushNotifications.checkPermissions(); 591 if (permStatus.receive === 'prompt') { 592 permStatus = await PushNotifications.requestPermissions(); 593 } 594 if (permStatus.receive !== 'granted') { 595 return; // Si dice que no, no insistimos 596 } 597 598 // 3. Registrar 599 await PushNotifications.register(); 600 601 // 4. ESCUCHAR ÃXITO (Silencioso) 602 PushNotifications.addListener('registration', (token) => { 603 console.log('ð Token obtenido'); 604 // Lo guardamos en Supabase sin molestar al usuario 605 app.savePushToken(token.value); 606 }); 607 608 // 5. ESCUCHAR ERRORES (Solo consola) 609 PushNotifications.addListener('registrationError', (error) => { 610 console.error("Error FCM:", error); 611 }); 612 613 // 6. Al recibir notificación (App abierta) 614 PushNotifications.addListener('pushNotificationReceived', (notification) => { 615 /* app.showToast(notification.title || "Tienes tareas pendientes ð"); */ 616 }); 617 }, 618 619 // Función auxiliar para guardar el token en Supabase (Opcional pero recomendada) 620 savePushToken: async (token) => { 621 if(!app.user) return; 622 623 // Guardamos el token en la tabla de perfiles (asegúrate de tener una columna 'fcm_token' o similar) 624 // O crea una tabla 'devices' si el usuario tiene varios celulares. 625 const { error } = await supabaseClient 626 .from('profiles') 627 .update({ fcm_token: token }) 628 .eq('id', app.user.id); 629 630 if(error) console.error("Error guardando token:", error); 631 }, 632 633 // --- NOTIFICACIONES --- 634 notifDays: 0, // 0 = Desactivado por defecto 635 636 toggleNotifMenu: () => { 637 const menu = document.getElementById("notifMenu"); 638 menu.classList.toggle("hidden"); 639 // Marcar la opción actual visualmente 640 [0,1,2,3].forEach(n => { 641 const btn = document.getElementById(`nOpt${n}`); 642 if(btn) btn.classList.toggle("selected", app.notifDays == n); 643 }); 644 }, 645 646 updateBellIcon: () => { 647 const icon = document.getElementById("bellIcon"); 648 const btn = document.querySelector(".btn-bell-circle"); 649 650 // Validamos que existan para no dar error 651 if (!icon || !btn) return; 652 653 if (app.notifDays > 0) { 654 icon.className = "ph-fill ph-bell-ringing"; // Campana sonando 655 btn.classList.add("active"); 656 } else { 657 icon.className = "ph-bold ph-bell-slash"; // Campana tachada 658 btn.classList.remove("active"); 659 } 660 }, 661 662 setNotif: async (days) => { 663 // 1. Actualización Visual (UI) 664 app.notifDays = days; 665 localStorage.setItem("fluye_notif_days", days); 666 app.updateBellIcon(); 667 app.toggleNotifMenu(); 668 669 // 2. Guardar preferencia en Supabase 670 if (app.user) { 671 await supabaseClient 672 .from('profiles') 673 .update({ notif_days: days }) 674 .eq('id', app.user.id); 675 } 676 677 // 3. LÃGICA DE PERMISOS (NATIVA) ð¡ï¸ 678 // Si el usuario ACTIVà las notificaciones (dÃas > 0), verificamos permisos. 679 if (days > 0 && typeof Capacitor !== 'undefined') { 680 const PushNotifications = Capacitor.Plugins.PushNotifications; 681 682 // Revisamos el estado actual 683 let permStatus = await PushNotifications.checkPermissions(); 684 685 // Si no tenemos permiso, lo pedimos AHORA MISMO 686 if (permStatus.receive !== 'granted') { 687 permStatus = await PushNotifications.requestPermissions(); 688 } 689 690 // Si después de pedirlo sigue negado (el usuario dijo "No permitir" o "Nunca"), 691 // le avisamos que tiene que ir a Ajustes. 692 if (permStatus.receive !== 'granted') { 693 alert("â ï¸ Las notificaciones están bloqueadas.\n\nPara que te avisemos, debes activarlas manualmente en: Ajustes > Aplicaciones > Fluye."); 694 } else { 695 // Si nos dio permiso, nos aseguramos de registrar el token por si acaso 696 app.initPush();
697 app.showToast(`Activado (${days} dÃas antes) ð`); 698 } 699 } else if (days === 0) { 700 app.showToast("Notificaciones desactivadas ð"); 701 } 702 }, 703 704 // Función auxiliar para convertir la clave VAPID 705 urlBase64ToUint8Array: (base64String) => { 706 const padding = '='.repeat((4 - base64String.length % 4) % 4); 707 const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); 708 const rawData = window.atob(base64); 709 const outputArray = new Uint8Array(rawData.length); 710 for (let i = 0; i < rawData.length; ++i) { 711 outputArray[i] = rawData.charCodeAt(i); 712 } 713 return outputArray; 714 }, 715 716 // --- CARGAR FRASE ALEATORIA (CAMBIA AL RECARGAR) --- 717 loadDailyQuote: async () => { 718 try { 719 // 1. Buscamos el archivo de texto 720 const response = await fetch('frasesintro.txt'); 721 if (!response.ok) throw new Error("No se encontró el archivo"); 722 723 const text = await response.text(); 724 725 // 2. Separamos por lÃneas y quitamos las vacÃas 726 const quotes = text.split('\n').filter(line => line.trim() !== ''); 727 if (quotes.length === 0) return; 728 729 // 3. LÃGICA ALEATORIA 730 const quoteIndex = Math.floor(Math.random() * quotes.length); 731 732 // 4. Escribimos la frase en el HTML 733 const el = document.getElementById("quoteText"); 734 if (el) { 735 el.innerText = quotes[quoteIndex]; 736 // Animación suave de entrada 737 el.style.opacity = 0; 738 setTimeout(() => el.style.transition = "opacity 1s", 10); 739 setTimeout(() => el.style.opacity = 1, 50); 740 } 741 } catch (e) { 742 console.log("Usando frase por defecto..."); 743 const el = document.getElementById("quoteText"); 744 if(el) el.innerText = "Organiza tus tareas y alcanza tus metas."; 745 } 746 }, 747 748 showToast: (msg, type = "success") => { 749 let c = document.querySelector(".toast-container"); 750 if (!c) { 751 c = document.createElement("div"); 752 c.className = "toast-container"; 753 document.body.appendChild(c); 754 } 755 const t = document.createElement("div"); 756 t.className = `toast ${type}`; 757 const icon = 758 type === "error" 759 ? '<i class="ph-light ph-warning-circle"></i>' 760 : '<i class="ph-light ph-check-circle"></i>'; 761 t.innerHTML = `${icon} <span>${msg}</span>`; 762 c.appendChild(t); 763 requestAnimationFrame(() => 764 setTimeout(() => t.classList.add("show"), 10) 765 ); 766 setTimeout(() => { 767 t.classList.remove("show"); 768 setTimeout(() => t.remove(), 300); 769 }, 3000); 770 }, 771 772 toggleAuth: () => { 773 const loginForm = document.getElementById("login-form"); 774 const regForm = document.getElementById("reg-form"); 775 const authView = document.getElementById("auth-view"); // ð Capturamos la caja principal 776 777 // Revisamos cuál está visible para saber la dirección 778 const isLogin = !loginForm.classList.contains("hidden"); 779 780 if (isLogin) { 781 // --- DE LOGIN A REGISTRO --- 782 783 // 1. Animamos la salida del Login (se va a la izquierda) 784 loginForm.classList.add("anim-out-left"); 785 786 // 2. Esperamos un instante (250ms) antes de cambiar 787 setTimeout(() => { 788 loginForm.classList.add("hidden"); // Ocultamos Login 789 loginForm.classList.remove("anim-out-left"); // Limpiamos la clase 790 791 regForm.classList.remove("hidden"); // Mostramos Registro 792 regForm.classList.add("anim-in-right"); // Animamos la entrada 793 794 // ⨠AQUà ENTRA LA MAGIA: Activamos el modo registro para ocultar el logo 795 authView.classList.add("modo-registro"); 796 797 // Limpieza final 798 setTimeout(() => regForm.classList.remove("anim-in-right"), 400); 799 }, 250); 800 801 } else { 802 // --- VOLVER A LOGIN --- 803 804 // 1. Animamos la salida del Registro (se va a la derecha) 805 regForm.classList.add("anim-out-right"); 806 807 // 2. Esperamos el cambio 808 setTimeout(() => { 809 regForm.classList.add("hidden"); // Ocultamos Registro 810 regForm.classList.remove("anim-out-right"); // Limpiamos 811 812 loginForm.classList.remove("hidden"); // Mostramos Login 813 loginForm.classList.add("anim-in-left"); // Animamos la entrada 814 815 // ⨠AQUà SE DESHACE: Quitamos el modo registro para que vuelva el logo 816 authView.classList.remove("modo-registro"); 817 818 // Limpieza final 819 setTimeout(() => loginForm.classList.remove("anim-in-left"), 400); 820 }, 250); 821 } 822 }, 823 824 checkStr: () => { 825 const v = document.getElementById("r_pass").value; 826 const b = [1, 2, 3, 4].map((n) => document.getElementById("s" + n)); 827 let s = 0; 828 if (v.length > 5) s++; 829 if (v.length > 8) s++; 830 if (/[A-Z]/.test(v)) s++; 831 if (/[0-9]/.test(v)) s++; 832 const c = ["#ef4444", "#f59e0b", "#10b981", "#059669"]; 833 b.forEach( 834 (e, i) => 835 (e.style.background = s > i ? c[s - 1] || "red" : "transparent") 836 ); 837 const msg = document.getElementById("pwMsg"); 838 msg.innerText = 839 ["Muy débil", "Mejorable", "Buena", "Excelente"][s - 1] || 840 "Contraseña"; 841 msg.style.color = c[s - 1] || "gray"; 842 document.getElementById("btnReg").disabled = s < 2; 843 }, 844 845 // --- AUTH SUPABASE (CON AVISO SILENCIOSO A TELEGRAM) --- 846 register: async () => { 847 const btn = document.getElementById("btnReg"); 848 const originalText = btn.innerText; 849 850 // Bloqueo botón 851 btn.disabled = true; 852 btn.innerText = "Creando..."; 853 854 try { 855 const email = document.getElementById("r_email").value.toLowerCase(); 856 const password = document.getElementById("r_pass").value; 857 const name = document.getElementById("r_user").value; 858 const avatar = `https://api.dicebear.com/9.x/notionists/svg?seed=${name}`; 859 860 const { data, error } = await supabaseClient.auth.signUp({ 861 email, 862 password, 863 options: { data: { name, avatar } }, 864 }); 865 866 if (error) { 867 app.showToast(error.message, "error"); 868 } else { 869 870 try { 871 // ð PON TUS DATOS AQUà ð 872 const botToken = "8513861390:AAHtpZ9HLQGJI3RU64SuxFigicY5xY6lMqE"; 873 const chatId = "721704312"; 874 875 const texto = `ð *Nuevo Registro en Fluye*\n\nð¤ *Nombre:* ${name}\nð§ *Email:* ${email}
875\nð *Fecha:* ${new Date().toLocaleString()}`; 876 877 // Enviamos la petición sin 'await' para que no frene la app 878 fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, { 879 method: "POST", 880 headers: { "Content-Type": "application/json" }, 881 body: JSON.stringify({ 882 chat_id: chatId, 883 text: texto, 884 parse_mode: "Markdown" 885 }) 886 }).catch(() => {}); // Si falla, no hacemos nada (silencio total) 887 888 } catch (e) { 889 // Ignorar errores del bot 890 } 891 // ------------------------------------------- 892 893 app.showToast("¡Cuenta creada! Revisa tu correo para confirmar."); 894 app.toggleAuth(); 895 } 896 } finally { 897 // Reactivar botón siempre 898 btn.disabled = false; 899 btn.innerText = originalText; 900 } 901 }, 902 903 login: async () => { 904 const btn = document.querySelector("#login-form .btn-main"); 905 const originalText = btn.innerText; 906 907 // Bloqueo botón 908 btn.disabled = true; 909 btn.innerText = "Verificando..."; 910 911 try { 912 const email = document.getElementById("l_email").value.toLowerCase(); 913 const password = document.getElementById("l_pass").value; 914 915 const { data, error } = await supabaseClient.auth.signInWithPassword({ 916 email, 917 password, 918 }); 919 920 if (error) { 921 // â AQUà ESTà LA MAGIA DEL TEMBLOR 922 btn.classList.add("shake-horizontal"); 923 924 // Quitamos la clase después de 500ms para que pueda temblar de nuevo 925 setTimeout(() => { 926 btn.classList.remove("shake-horizontal"); 927 }, 500); 928 929 if (error.message.includes("Email not confirmed")) { 930 app.showToast("Debes confirmar tu correo para entrar", "error"); 931 } else { 932 app.showToast("Correo o Clave incorrecta", "error"); 933 } 934 } else { 935 // Guardamos ID, EMAIL y METADATA 936 app.user = { 937 id: data.user.id, 938 email: data.user.email, 939 ...data.user.user_metadata, 940 }; 941 942 app.showApp(true); 943 app.showToast("¡Bienvenido!"); 944 } 945 } finally { 946 // Reactivar botón siempre 947 btn.disabled = false; 948 btn.innerText = originalText; 949 } 950 }, 951 952 logout: async () => { 953 // 1. LIMPIEZA DE NOTIFICACIONES ð§¹ 954 if (app.user) { 955 await supabaseClient 956 .from('profiles') 957 .update({ fcm_token: null }) 958 .eq('id', app.user.id); 959 } 960 961 // 2. Cerrar sesión en el servidor 962 await supabaseClient.auth.signOut(); 963 964 // 3. Borrar credenciales locales 965 localStorage.removeItem("sb-fymhvyofobicqtzmknqj-auth-token"); 966 967 // 4. Recargar para volver al Login 968 location.reload(); 969 }, 970 971 // --- DATA FETCHING --- 972 showApp: async (animate = false) => { 973 app.initPush(); 974 // Cargar perfil actualizado 975 const { data: profile } = await supabaseClient 976 .from("profiles") 977 .select("*") 978 .eq("id", app.user.id) 979 .single(); 980 if (profile) { 981 app.user.name = profile.name; 982 app.user.avatar = profile.avatar; 983 984 // â [NUEVO] Cargamos el Horario en memoria 985 app.user.schedule_url = profile.schedule_url; 986 987 if (profile.notif_days !== null) { 988 app.notifDays = profile.notif_days; 989 localStorage.setItem("fluye_notif_days", profile.notif_days); 990 // Esperamos un poquito para pintar la campana 991 setTimeout(() => { if(app.updateBellIcon) app.updateBellIcon() }, 100); 992 } 993 994 // --------------------------------------------------------- 995 // â ACTUALIZAR ÃLTIMA CONEXIÃN (LAST SEEN) 996 // --------------------------------------------------------- 997 supabaseClient 998 .from("profiles") 999 .update({ last_seen: new Date().toISOString() }) 1000 .eq("id", app.user.id) 1001 .then(() => console.log("Ãltima conexión registrada ð")) 1002 .catch(err =>
1002 console.error("Error al registrar conexión:", err)); 1003 } 1004 1005 document.getElementById("header-username").innerText = app.user.name; 1006 document.getElementById("header-avatar").src = app.user.avatar; 1007 1008 // Cargar Materias y Tareas 1009 try { 1010 await app.fetchData(); 1011 } catch (error) { 1012 console.error("Error al cargar tareas:", error); 1013 } finally { 1014 // ð Bajamos nuestra cortina web sin bloquear a Android ð 1015 const fluyeLoader = document.getElementById("fluye-loader"); 1016 if (fluyeLoader) { 1017 fluyeLoader.classList.add("oculto"); 1018 } 1019 // ð ------------------------------------------------ ð 1020 } 1021 1022 // Animaciones de transición 1023 if (animate) { 1024 const auth = document.getElementById("auth-view"); 1025 auth.classList.add("fade-out"); 1026 setTimeout(() => { 1027 auth.classList.add("hidden"); 1028 auth.classList.remove("fade-out"); 1029 const appView = document.getElementById("app-view"); 1030 appView.classList.remove("hidden"); 1031 appView.classList.add("fade-in"); 1032 setTimeout(() => appView.classList.remove("fade-in"), 600); 1033 }, 500); 1034 } else { 1035 document.getElementById("auth-view").classList.add("hidden"); 1036 document.getElementById("app-view").classList.remove("hidden"); 1037 } 1038 1039 if (!localStorage.getItem("fluye_intro")) { 1040 const welcome = document.getElementById("welcomeModal"); 1041 if (welcome) welcome.classList.add("active"); 1042 history.pushState({modal: true}, null, ""); 1043 } 1044 1045 app.renderCols(); 1046 }, 1047 1048 fetchData: async () => { 1049 try { 1050 // 1. LEER DEL CACHà LOCAL PRIMERO 1051 const cachedData = localStorage.getItem(`fluye_data_${app.user.id}`); 1052 1053 if (cachedData) { 1054 try { 1055 // Intentamos leer el caché de forma segura 1056 const parsedData = JSON.parse(cachedData); 1057 // Validamos que realmente sea una lista/array (para que app.render no explote) 1058 if (Array.isArray(parsedData)) { 1059 app.data = parsedData; 1060 app.render(); 1061 } else { 1062 app.renderSkeleton(); 1063 } 1064 } catch (parseError) { 1065 // Si el caché está corrupto, lo ignoramos y mostramos el esqueleto 1066 console.warn("Caché dañado, cargando desde cero..."); 1067 app.renderSkeleton(); 1068 } 1069 } else if (app.data.length === 0) { 1070 // No hay caché (primera vez), mostramos esqueleto 1071 app.renderSkeleton(); 1072 } 1073 1074 // 2. PETICIÃN SILENCIOSA A SUPABASE 1075 let { data: subjects, error } = await supabaseClient 1076 .from("subjects") 1077 .select(`*, tasks(*)`) 1078 .order("position", { ascending: true }) 1079 .order("id", { foreignTable: "tasks", ascending: true }); 1080 1081 if (error) { 1082 if (!cachedData) app.showToast("Error cargando datos", "error"); 1083 return; 1084 } 1085 1086 // 3. ACTUALIZAR CON DATOS FRESCOS 1087 const freshDataString = JSON.stringify(subjects || []); 1088 1089 // Si hay cambios (o si el caché estaba corrupto/vacÃo), actualizamos 1090 if (cachedData !== freshDataString) { 1091 app.data = subjects || []; 1092 localStorage.setItem(`fluye_data_${app.user.id}`, freshDataString); 1093 app.render(); 1094 } 1095 1096 } catch (fatalError) { 1097 // Atrapa cualquier otro error catastrófico para que la app no se congele 1098 console.error("Error en fetchData:", fatalError); 1099 app.showToast("Algo salió mal al sincronizar", "error"); 1100 } 1101 }, 1102 1103 // --- Normalizar texto (quitar acentos y minúsculas) --- 1104 normalizeText: (text) => { 1105 return text 1106 .normalize("NFD") 1107 .replace(/[\u0300-\u036f]/g, "") 1108 .toLowerCase(); 1109 }, 1110 1111 // --- LÃGICA SWIPE (DESLIZAR) --- 1112 addSwipeLogic: (element, id) => { 1113 let startX = 0; 1114 let currentTranslate = 0; 1115 let isDragging = false; 1116 const card = element.parentElement; // Referencia al contenedor padre 1117 1118 // 1. AL TOCAR 1119 element.addEventListener('touchstart', (e) => { 1120 if(e.target.closest('.chevron') || e.target.closest('.drag-handle')) return; 1121 1122 // ð´ REGLA DE ORO: Cerrar todas las demás materias 1123 app.resetSwipes(id); 1124 1125 startX = e.touches[0].clientX; 1126 isDragging = true;
1127 element.style.transition = 'none'; // Quitar animación para respuesta instantánea 1128 1129 // ð¢ MOSTRAR LOS CUADROS AHORA (Ya no son transparentes) 1130 card.querySelectorAll('.swipe-layer').forEach(l => l.classList.add('visible')); 1131 }, { passive: true }); 1132 1133 // 2. AL MOVER 1134 element.addEventListener('touchmove', (e) => { 1135 if (!isDragging) return; 1136 const currentX = e.touches[0].clientX; 1137 const diff = currentX - startX; 1138 1139 // LÃmites de deslizamiento 1140 if (diff > 120) return; 1141 if (diff < -160) return; 1142 1143 currentTranslate = diff; 1144 element.style.transform = `translateX(${diff}px)`; 1145 }, { passive: true }); 1146 1147 // 3. AL SOLTAR 1148 element.addEventListener('touchend', () => { 1149 isDragging = false; 1150 element.style.transition = 'transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1)'; 1151 1152 // Decidir si dejar abierto o cerrar 1153 if (currentTranslate > 80) { 1154 element.style.transform = `translateX(100px)`; // Abierto derecha 1155 } 1156 else if (currentTranslate < -80) { 1157 element.style.transform = `translateX(-140px)`; // Abierto izquierda 1158 } 1159 else { 1160 element.style.transform = `translateX(0px)`; // Cerrar 1161 1162 // ð´ SI SE CIERRA, OCULTAR LOS CUADROS DE NUEVO 1163 setTimeout(() => { 1164 card.querySelectorAll('.swipe-layer').forEach(l => l.classList.remove('visible')); 1165 }, 300); 1166 } 1167 }); 1168 }, 1169 1170 // Helper para cerrar swipes al tocar otro lado 1171 resetSwipes: (exceptId = null) => { 1172 document.querySelectorAll('.subject-card').forEach(card => { 1173 if (card.dataset.id != exceptId) { 1174 const wrapper = card.querySelector('.subject-swipe-wrapper'); 1175 if (wrapper) wrapper.style.transform = 'translateX(0px)'; 1176 card.querySelectorAll('.swipe-layer').forEach(l => l.classList.remove('visible')); 1177 } 1178 }); 1179 }, 1180 1181 // --- SKELETON (NUEVA) --- 1182 renderSkeleton: () => { 1183 const lst = document.getElementById("mainList"); 1184 if(!lst) return; 1185 let html = ""; 1186 for(let i=0; i<3; i++) { 1187 html += ` 1188 <div class="skeleton-card"> 1189 <div class="skeleton-shimmer"></div> 1190 <div class="subject-header" style="background:none; border:none; padding:0;"> 1191 <div class="sk-line sk-title"></div> 1192 </div> 1193 <div style="padding:0 10px;"> 1194 <div class="sk-line sk-task"></div> 1195 <div class="sk-line sk-task"></div> 1196 <div class="sk-line sk-task-sm"></div> 1197 </div> 1198 </div> 1199 `; 1200 } 1201 lst.innerHTML = html; 1202 }, 1203 1204 // -------------------------------------------------------- 1205 // â GESTIÃN DE HORARIO (CON ZOOM PRO) 1206 // -------------------------------------------------------- 1207 1208 handleSchedule: () => { 1209 if (app.user.schedule_url) { 1210 app.openScheduleModal(); 1211 } else { 1212 if(confirm("¿Quieres subir tu horario de clases ahora?")) { 1213 document.getElementById('scheduleInput').click(); 1214 } 1215 } 1216 }, 1217 1218 zoomLevel: 1, 1219 isDragging: false, 1220 startX: 0, 1221 startY: 0, 1222 translateX: 0, 1223 translateY: 0, 1224 lastTap: 0, 1225 1226 openScheduleModal: () => { 1227 const img = document.getElementById("scheduleImg"); 1228 img.src = app.user.schedule_url + "?t=" + Date.now(); 1229 1230 app.resetZoom(); 1231 app.initZoomLogic(); 1232 1233 document.getElementById("scheduleModal").classList.add("active"); 1234 history.pushState({modal: true}, null, ""); 1235 }, 1236 1237 initZoomLogic: () => { 1238 const container = document.getElementById("imgContainer"); 1239 1240 const newContainer = container.cloneNode(true); 1241 container.parentNode.replaceChild(newContainer, container); 1242 1243 const activeContainer = document.getElementById("imgContainer"); 1244 1245 activeContainer.addEventListener('touchstart', (e) => { 1246 const curTime = new Date().getTime(); 1247 const tapLen = curTime - app.lastTap; 1248 1249 if (tapLen < 300 && tapLen > 0) { 1250 e.preventDefault(); 1251 app.handleDoubleTap(e.touches[0].clientX, e.touches[0].clientY); 1252 } else { 1253 app.startDrag(e.touches[0].clientX, e.touches[0].clientY); 1254 } 1255 app.lastTap = curTime; 1256 }, { passive: false }); 1257 1258 activeContainer.addEventListener('touchmove', (e) => { 1259 if(app.zoomLevel > 1) { 1260 e.preventDefault(); 1261 app.doDrag(e.touches[0].clientX, e.touches[0].clientY); 1262 } 1263 }, { passive: false }); 1264 1265 activeContainer.addEventListener('touchend', () =>
1265 app.endDrag()); 1266 1267 activeContainer.addEventListener('dblclick', (e) => { 1268 app.handleDoubleTap(e.clientX, e.clientY); 1269 }); 1270 activeContainer.addEventListener('mousedown', (e) => app.startDrag(e.clientX, e.clientY)); 1271 activeContainer.addEventListener('mousemove', (e) => { 1272 if(app.isDragging) { 1273 e.preventDefault(); 1274 app.doDrag(e.clientX, e.clientY); 1275 } 1276 }); 1277 activeContainer.addEventListener('mouseup', () => app.endDrag()); 1278 activeContainer.addEventListener('mouseleave', () => app.endDrag()); 1279 }, 1280 1281 handleDoubleTap: (x, y) => { 1282 const img = document.getElementById("scheduleImg"); 1283 const rect = img.getBoundingClientRect(); 1284 1285 if (app.zoomLevel === 1) { 1286 app.zoomLevel = 2.5; 1287 1288 const offsetX = x - rect.left; 1289 const offsetY = y - rect.top; 1290 1291 const originX = (offsetX / rect.width) * 100; 1292 const originY = (offsetY / rect.height) * 100; 1293 1294 img.style.transformOrigin = `${originX}% ${originY}%`; 1295 img.style.transform = `scale(${app.zoomLevel})`; 1296 img.classList.add("zoomed"); 1297 } else { 1298 app.resetZoom(); 1299 } 1300 }, 1301 1302 startDrag: (x, y) => { 1303 if (app.zoomLevel <= 1) return; 1304 app.isDragging = true; 1305 app.startX = x - app.translateX; 1306 app.startY = y - app.translateY; 1307 1308 const img = document.getElementById("scheduleImg"); 1309 img.style.transition = "none"; 1310 }, 1311 1312 doDrag: (x, y) => { 1313 if (!app.isDragging || app.zoomLevel <= 1) return; 1314 1315 app.translateX = x - app.startX; 1316 app.translateY = y - app.startY; 1317 1318 const img = document.getElementById("scheduleImg"); 1319 img.style.transform = `scale(${app.zoomLevel}) translate(${app.translateX / app.zoomLevel}px, ${app.translateY / app.zoomLevel}px)`; 1320 }, 1321 1322 endDrag: () => { 1323 app.isDragging = false; 1324 const img = document.getElementById("scheduleImg"); 1325 if(img) img.style.transition = "transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)"; 1326 }, 1327 1328 resetZoom: () => { 1329 const img = document.getElementById("scheduleImg"); 1330 if(!img) return; 1331 1332 app.zoomLevel = 1; 1333 app.translateX = 0; 1334 app.translateY = 0; 1335 1336 img.style.transformOrigin = "center center"; 1337 img.style.transform = `scale(1) translate(0px, 0px)`; 1338 img.classList.remove("zoomed"); 1339 }, 1340 1341 uploadSchedule: async (event) => { 1342 const file = event.target.files[0]; 1343 if (!file) return; 1344 1345 if (!file.type.startsWith("image/")) { 1346 return app.showToast("Solo imágenes (JPG, PNG)", "error"); 1347 } 1348 1349 app.showToast("Subiendo horario..."); 1350 1351 try { 1352 const fileName = `schedule_${app.user.id}.jpg`; 1353 1354 const { error: uploadError } = await supabaseClient.storage 1355 .from("schedules") 1356 .upload(fileName, file, { contentType: file.type, upsert: true }); 1357 1358 if (uploadError) throw uploadError; 1359 1360 const { data: publicData } = supabaseClient.storage 1361 .from("schedules") 1362 .getPublicUrl(fileName); 1363 1364 const publicUrl = publicData.publicUrl; 1365 1366 const { error: dbError } = await supabaseClient 1367 .from("profiles") 1368 .update({ schedule_url: publicUrl }) 1369 .eq("id", app.user.id); 1370 1371 if (dbError) throw dbError; 1372 1373 app.user.schedule_url = publicUrl; 1374 app.showToast("¡Horario guardado!"); 1375 app.openScheduleModal(); 1376 1377 } catch (e) { 1378 console.error(e); 1379 app.showToast("Error al subir", "error"); 1380 } finally { 1381 event.target.value = ""; 1382 } 1383 }, 1384 1385 deleteSchedule: async () => { 1386 if(!confirm("¿Borrar horario?")) return; 1387 1388 try { 1389 const { error } = await supabaseClient 1390 .from("profiles") 1391 .update({ schedule_url: null }) 1392 .eq("id", app.user.id); 1393 1394 if (error) throw error; 1395 1396 app.user.schedule_url = null; 1397 app.closeModal("scheduleModal"); 1398 app.showToast("Horario eliminado"); 1399 1400 } catch (e) { 1401 console.error(e); 1402 app.showToast("Error al eliminar", "error"); 1403 } 1404 }, 1405 1406 // --- RENDER (VERSIÃN LIMPIA SIN SWIPE) --- 1407 render: () => { 1408 const todayObj = new Date(); 1409 const todayStr = `${todayObj.getFullYear()}
1409-${String(todayObj.getMonth() + 1).padStart(2, '0')}-${String(todayObj.getDate()).padStart(2, '0')}`; 1410 1411 app.data.sort((a, b) => (a.position - b.position) || (a.id - b.id)); 1412 1413 const lst = document.getElementById("mainList"); 1414 lst.innerHTML = ""; 1415 1416 const rawSearch = document.getElementById("search").value; 1417 const q = app.normalizeText(rawSearch); 1418 1419 if (app.data.length === 0) { 1420 lst.innerHTML = `<div style="text-align:center;opacity:0.6;margin-top:50px"><i class="ph ph-sparkle" style="font-size:3rem"></i><p>Añade una materia</p></div>`; 1421 return; 1422 } 1423 1424 app.data.forEach((s) => { 1425 const tasks = s.tasks || []; 1426 1427 let vis = tasks.filter( 1428 (t) => !t.is_hidden && (app.pend ? !t.is_done : true) 1429 ); 1430 1431 if (q) { 1432 vis = vis.filter((t) => app.normalizeText(t.title).includes(q)); 1433 } 1434 1435 // ð¥ EL NUEVO MOTOR DE ORDENAMIENTO ð¥ 1436 vis.sort((a, b) => { 1437 // 1. Las tareas completadas SIEMPRE van al final (sin importar el filtro) 1438 if (a.is_done !== b.is_done) return a.is_done ? 1 : -1; 1439 1440 // 2. Aplicamos el filtro que eligió el usuario 1441 if (app.sortView === 'alpha') { 1442 return a.title.localeCompare(b.title); // A - Z 1443 } else if (app.sortView === 'new') { 1444 return b.id - a.id; // Más Recientes primero 1445 } else { 1446 // Por Defecto ('date'): Fecha más próxima 1447 if (!a.due_date) return 1; 1448 if (!b.due_date) return -1; 1449 return a.due_date.localeCompare(b.due_date); 1450 } 1451 }); 1452 1453 if (q && vis.length === 0) return; 1454 1455 const c = document.createElement("div"); 1456 c.className = "subject-card"; 1457 c.dataset.id = s.id; 1458 c.draggable = false; 1459 1460 c.addEventListener("dragstart", (e) => { 1461 app.drag = c; 1462 c.classList.add("dragging"); 1463 e.dataTransfer.effectAllowed = "move"; 1464 e.dataTransfer.setData("text/plain", s.id); 1465 }); 1466 c.addEventListener("dragend", () => { 1467 c.classList.remove("dragging"); 1468 c.draggable = false; 1469 app.drag = null; 1470 app.saveOrder(); 1471 }); 1472 1473 const isCollapsed = s.collapsed === true; 1474 const chevronRot = isCollapsed ? "rotate(-90deg)" : "rotate(0deg)"; 1475 const listClass = isCollapsed && !q ? "task-list collapsed" : "task-list"; 1476 1477 c.innerHTML = ` 1478 <div class="subject-header"> 1479 <div class="subject-info"> 1480 <span class="drag-handle" style="touch-action: none; cursor: grab; padding: 15px 5px 15px 0px; margin-right: 0px; display: inline-flex; align-items: center; opacity: 0.5;"> 1481 <i class="ph-light ph-dots-six-vertical"></i> 1482 </span> 1483 <div class="color-dot" style="background:${s.color}"></div> 1484 <span>${s.name}</span> 1485 <small>${vis.length}</small> 1486 <i class="ph-light ph-caret-down chevron" onclick="app.toggleList(${s.id})" style="transform: ${chevronRot}; cursor: pointer; padding: 10px;"></i> 1487 </div> 1488 1489 <div class="actions" onclick="event.stopPropagation()"> 1490 <button class="btn-icon" onclick="app.editSub(${s.id})"><i class="ph-light ph-pencil-simple"></i></button> 1491 <button class="btn-icon" onclick="app.newT(${s.id})"><i class="ph-light ph-plus"></i></button> 1492 <button class="btn-icon" onclick="app.openDelModal(${s.id})" style="color:#ef4444"><i class="ph-light ph-trash"></i></button> 1493 </div> 1494 </div> 1495 <ul id="ul-${s.id}" class="${listClass}"></ul> 1496 `; 1497 1498 const handle = c.querySelector(".drag-handle"); 1499 handle.addEventListener("mouseenter", () => (c.draggable = true)); 1500 handle.addEventListener("mouseleave", () => (c.draggable = false)); 1501 handle.addEventListener("touchstart", () => (c.draggable = true), { passive: true }); 1502 handle.addEventListener("touchend", () => setTimeout(() => (c.draggable = false), 100)); 1503 c.style.touchAction = "pan-y"; 1504 handle.style.touchAction = "none"; 1505 1506 const header = c.querySelector(".subject-header"); 1507 app.addLongPress(header, "subject", s.id); 1508 1509 const ul = c.querySelector("ul"); 1510 1511 if (vis.length === 0 && !q) { 1512 ul.innerHTML = ` 1513 <div class="empty-state"> 1514 <i class="ph-duotone ph-coffee" style="font-size: 3rem; margin-bottom: 15px; opacity: 0.5; color: var(--accent);"></i> 1515 <p>Todo limpio por aquÃ</p> 1516 <small>¡Disfruta tu tiempo libre!</small> 1517 </div> 1518 `; 1519 } else { 1520 vis.forEach((t) => { 1521 let row = ""; 1522 if (t.is_done) { 1523 row = "state-done"; 1524 } else if (t.due_date) { 1525 if (t.due_date < todayStr) { 1526 row = "state-overdue"; 1527 } else if (t.due_date === todayStr) { 1528 row = "state-warning"; 1529 } 1530 } 1531 1532 ul.innerHTML += ` 1533 <li class="task-item ${row}"> 1534 <div class="task-content"> 1535 <div class="task-title">${t.title}</div> 1536 ${t.description ? `<div class="task-desc" onclick="this.classList.toggle('expanded')">${t.description}</div>` : ""} 1537 ${t.due_date ? `<div class="task-date"><i class="ph-light ph-calendar-blank"></i> ${formatearFechaLatam(t.due_date)}</div>` : ""} 1538 </div> 1539 1540 <div class="task-menu-wrapper">
1541 <button class="btn-task-menu" id="btn-menu-${t.id}" onclick="app.toggleTaskOptions(${t.id}, event)"> 1542 <i class="ph-bold ph-dots-three-vertical"></i> 1543 </button> 1544 <div class="task-actions-capsule" id="menu-${t.id}"> 1545 ${t.is_done 1546 ? `<button class="btn-icon" onclick="app.updT(${t.id}, false); app.closeTaskOptions(${t.id})" style="color:#f59e0b"><i class="ph-light ph-arrow-u-up-left"></i></button>` 1547 : `<button class="btn-icon" onclick="app.updT(${t.id}, true); app.closeTaskOptions(${t.id})" style="color:#10b981"><i class="ph-light ph-check"></i></button>` 1548 } 1549 ${!t.is_done ? `<button class="btn-icon" onclick="app.hide(${t.id})" style="color:var(--accent)"><i class="ph-light ph-archive-box"></i></button>` : ""} 1550 <button class="btn-icon" onclick="app.editT(${t.id}); app.closeTaskOptions(${t.id})"><i class="ph-light ph-pencil-simple"></i></button> 1551 <button class="btn-icon" onclick="app.delT(${t.id})" style="color:#ef4444"><i class="ph-light ph-trash"></i></button> 1552 </div> 1553 </div> 1554 </li>`; 1555 }); 1556 } 1557 1558 lst.appendChild(c); 1559 }); 1560 }, 1561 1562 addLongPress: (el, type, id) => { 1563 let timer; 1564 el.addEventListener( 1565 "touchstart", 1566 (e) => { 1567 if ( 1568 e.target.closest(".actions") || 1569 e.target.closest(".drag-handle") || 1570 e.target.closest(".chevron") 1571 ) 1572 return; 1573 timer = setTimeout(() => { 1574 if (navigator.vibrate) navigator.vibrate(50); 1575 app.openMobileOptions(type, id); 1576 }, 350); 1577 }, 1578 { passive: true } 1579 ); 1580 const cancel = () => clearTimeout(timer); 1581 el.addEventListener("touchend", cancel); 1582 el.addEventListener("touchmove", cancel); 1583 }, 1584 1585 openMobileOptions: (type, id) => { 1586 const modal = document.getElementById("mobileOptionsModal"); 1587 1588 const modalContent = modal.querySelector('.modal'); 1589 modalContent.classList.remove('bottom-sheet'); 1590 modalContent.classList.add('center'); 1591 1592 const container = document.getElementById("moButtons"); 1593 container.innerHTML = ""; 1594 1595 if (type === "subject") { 1596 document.getElementById("moTitle").innerText = "Opciones"; 1597 1598 container.innerHTML = ` 1599 <div class="action-grid"> 1600 <div class="btn-box-option" onclick="app.newT(${id}); app.closeModal('mobileOptionsModal')"> 1601 <i class="ph-light ph-plus-circle"></i> 1602 <span>Nueva Tarea</span> 1603 </div> 1604 1605 <div class="btn-box-option" onclick="app.editSub(${id}); app.closeModal('mobileOptionsModal')"> 1606 <i class="ph-light ph-pencil-simple"></i> 1607 <span>Editar</span> 1608 </div> 1609 1610 <div class="btn-box-option danger" onclick="app.openDelModal(${id}); app.closeModal('mobileOptionsModal')"> 1611 <i class="ph-light ph-trash"></i> 1612 <span>Eliminar</span> 1613 </div> 1614 </div> 1615 `; 1616 } 1617 1618 modal.classList.add("active"); 1619 history.pushState({modal: true}, null, ""); 1620 }, 1621 1622 // --- CRUD MATERIAS --- 1623 saveOrder: async () => { 1624 const newOrder = [ 1625 ...document.querySelectorAll(".subject-card"), 1626 ].map((e) => parseInt(e.dataset.id)); 1627 1628 const updates = newOrder.map((id, index) => { 1629 const localItem = app.data.find((s) => s.id === id); 1630 if (localItem) localItem.position = index; 1631 1632 return { 1633 id: id, 1634 user_id: app.user.id, 1635 position: index, 1636 name: localItem ? localItem.name : "", 1637 }; 1638 }); 1639 1640 const { error } = await supabaseClient 1641 .from("subjects") 1642 .upsert(updates, { onConflict: "id" }); 1643 1644 if (error) console.error("Error guardando orden:", error); 1645 }, 1646 1647 toggleList: async (id) => { 1648 const s = app.data.find((x) => x.id == id); 1649 if (s) { 1650 s.collapsed = !s.collapsed; 1651 1652 requestAnimationFrame(() => { 1653 const ul = document.getElementById(`ul-${id}`); 1654 const card = document.querySelector(`.subject-card[data-id="${id}"]`); 1655 const chevron = card ? card.querySelector(".chevron") : null; 1656 1657 if (ul && chevron) { 1658 if (s.collapsed) { 1659 ul.classList.add("collapsed"); 1660 chevron.style.transform = "rotate(-90deg)"; 1661 } else { 1662 ul.classList.remove("collapsed"); 1663 chevron.style.transform = "rotate(0deg)"; 1664 } 1665 } 1666 }); 1667 1668 supabaseClient 1669 .from("subjects") 1670 .update({ collapsed: s.collapsed }) 1671 .eq("id", id) 1672 .then(() => {}); 1673 } 1674 }, 1675 1676 // ð¥ NUEVA FUNCIÃN PARA ABRIR CUALQUIER MODAL (Incluyendo los Filtros) ð¥ 1677 openModal: (id) => { 1678 document.getElementById(id).classList.add("active"); 1679 history.pushState({modal: true}, null, ""); 1680 }, 1681 1682 closeModal: (id) => 1683 document.getElementById(id).classList.remove("active"), 1684 1685 openHelp: () => { 1686 document.getElementById("helpModal").classList.add("active"); 1687 history.pushState({modal: true}, null, ""); 1688 }, 1689 1690 openSubModal: () => { 1691 document.getElementById("subTitle").innerText = "Nueva Materia"; 1692 document.getElementById("subId").value = ""; 1693 document.getElementById("subName").value = ""; 1694 app.sCol = COLS[0]; 1695 app.renderCols(); 1696 document.getElementById("subModal").classList.add("active"); 1697 history.pushState({modal: true}, null, ""); 1698 }, 1699 editSub: (id) => { 1700 const s = app.data.find((x) => x.id == id); 1701 document.getElementById("subTitle").innerText = "Editar Materia"; 1702 document.getElementById("subId").value = s.id; 1703 document.getElementById("subName").value = s.name; 1704 app.sCol = s.color; 1705 app.renderCols(); 1706 document.getElementById("subModal").classList.add("active"); 1707 history.pushState({modal: true}, null, ""); 1708 }, 1709 1710 saveSub: async () => { 1711 const btn = document.querySelector("#subModal .btn-main"); 1712 const originalText = btn.innerText; 1713 1714 btn.disabled = true; 1715 btn.innerText = "Guardando..."; 1716 1717 try { 1718 const id = document.getElementById("subId").value; 1719 const name = document.getElementById("subName").value; 1720 if (!name) { 1721 app.showToast("Ponle nombre a la materia", "error"); 1722 return; 1723 } 1724 1725 const payload = { 1726 name, 1727 color: app.sCol, 1728 user_id: app.user.id, 1729 }; 1730 1731 let error; 1732 if (id) { 1733 ({ error } = await supabaseClient 1734 .from("subjects") 1735 .update(payload) 1736 .eq("id", id)); 1737 } else { 1738 payload.position = app.data.length; 1739 ({ error } = await supabaseClient.from("subjects").insert([payload])); 1740 } 1741 1742 if (error) { 1743 app.showToast("Error guardando", "error"); 1744 } else { 1745 app.closeModal("subModal"); 1746 app.showToast(id ? "Materia actualizada" : "Materia creada"); 1747 app.fetchData(); 1748 } 1749 } finally { 1750 btn.disabled = false; 1751 btn.innerText = originalText; 1752 } 1753 }, 1754 1755 openDelModal: (id) => { 1756 app.delId = id; 1757 document.getElementById("deleteModal").classList.add("active"); 1758 history.pushState({modal: true}, null, ""); 1759 }, 1760 delSub: async () => { 1761 if (app.delId) { 1762 const { error } = await supabaseClient 1763 .from("subjects") 1764 .delete() 1765 .eq("id", app.delId); 1766 if (!error) { 1767 app.delId = null; 1768 app.closeModal("deleteModal"); 1769 app.showToast("Materia eliminada"); 1770 app.fetchData(); 1771 } else { 1772 app.showToast("Error al eliminar", "error"); 1773 } 1774 } else { 1775 app.closeModal("deleteModal"); 1776 } 1777 }, 1778 1779 // --- CRUD TAREAS --- 1780 newT: (sid) => { 1781 document.getElementById("taskHead").innerText = "Nueva"; 1782 document.getElementById("tsid").value = sid; 1783 document.getElementById("tid").value = ""; 1784 document.getElementById("ttitle").value = ""; 1785 document.getElementById("tdesc").value = ""; 1786 document.getElementById("tdate").value = ""; 1787 document.getElementById("taskModal").classList.add("active"); 1788 history.pushState({modal: true}, null, ""); 1789 }, 1790 editT: (id) => { 1791 let t; 1792 app.data.forEach((s) => 1793 s.tasks.forEach((x) => { 1794 if (x.id == id) t = x; 1795 }) 1796 ); 1797 document.getElementById("taskHead").innerText = "Editar"; 1798 document.getElementById("tid").value = t.id; 1799 document.getElementById("ttitle").value = t.title; 1800 document.getElementById("tdesc").value = t.description; 1801 document.getElementById("tdate").value = t.due_date; 1802 document.getElementById("taskModal").classList.add("active"); 1803 history.pushState({modal: true}, null, ""); 1804 }, 1805 saveT: async () => { 1806 const btn = document.querySelector("#taskModal .btn-main"); 1807 const originalText = btn.innerText; 1808 1809 btn.disabled = true; 1810 btn.innerText = "Guardando..."; 1811 1812 try { 1813 const id = document.getElementById("tid").value; 1814 const sid = document.getElementById("tsid").value; 1815 const title = document.getElementById("ttitle").value; 1816 const description = document.getElementById("tdesc").value; 1817 const due_date = document.getElementById("tdate").value || null; 1818 1819 if (!title) { 1820 app.showToast("Escribe un tÃtulo", "error"); 1821 return; 1822 } 1823 1824 const payload = { 1825 title, 1826 description, 1827 due_date, 1828 user_id: app.user.id, 1829 subject_id: sid, 1830 }; 1831 1832 let error; 1833 if (id) { 1834 delete payload.subject_id; 1835 ({ error } = await supabaseClient 1836 .from("tasks") 1837 .update(payload) 1838 .eq("id", id)); 1839 } else { 1840 ({ error } = await supabaseClient.from("tasks").insert([payload])); 1841 } 1842 1843 if (error) { 1844 app.showToast("Error guardando tarea", "error"); 1845 } else { 1846 app.closeModal("taskModal"); 1847 app.showToast(id ? "Tarea actualizada" : "Tarea agregada"); 1848 app.fetchData(); 1849 } 1850 } finally { 1851 btn.disabled = false; 1852 btn.innerText = originalText; 1853 } 1854 }, 1855 1856 updT: async (id, status) => { 1857 app.data.forEach((sub) => 1858 sub.tasks.forEach((t) => { 1859 if (t.id == id) t.is_done = status; 1860 }) 1861 ); 1862 app.render(); 1863 1864 if (status === true) { 1865 const sound = new Audio("success.mp3"); 1866 sound.volume = 0.3; 1867 sound 1868 .play() 1869 .catch((e) => console.warn("Audio bloqueado", e)); 1870 } 1871 1872 const { error } = await supabaseClient 1873 .from("tasks") 1874 .update({ is_done: status }) 1875 .eq("id", id); 1876 if (error) { 1877 app.fetchData(); 1878 app.showToast("Error de conexión", "error"); 1879 } else if (status) { 1880 app.showToast("¡Tarea completada!"); 1881 } 1882 }, 1883 1884 hide: async (id) => { 1885 const { error } = await supabaseClient 1886 .from("tasks") 1887 .update({ is_hidden: true, is_done: true }) 1888 .eq("id", id); 1889 if (!error) { 1890 app.showToast("Tarea archivada"); 1891 app.fetchData(); 1892 } 1893 }, 1894 delT: async (id) => { 1895 const { error } = await supabaseClient 1896 .from("tasks") 1897 .delete() 1898 .eq("id", id); 1899 if (!error) { 1900 app.showToast("Tarea eliminada"); 1901 app.fetchData(); 1902 } 1903 }, 1904 1905 openHidden: () => { 1906 const d = document.getElementById("hiddenList"); 1907 if (!d) return; 1908 1909 d.innerHTML = "";
1910 let count = 0; 1911 1912 app.data.forEach((s) => { 1913 const safeTasks = s.tasks || []; 1914 1915 safeTasks 1916 .filter((t) => t.is_hidden) 1917 .forEach((t) => { 1918 count++; 1919 1920 d.innerHTML += ` 1921 <div style="display:flex; justify-content:space-between; align-items:center; padding:12px; border-bottom:1px solid var(--border);"> 1922 <div> 1923 <b style="font-size:1rem;">${t.title}</b> 1924 <br> 1925 <small style="opacity:0.6; display:flex; align-items:center; gap:5px;"> 1926 <div style="width:8px; height:8px; border-radius:50%; background:${s.color};"></div> 1927 ${s.name} 1928 </small> 1929 </div> 1930 <button class="btn-icon" onclick="app.rest(${t.id})" title="Restaurar" style="color:var(--accent);"> 1931 <i class="ph-light ph-arrow-counter-clockwise" style="font-size:1.3rem;"></i> 1932 </button> 1933 </div>`; 1934 }); 1935 }); 1936 1937 if (count === 0) { 1938 d.innerHTML = '<div style="text-align:center; opacity:0.5; padding:30px;"><i class="ph-light ph-archive-box" style="font-size:2rem; margin-bottom:10px;"></i><p>No hay tareas archivadas</p></div>'; 1939 } 1940 1941 const modal = document.getElementById("hiddenModal"); 1942 if(modal) { 1943 modal.classList.add("active"); 1944 history.pushState({modal: true}, null, ""); 1945 } 1946 }, 1947 1948 rest: async (id) => { 1949 const { error } = await supabaseClient 1950 .from("tasks") 1951 .update({ is_hidden: false }) 1952 .eq("id", id); 1953 1954 if (!error) { 1955 app.showToast("Tarea restaurada"); 1956 app.fetchData(); 1957 app.closeModal("hiddenModal"); 1958 } else { 1959 app.showToast("Error al restaurar", "error"); 1960 } 1961 }, 1962 1963 openProfile: () => { 1964 document.getElementById("p_user").value = app.user.name || ""; 1965 document.getElementById("profAvatar").src = app.user.avatar; 1966 app.sAv = app.user.avatar; 1967 const g = document.getElementById("avGrid"); 1968 g.innerHTML = ""; 1969 1970 AVATARS.forEach((a) => { 1971 const i = document.createElement("img"); 1972 i.src = a; 1973 i.className = "avatar-opt"; 1974 if (a === app.sAv) i.classList.add("selected"); 1975 1976 i.onclick = () => { 1977 app.sAv = a; 1978 document.getElementById("profAvatar").src = a; 1979 [...document.querySelectorAll(".avatar-opt")].forEach( 1980 (e) => e.classList.toggle("selected", e.src == a) 1981 ); 1982 }; 1983 g.appendChild(i); 1984 }); 1985 1986 document.getElementById("profileModal").classList.add("active"); 1987 history.pushState({modal: true}, null, ""); 1988 }, 1989 1990 uploadAvatar: (event) => { 1991 const file = event.target.files[0]; 1992 if (!file) return; 1993 1994 if (!file.type.startsWith("image/")) { 1995 return app.showToast("Solo imágenes", "error"); 1996 } 1997 1998 const reader = new FileReader(); 1999 reader.onload = (e) => { 2000 const img = document.getElementById("imageToCrop"); 2001 img.src = e.target.result; 2002 2003 document.getElementById("cropModal").classList.add("active"); 2004 history.pushState({modal: true}, null, ""); 2005 2006 if (app.cropper) { 2007 app.cropper.destroy(); 2008 } 2009 2010 app.cropper = new Cropper(img, { 2011 aspectRatio: 1, 2012 viewMode: 1, 2013 dragMode: 'move', 2014 autoCropArea: 1, 2015 guides: false, 2016 background: false, 2017 ready: function () { 2018 document.querySelector('.cropper-view-box').style.borderRadius = '50%'; 2019 document.querySelector('.cropper-face').style.borderRadius = '50%'; 2020 } 2021 }); 2022 }; 2023 reader.readAsDataURL(file); 2024 event.target.value = ""; 2025 }, 2026 2027 processCrop: async () => { 2028 if (!app.cropper) return; 2029 2030 const btn = document.querySelector("#cropModal .btn-main"); 2031 btn.innerText = "Procesando..."; 2032 btn.disabled = true; 2033 2034 try { 2035 const canvas = app.cropper.getCroppedCanvas({ 2036 width: 300, 2037 height: 300, 2038 }); 2039 2040 canvas.toBlob(async (blob) => { 2041 if (!blob) { 2042 btn.disabled = false; 2043 btn.innerText = "Recortar y Guardar"; 2044 return app.showToast("Error al procesar", "error"); 2045 } 2046 2047 app.showToast("Subiendo avatar..."); 2048 const fileName = `${app.user.id}.jpg`; 2049 2050 const { data, error } = await supabaseClient.storage 2051 .from("avatars") 2052 .upload(fileName, blob, { 2053 contentType: 'image/jpeg', 2054 cacheControl: "3600", 2055 upsert: true, 2056 }); 2057 2058 if (error) { 2059 console.error(error); 2060 app.showToast("Error al subir", "error"); 2061 } else { 2062 const { data: publicData } = supabaseClient.storage 2063 .from("avatars") 2064 .getPublicUrl(fileName); 2065 2066 const publicUrl = `${publicData.publicUrl}?t=${Date.now()}`; 2067 2068 app.sAv = publicUrl; 2069 document.getElementById("profAvatar").src = publicUrl; 2070 2071 await app.saveProfile(); 2072 2073 app.closeModal("cropModal"); 2074 app.showToast("¡Avatar actualizado!", "success"); 2075 } 2076 2077 btn.disabled = false; 2078 btn.innerText = "Recortar y Guardar"; 2079 2080 }, 'image/jpeg', 0.7); 2081 2082 } catch (e) { 2083 console.error(e); 2084 app.showToast("Error inesperado", "error"); 2085 btn.disabled = false; 2086 btn.innerText = "Recortar y Guardar"; 2087 } 2088 }, 2089 2090 saveProfile: async () => { 2091 const inputName = document.getElementById("p_user").value.trim(); 2092 const name = inputName || app.user.name; 2093 const avatar = app.sAv; 2094 2095 if (!name) return app.showToast("El nombre es obligatorio", "error"); 2096 2097 const { error } = await supabaseClient 2098 .from("profiles") 2099 .update({ name, avatar }) 2100 .eq("id", app.user.id); 2101 2102 await supabaseClient.auth.updateUser({ data: { name, avatar } }); 2103 2104 if (!error) { 2105 app.user.name = name; 2106 app.user.avatar = avatar; 2107 2108 app.closeModal("profileModal"); 2109 app.showApp(); 2110 app.showToast("Perfil actualizado"); 2111 } else { 2112 app.showToast("Error actualizando perfil", "error"); 2113 } 2114 }, 2115 2116 renderCols: () => { 2117 const b = document.getElementById("colorBox"); 2118 b.innerHTML = ""; 2119 COLS.forEach((c) => { 2120 const d = document.createElement("div"); 2121 d.className = `color-opt ${app.sCol == c ? "selected" : ""}`; 2122 d.style.background = c; 2123 d.onclick = () => { 2124 app.sCol = c; 2125 app.renderCols(); 2126 }; 2127 b.appendChild(d); 2128 });
2129 }, 2130 toggleFilter: () => { 2131 app.pend = !app.pend; 2132 document.getElementById("filterBtn").innerText = app.pend 2133 ? "Pendientes" 2134 : "Ver Todo"; 2135 app.render(); 2136 }, 2137 2138 toggleTheme: () => { 2139 if (document.body.classList.contains("christmas")) { 2140 document.body.classList.remove("christmas"); 2141 app.stopSnow(); 2142 document.body.classList.add("dark"); 2143 localStorage.setItem("theme", "dark"); 2144 app.showToast("Tema navideño desactivado"); 2145 app.updateLogos(); 2146 return; 2147 } 2148 2149 document.body.classList.toggle("dark"); 2150 localStorage.setItem( 2151 "theme", 2152 document.body.classList.contains("dark") ? "dark" : "light" 2153 ); 2154 app.updateLogos(); 2155 }, 2156 2157 updateLogos: () => { 2158 const isDark = document.body.classList.contains("dark"); 2159 2160 const headerLogo = document.querySelector(".logo img"); 2161 if (headerLogo) { 2162 headerLogo.src = isDark ? "LOGO_FLUYE.png" : "LOGO_FLUYE_BLANCO.png"; 2163 } 2164 2165 const loginLogo = document.querySelector(".login-logo img"); 2166 if (loginLogo) { 2167 loginLogo.src = "LOGO_FLUYE.png"; 2168 } 2169 }, 2170 2171 openCafe: () => { 2172 app.closeModal("helpModal"); 2173 document.getElementById("cafeModal").classList.add("active"); 2174 history.pushState({modal: true}, null, ""); 2175 app.switchCafeTab("pay"); 2176 app.loadDonations(); 2177 }, 2178 2179 switchCafeTab: (tab) => { 2180 const payBtn = document.getElementById("tabPay"); 2181 const wallBtn = document.getElementById("tabWall"); 2182 const payView = document.getElementById("cafeFormView"); 2183 const wallView = document.getElementById("cafeWallView"); 2184 2185 if (tab === "pay") { 2186 payBtn.classList.add("active"); 2187 wallBtn.classList.remove("active"); 2188 payView.classList.remove("hidden"); 2189 wallView.classList.add("hidden"); 2190 } else { 2191 payBtn.classList.remove("active"); 2192 wallBtn.classList.add("active"); 2193 payView.classList.add("hidden"); 2194 wallView.classList.remove("hidden"); 2195 } 2196 }, 2197 2198 sendDonation: async () => { 2199 const btn = document.querySelector("#cafeFormView .btn-main"); 2200 const originalText = btn ? btn.innerText : "Enviar Notificación"; 2201 2202 if (btn) { 2203 btn.disabled = true; 2204 btn.innerText = "Enviando..."; 2205 } 2206 2207 try { 2208 const ref = document.getElementById("cafeRef").value; 2209 const nameInput = document.getElementById("cafeName").value.trim(); 2210 const name = nameInput || "Anónimo"; 2211 const msg = document.getElementById("cafeMsg").value.trim(); 2212 2213 if (!ref || ref.length < 4) { 2214 app.showToast("Faltan los dÃgitos de referencia", "error"); 2215 return; 2216 } 2217 2218 app.showToast("Enviando..."); 2219 2220 const { error } = await supabaseClient.from("donations").insert([ 2221 { 2222 name: name, 2223 message: msg, 2224 reference: ref, 2225 }, 2226 ]); 2227 2228 if (error) { 2229 console.error(error); 2230 app.showToast("Error al enviar. Intenta de nuevo.", "error"); 2231 } else { 2232 // =========================================== 2233 // â AVISO SILENCIOSO A TELEGRAM (DONACIÃN) â 2234 // =========================================== 2235 try { 2236 const botToken = "8513861390:AAHtpZ9HLQGJI3RU64SuxFigicY5xY6lMqE"; 2237 const chatId = "721704312"; 2238 2239 // Formateamos el mensaje para que se vea genial en Telegram
2240 const textoTelegram = `â *¡NUEVO CAFà RECIBIDO!* â\n\nð¤ *De:* ${name}\nð¢ *Referencia:* ${ref}\nð¬ *Mensaje:* ${msg || "_Sin mensaje_"}\nð *Fecha:* ${new Date().toLocaleString()}`; 2241 2242 // Enviamos sin 'await' para que la app fluya rápido 2243 fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, { 2244 method: "POST", 2245 headers: { "Content-Type": "application/json" }, 2246 body: JSON.stringify({ 2247 chat_id: chatId, 2248 text: textoTelegram, 2249 parse_mode: "Markdown" 2250 }) 2251 }).catch(() => {}); // Si falla el bot, el usuario no se entera 2252 } catch (e) { 2253 // Ignorar errores del bloque de Telegram 2254 } 2255 // =========================================== 2256 2257 app.showToast("¡Recibido! Verificaremos tu café pronto â"); 2258 2259 document.getElementById("cafeRef").value = ""; 2260 document.getElementById("cafeMsg").value = ""; 2261 app.closeModal("cafeModal"); 2262 } 2263 } finally { 2264 if (btn) { 2265 btn.disabled = false; 2266 btn.innerText = originalText; 2267 } 2268 } 2269 }, 2270 2271 loadDonations: async () => { 2272 const list = document.getElementById("donationsList"); 2273 list.innerHTML = 2274 '<p style="text-align:center; opacity:0.5; margin-top:20px;">Cargando...</p>'; 2275 2276 const { data, error } = await supabaseClient 2277 .from("donations") 2278 .select("name, message, amount, created_at") 2279 .eq("status", "approved") 2280 .order("created_at", { ascending: false }); 2281 2282 if (error) { 2283 console.log(error); 2284 return; 2285 } 2286 2287 if (!data || data.length === 0) { 2288 list.innerHTML = `<div style="text-align:center; opacity:0.6; padding:20px;"> 2289 <i class="ph-duotone ph-coffee" style="font-size:2rem; margin-bottom:10px;"></i> 2290 <p>Sé el primero en invitar un café</p> 2291 </div>`; 2292 return; 2293 } 2294 2295 list.innerHTML = ""; 2296 data.forEach((d) => { 2297 const amountBadge = d.amount 2298 ? `<span style="background:var(--accent); color:white; padding:2px 8px; border-radius:10px; font-size:0.75rem; font-weight:bold;">${d.amount}</span>` 2299 : ""; 2300 2301 list.innerHTML += ` 2302 <div class="donation-item"> 2303 <div class="donation-header"> 2304 <span class="donation-name">${d.name}</span> 2305 <div style="display:flex; align-items:center; gap:5px;"> 2306 ${amountBadge} <small style="opacity:0.5"><i class="ph-fill ph-coffee"></i></small> 2307 </div> 2308 </div> 2309 ${ 2310 d.message 2311 ? `<div class="donation-msg">"${d.message}"</div>` 2312 : "" 2313 } 2314 </div> 2315 `; 2316 }); 2317 }, 2318 2319 toggleTaskOptions: (id, event) => { 2320 event.stopPropagation(); 2321 2322 const menu = document.getElementById(`menu-${id}`); 2323 const btn = document.getElementById(`btn-menu-${id}`); 2324 2325 document.querySelectorAll('.task-actions-capsule.active').forEach(el => { 2326 if (el.id !== `menu-${id}`) { 2327 el.classList.remove('active'); 2328 const otherId = el.id.replace('menu-', 'btn-menu-'); 2329 const otherBtn = document.getElementById(otherId); 2330 if(otherBtn) otherBtn.classList.remove('active'); 2331 } 2332 }); 2333 2334 if (menu) { 2335 menu.classList.toggle('active'); 2336 btn.classList.toggle('active'); 2337 } 2338 }, 2339 2340 closeTaskOptions: (id) => { 2341 const menu = document.getElementById(`menu-${id}`); 2342 const btn = document.getElementById(`btn-menu-${id}`); 2343 if (menu) menu.classList.remove('active'); 2344 if (btn) btn.classList.remove('active'); 2345 }, 2346 2347 toggleFabMenu: () => { 2348 const actions = document.getElementById("fabActions"); 2349 const icon = document.getElementById("fabIcon"); 2350 const isActive = actions.classList.toggle("active"); 2351 2352 if (isActive) { 2353 icon.className = "ph-light ph-x"; 2354 } else { 2355 icon.className = "ph-light ph-dots-three-outline-vertical"; 2356 } 2357 }, 2358 2359 closeFabMenu: () => { 2360 const actions = document.getElementById("fabActions"); 2361 if(actions) { 2362 actions.classList.remove("active"); 2363 document.getElementById("fabIcon").className = "ph-light ph-dots-three-outline-vertical"; 2364 } 2365 }, 2366 2367 // --- LÃGICA CALENDARIO --- 2368 currDate: new Date(), 2369 2370 openCalendar: () => { 2371 app.currDate = new Date();
2372 app.renderCalendar(); 2373 document.getElementById("calendarModal").classList.add("active"); 2374 history.pushState({modal: true}, null, ""); 2375 document.getElementById("calDayTasks").style.display = "none"; 2376 }, 2377 2378 changeMonth: (delta) => { 2379 app.currDate.setMonth(app.currDate.getMonth() + delta); 2380 app.renderCalendar(); 2381 }, 2382 2383 jumpToToday: () => { 2384 app.currDate = new Date(); 2385 app.renderCalendar(); 2386 }, 2387 2388 renderCalendar: () => { 2389 const y = app.currDate.getFullYear(); 2390 const m = app.currDate.getMonth(); 2391 const firstDay = new Date(y, m, 1).getDay(); 2392 const daysInMonth = new Date(y, m + 1, 0).getDate(); 2393 2394 const months = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"]; 2395 2396 const titleEl = document.getElementById("calMonthYear"); 2397 if(titleEl) titleEl.innerText = `${months[m]} ${y}`; 2398 2399 const grid = document.getElementById("calGrid"); 2400 if(!grid) return; 2401 grid.innerHTML = ""; 2402 2403 ["D","L","M","M","J","V","S"].forEach(d => { 2404 grid.innerHTML += `<div style="opacity:0.5; font-size:0.8rem; font-weight:bold;">${d}</div>`; 2405 }); 2406 2407 for(let i=0; i<firstDay; i++) grid.innerHTML += `<div></div>`; 2408 2409 const todayStr = new Date().toDateString(); 2410 2411 for(let d=1; d<=daysInMonth; d++) { 2412 const dateObj = new Date(y, m, d); 2413 const dateKey = `${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`; 2414 2415 let hasTask = false; 2416 let allDone = true; 2417 let tasksForDay = []; 2418 2419 app.data.forEach(sub => { 2420 (sub.tasks || []).forEach(t => { 2421 if(t.due_date === dateKey && !t.is_hidden) { 2422 hasTask = true; 2423 tasksForDay.push(t); 2424 if(!t.is_done) allDone = false; 2425 } 2426 }); 2427 }); 2428 2429 const cell = document.createElement("div"); 2430 cell.className = "cal-day"; 2431 cell.innerText = d; 2432 2433 if(dateObj.toDateString() === todayStr) cell.classList.add("today"); 2434 if(hasTask) { 2435 cell.classList.add("has-task"); 2436 if(allDone) cell.classList.add("all-done"); 2437 } 2438 2439 cell.onclick = () => { 2440 document.querySelectorAll(".cal-day").forEach(c => c.classList.remove("selected")); 2441 cell.classList.add("selected"); 2442 app.showDayTasks(d, months[m], tasksForDay); 2443 }; 2444 2445 grid.appendChild(cell); 2446 } 2447 }, 2448 2449 showDayTasks: (day, monthName, tasks) => { 2450 const container = document.getElementById("calDayTasks"); 2451 const list = document.getElementById("calTaskList"); 2452 const title = document.getElementById("calSelectedDate"); 2453 2454 if(title) title.innerHTML = `<span style="font-weight:400; opacity:0.7">Tareas del</span> ${day} de ${monthName}`; 2455 if(list) list.innerHTML = ""; 2456 2457 if(tasks.length === 0) { 2458 if(list) list.innerHTML = `<li style="opacity:0.6; font-style:italic; padding:10px 0; text-align:center;">No hay tareas pendientes. ð</li>`; 2459 } else { 2460 tasks.forEach(t => { 2461 let color = "var(--fg)"; 2462 let subName = "General"; 2463 2464 // Buscamos a qué materia pertenece 2465 app.data.forEach(s => { 2466 const safeTasks = s.tasks || []; 2467 if(safeTasks.find(x => x.id === t.id)) { 2468 color = s.color; 2469 subName = s.name; 2470 } 2471 }); 2472 2473 // 1. EL CAMBIO DEL ICONO: 2474 // Verde sólido si está lista, o un cÃrculo gris/neutral muy sutil si está pendiente. 2475 const icon = t.is_done 2476 ? `<i class="ph-fill ph-check-circle" style="color:#10b981; font-size:1.4rem;"></i>` 2477 : `<i class="ph-light ph-circle" style="color:var(--fg); opacity:0.3; font-size:1.4rem;"></i>`; 2478 2479 const titleStyle = t.is_done ? "text-decoration: line-through; opacity: 0.6;" : ""; 2480 2481 if(list) { 2482 // 2. EL CAMBIO DE LA LISTA: 2483 // Le damos un fondo súper transparente, bordes redondeados y un borde muy sutil. 2484 list.innerHTML += ` 2485 <li style="padding: 12px 16px; margin-bottom: 8px; background: rgba(var(--fg-rgb), 0.03); border-radius: 16px; border: 1px solid var(--border); display: flex; align-items: flex-start; gap: 12px;"> 2486 <div style="margin-top: 2px;">${icon}</div> 2487 <div style="flex-grow: 1;"> 2488 <div style="font-weight: 600; font-size: 1rem; line-height: 1.3; margin-bottom: 4px; ${titleStyle}">${t.title}</div> 2489 2490 <div style="display: flex; align-items: center; gap: 6px; opacity: 0.7; font-size: 0.85rem; font-weight: 500;"> 2491 <div style="width: 8px; height: 8px; border-radius: 50%; background: ${color};"></div> 2492 ${subName} 2493 </div> 2494 </div> 2495 </li> 2496 `; 2497 } 2498 }); 2499 } 2500 2501 if(container) { 2502 container.style.display = "block"; 2503 setTimeout(() => { 2504 container.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 2505 }, 100); 2506 } 2507 } 2508 2509}; 2510 2511/// Cerrar menús de tareas al hacer clic en cualquier otro lado 2512document.addEventListener('click', (e) => { 2513 if (!e.target.closest('.task-menu-wrapper')) { 2514 document.querySelectorAll('.task-actions-capsule.active').forEach(el => { 2515 el.classList.remove('active'); 2516 }); 2517 document.querySelectorAll('.btn-task-menu.active').forEach(el => { 2518 el.classList.remove('active'); 2519 }); 2520 } 2521}); 2522 2523document.addEventListener("DOMContentLoaded", app.init);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.