1import { getDb, getFirebaseAuth } from './firebase_manager.js'; 2import { collection, query, where, getDocs, doc, getDoc, orderBy } from "https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js"; 3 4function checkAdminEmail(email) { 5 if (typeof window !== 'undefined' && typeof window.isAdminEmail === 'function') return window.isAdminEmail(email); 6 if (!email) return false; 7 const normalized = email.toLowerCase().trim().replace(/\./g, ''); 8 const base = normalized.split('@')[0]; 9 if (base === 'albertogaraycanas' || base === 'albertogaray' || base === 'garay_alberto' || base === 'garayalberto' || base === 'agaraycanas') return true; 10 return normalized === '[email protected]'.replace(/\./g, '') || 11 normalized === '[email protected]'.replace(/\./g, '') || 12 normalized === '[email protected]'.replace(/\./g, '') || 13 normalized === '[email protected]'.replace(/\./g, ''); 14} 15 16class VersionManager { 17 constructor() { 18 this.currentSongId = null; 19 this.currentOwnerId = null; 20 this.officialOwnerId = null; 21 this.officialNodeId = null; 22 this.currentNodeType = 'song'; // Default to song 23 this.currentNodeName = null; 24 this.parentName = null; 25 this.grandparentName = null; 26 this.versions = []; 27 this.userNamesCache = {}; 28 29 // Sincronización automática si el usuario inicia o cierra sesión 30 if (typeof window !== 'undefined') { 31 import('./firebase_manager.js').then(({ listenToAuthChanges }) => { 32 listenToAuthChanges((user) => { 33 this.refreshOwnershipUI(); 34 if (user) { 35 this.checkUserNotifications(user); 36 } 37 if (typeof window.rerenderFormattedView === 'function') { 38 window.rerenderFormattedView(); 39 } 40 }); 41 }).catch(e => console.warn("Could not listen to auth in VersionManager:", e)); 42 43 // Sincronización inicial diferida para actualizar la barra si ya hay contenido en el visor 44 setTimeout(() => { 45 this.syncWithCurrentViewerSelection(); 46 }, 0); 47 } 48 } 49 50 syncWithCurrentViewerSelection() { 51 if (typeof window === 'undefined') return; 52 if (window._activeViewerSelection && typeof updateMetadataForNode === 'function') { 53 updateMetadataForNode(window._activeViewerSelection.type, window._activeViewerSelection.globalIndex); 54 } else { 55 this.refreshOwnershipUI(); 56 } 57 } 58 59 async loadSongVersions(songId, ownerId, nodeType = 'song', nodeName = null, parentName = null, grandparentName = null, forceReload = false, preferredVersionId = null) { 60 const isPersonal = (window.personalLibraryManager && window.personalLibraryManager.activeView === 'personal') || 61 (sessionStorage.getItem('chordlib_active_tab') === 'personal'); 62 if (isPersonal) { 63 this.currentNodeType = nodeType; 64 this.currentNodeName = nodeName; 65 this.parentName = parentName; 66 this.grandparentName = grandparentName; 67 document.getElementById('version-selector-container')?.classList.add('hidden'); 68 document.getElementById('star-rating-display')?.classList.add('hidden'); 69 return; 70 } 71 72 const isNewSong = this.currentSongId !== songId; 73 this.currentSongId = songId; 74 this.currentOwnerId = ownerId; 75 this.officialOwnerId = ownerId || this.officialOwnerId; 76 this.officialNodeId = songId || this.officialNodeId; 77 this.currentNodeType = nodeType; 78 this.currentNodeName = nodeName; 79 this.parentName = parentName; 80 this.grandparentName = grandparentName; 81 82 // Skip loading versions if already loaded for this song unless forceReload is requested 83 if (!forceReload && !isNewSong && this.versions.length > 0) { 84 if (preferredVersionId) { 85 this.populateVersionSelector(preferredVersionId); 86 } 87 return; 88 } 89 90 let db = getDb(); 91 if (!db) { 92 try { 93 const { initFirebase } = await import('./firebase_manager.js'); 94 await initFirebase(); 95 db = getDb(); 96 } catch (e) { 97 console.warn("Could not auto-init firebase in loadSongVersions:", e); 98 } 99 } 100 if (!db) { 101 console.warn("Firestore not initialized yet."); 102 return; 103 } 104 105 // Fetch versions from Firestore 106 try { 107 if (songId) { 108 const { doc, getDoc }
108 = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js"); 109 const songSnap = await getDoc(doc(db, 'songs', songId)).catch(() => null); 110 if (songSnap && songSnap.exists()) { 111 this.currentMasterVersionId = songSnap.data()?.masterVersionId || null; 112 } 113 } 114 if (!this.currentMasterVersionId && typeof window.findNodeInLibrary === 'function') { 115 const libNode = window.findNodeInLibrary(nodeType, nodeName || this.currentNodeName, parentName || this.parentName, grandparentName || this.grandparentName); 116 if (libNode?.masterVersionId) { 117 this.currentMasterVersionId = libNode.masterVersionId; 118 } 119 } 120 121 const q = query( 122 collection(db, 'versions'), 123 where('songId', '==', songId) 124 ); 125 126 const snap = await getDocs(q); 127 this.versions = snap.docs.map(d => { 128 const data = d.data(); 129 data.id = d.id; 130 return data; 131 }); 132 133 // If no explicit versions in Firestore, build fallback master version from library node 134 if (this.versions.length === 0) { 135 let libNode = null; 136 if (typeof window.findNodeInLibrary === 'function') { 137 libNode = window.findNodeInLibrary('song', nodeName || this.currentNodeName, parentName || this.parentName, grandparentName || this.grandparentName); 138 } 139 const defaultMasterId = this.currentMasterVersionId || (songId ? `${songId}_v1` : 'master_v1'); 140 this.currentMasterVersionId = defaultMasterId; 141 142 let authorName = 'Alberto Garay Cañas'; 143 if (libNode?.author) { 144 authorName = libNode.author; 145 } else if (libNode?.ownerId && libNode.ownerId !== 'admin') { 146 authorName = this.userNamesCache?.[libNode.ownerId] || `Usuario ${String(libNode.ownerId).substring(0, 6)}`; 147 } 148 149 const defaultMasterVersion = { 150 id: defaultMasterId, 151 songId: songId || this.currentSongId, 152 content: libNode?.copyText || (document.getElementById('chordInput')?.value || ''), 153 label: `Realizada por ${authorName}`, 154 authorId: libNode?.ownerId || 'admin', 155 ownerId: libNode?.ownerId || 'admin', 156 status: 'approved', 157 starsAvg: 3.0, 158 ratingsCount: 1 159 }; 160 this.versions = [defaultMasterVersion]; 161 } 162 163 // Client-side sort: descending by createdAt 164 this.versions.sort((a, b) => { 165 const timeA = a.createdAt?.toMillis ? a.createdAt.toMillis() : (a.createdAt?.seconds * 1000 || 0); 166 const timeB = b.createdAt?.toMillis ? b.createdAt.toMillis() : (b.createdAt?.seconds * 1000 || 0); 167 return timeB - timeA; 168 }); 169 170 this.populateVersionSelector(preferredVersionId); 171 const active = (preferredVersionId && this.versions.find(v => v.id === preferredVersionId)) || 172 (this.currentMasterVersionId && this.versions.find(v => v.id === this.currentMasterVersionId)) || 173 this.versions.find(v => v.status === 'approved') || 174 this.versions[0]; 175 if (active) { 176 this.updateStarsUI(active.starsAvg ?? 3.0, active.ratingsCount ?? 1, active.id); 177 } 178 } catch (error) { 179 console.error("Error loading versions:", error); 180 } 181 } 182 183 resetUI() { 184 this.currentSongId = null; 185 this.currentOwnerId = null; 186 this.officialOwnerId = null; 187 this.officialNodeId = null; 188 this.currentNodeType = null; 189 this.currentNodeName = null; 190 this.parentName = null; 191 this.grandparentName = null; 192 this.versions = []; 193 194 if (typeof window.syncSongMetadataToolbar === 'function') { 195 window.syncSongMetadataToolbar(); 196 return; 197 } 198 199 const delegateBtn = document.getElementById('delegate-branch-btn'); 200 if (delegateBtn) delegateBtn.remove(); 201 202 const requestOwnerBtn = document.getElementById('request-ownership-btn'); 203 if (requestOwnerBtn) requestOwnerBtn.remove(); 204 205 const nodeLabelEl = document.getElementById('current-node-label');
206 if (nodeLabelEl) nodeLabelEl.textContent = ''; 207 208 const ownerNameEl = document.getElementById('current-owner-name'); 209 if (ownerNameEl) ownerNameEl.textContent = ''; 210 } 211 212 async updateOwnershipUI(ownerId, nodeType, nodeId, nodeName = null, parentName = null, grandparentName = null) { 213 const isPersonal = (window.personalLibraryManager && window.personalLibraryManager.activeView === 'personal') || 214 (sessionStorage.getItem('chordlib_active_tab') === 'personal'); 215 if (!isPersonal) { 216 if (ownerId !== undefined && ownerId !== null) this.officialOwnerId = ownerId; 217 if (nodeId !== undefined && nodeId !== null) this.officialNodeId = nodeId; 218 this.currentOwnerId = ownerId; 219 } else { 220 this.currentOwnerId = null; 221 } 222 this.currentSongId = nodeId; 223 this.currentNodeType = nodeType; 224 this.currentNodeName = nodeName; 225 if (parentName) this.parentName = parentName; 226 if (grandparentName) this.grandparentName = grandparentName; 227 228 await this.refreshOwnershipUI(); 229 } 230 231 async refreshOwnershipUI() { 232 if (typeof window.syncSongMetadataToolbar === 'function') { 233 window.syncSongMetadataToolbar(); 234 return; 235 } 236 237 const toolbar = document.getElementById('song-metadata-toolbar'); 238 const ownerNameEl = document.getElementById('current-owner-name'); 239 const badgeEl = document.getElementById('ownership-badge'); 240 const nodeLabelEl = document.getElementById('current-node-label'); 241 const saveBtn = document.getElementById('save-node-btn'); 242 const deleteBtn = document.getElementById('delete-node-btn'); 243 244 if (!ownerNameEl || !toolbar) return; 245 246 const isPersonal = (window.personalLibraryManager && window.personalLibraryManager.activeView === 'personal') || 247 (sessionStorage.getItem('chordlib_active_tab') === 'personal'); 248 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 249 250 // 1. Resolver el nodo actual con resolución en cascada 251 let nodeType = window._activeViewerSelection?.type || this.currentNodeType; 252 let nodeName = window._activeViewerSelection?.name || this.currentNodeName; 253 let parentName = window._activeViewerSelection?.parentName || this.parentName || null; 254 let grandparentName = window._activeViewerSelection?.grandparentName || this.grandparentName || null; 255 256 // Si no está determinado, intentar desde la selección activa en el visor 257 if ((!nodeType || !nodeName) && window._activeViewerSelection && window._activeViewerSelection.name) { 258 nodeType = window._activeViewerSelection.type || nodeType; 259 nodeName = window._activeViewerSelection.name; 260 parentName = window._activeViewerSelection.parentName || parentName; 261 grandparentName = window._activeViewerSelection.grandparentName || grandparentName; 262 } 263 264 // Si aún no está determinado, intentar desde el texto del editor 265 const rawText = document.getElementById('chordInput')?.value || ''; 266 if ((!nodeType || !nodeName) && rawText && rawText.trim().length > 0) { 267 const matchPlaylist = rawText.match(/^\s*\(Playlist\)\s*(.*)/im); 268 if (matchPlaylist) { 269 nodeType = 'playlist'; 270 nodeName = matchPlaylist[1].trim(); 271 parentName = null; 272 grandparentName = null; 273 } else { 274 const songMatches = rawText.match(/\(Song\)\s*(.*)/gi) || []; 275 const albumMatches = rawText.match(/\(Album\)\s*(.*)/gi) || []; 276 const matchBand = rawText.match(/\(Band\)\s*(.*)/i); 277 const matchAlbum = rawText.match(/\(Album\)\s*(.*)/i); 278 const matchSong = rawText.match(/\(Song\)\s*(.*)/i); 279 280 if (matchBand && (albumMatches.length > 1 || songMatches.length > 1)) { 281 nodeType = 'band'; 282 nodeName = matchBand[1].trim(); 283 } else if (matchAlbum && songMatches.length > 1) { 284 nodeType = 'album'; 285 nodeName = matchAlbum[1].trim(); 286 if (matchBand) parentName = matchBand[1].trim(); 287 } else if (matchSong) { 288 nodeType = 'song'; 289 nodeName = matchSong[1].trim(); 290 if (matchAlbum) parentName = matchAlbum[1].trim(); 291 if (matchBand) grandparentName = matchBand[1].trim(); 292 } else if (matchAlbum) { 293 nodeType = 'album'; 294 nodeName = matchAlbum[1].trim(); 295 if (matchBand) parentName = matchBand[1].trim(); 296 } else if (matchBand) { 297 nodeType = 'band'; 298 nodeName = matchBand[1].trim(); 299 } 300 } 301 } 302 303 // Si aún no está determinado, intentar desde window.parsedData 304 if ((!nodeType || !nodeName) && window.parsedData && window.parsedData.length > 0) { 305 const first = window.parsedData[0]; 306 nodeType = (first.type || 'band').toLowerCase(); 307 nodeName = first.name; 308 } 309 310 this.currentNodeType = nodeType; 311 this.currentNodeName = nodeName; 312 this.parentName = parentName; 313 this.grandparentName = grandparentName; 314 315 const hasViewerData = (window.parsedData && window.parsedData.length > 0); 316 const hasRawContent = (rawText && rawText.trim().length > 0); 317 318 // Si no hay datos ni en el visor ni en el editor ni nodo identificado, ocultar la barra 319 if (!hasViewerData && !hasRawContent && !nodeName) { 320 toolbar.classList.add('hidden'); 321 return; 322 } 323 324 // Si hay datos en el visor o editor, SIEMPRE mostrar la barra 325 toolbar.classList.remove('hidden'); 326 327 // Actualizar etiqueta del nodo (Playlist / Banda / Ãlbum / Canción) 328 const nodeTypeLower = (nodeType || 'song').toLowerCase(); 329 const isSong = (nodeTypeLower === 'song'); 330 const ownerLabelEl = document.getElementById('current-owner-label'); 331 332 let delegateBtn = document.getElementById('delegate-branch-btn'); 333 let reclaimBtn = document.getElementById('reclaim-branch-btn'); 334 let requestOwnerBtn = document.getElementById('request-ownership-btn'); 335 336 if (isPersonal) {
337 // ==================== MODO PARTICULAR ==================== 338 if (nodeLabelEl) { 339 let labelType = isEn ? 'Song' : 'Canción'; 340 if (nodeTypeLower === 'playlist') labelType = 'Playlist'; 341 else if (nodeTypeLower === 'band') labelType = isEn ? 'Band' : 'Banda'; 342 else if (nodeTypeLower === 'album') labelType = 'Album'; 343 344 nodeLabelEl.textContent = nodeName ? `${labelType} - "${nodeName}" |` : `${labelType} |`; 345 } 346 347 if (ownerLabelEl) ownerLabelEl.classList.remove('hidden'); 348 if (ownerNameEl) { 349 ownerNameEl.classList.remove('hidden'); 350 ownerNameEl.textContent = isEn ? 'Your Personal Library' : 'Tu Biblioteca Particular'; 351 ownerNameEl.className = 'text-indigo-600 dark:text-indigo-400 font-bold ml-0.5'; 352 } 353 354 // Quitar botones de delegar, recuperar y solicitar de Firestore 355 if (delegateBtn) delegateBtn.remove(); 356 if (reclaimBtn) reclaimBtn.remove(); 357 if (requestOwnerBtn) requestOwnerBtn.remove(); 358 359 // Ocultar selector de versiones y estrellas comunitarias 360 document.getElementById('version-selector-container')?.classList.add('hidden'); 361 document.getElementById('star-rating-display')?.classList.add('hidden'); 362 363 // Botón Guardar en Particular (Siempre habilitado) 364 if (saveBtn) { 365 saveBtn.disabled = false; 366 saveBtn.className = 'px-2.5 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 cursor-pointer'; 367 saveBtn.title = isEn ? 'Save changes to your personal library' : 'Guardar cambios de este Ãtem en tu biblioteca particular'; 368 saveBtn.innerHTML = `<span>ð¾</span> <span>${isEn ? 'Save' : 'Grabar'}</span>`; 369 } 370 371 // Botón Borrar en Particular (Siempre habilitado) 372 if (deleteBtn) { 373 deleteBtn.disabled = false; 374 deleteBtn.className = 'px-2.5 py-1 bg-rose-600 hover:bg-rose-700 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 cursor-pointer'; 375 deleteBtn.title = isEn ? 'Delete this item from your personal library' : 'Eliminar este Ãtem de tu biblioteca particular'; 376 deleteBtn.innerHTML = `<span>ðï¸</span> <span>${isEn ? 'Delete' : 'Borrar'}</span>`; 377 } 378 379 } else { 380 // ==================== MODO OFICIAL ==================== 381 let officialOwner = this.officialOwnerId; 382 let officialId = this.currentSongId; 383 384 if (nodeType === 'playlist') { 385 officialOwner = 'admin'; 386 officialId = nodeName; 387 this.officialOwnerId = 'admin'; 388 this.currentSongId = nodeName; 389 } else if (nodeType && nodeName && window.findNodeInLibrary) { 390 const libNode = window.findNodeInLibrary(nodeType, nodeName, parentName, grandparentName); 391 if (libNode) { 392 officialOwner = libNode.ownerId || 'admin'; 393 officialId = libNode.id || null; 394 this.officialOwnerId = officialOwner; 395 this.currentSongId = officialId; 396 } 397 } 398 this.currentOwnerId = officialOwner; 399 400 const auth = getFirebaseAuth(); 401 const currentUser = window.currentUser || auth?.currentUser; 402 const currentUid = currentUser?.uid; 403 404 const isAdminEmail = checkAdminEmail; 405 const isUserActuallyTheAdmin = Boolean(currentUid && (currentUid === 'admin' || isAdminEmail(currentUser?.email))); 406 const isOwner = Boolean(currentUid && officialOwner && currentUid === officialOwner); 407 const isAdminNode = officialOwner === 'admin'; 408 const currentUserName = currentUser?.displayName || currentUser?.email?.split('@')[0] || (isEn ? 'You' : 'Tú'); 409 410 if (isSong) { 411 // Para CANCIONES: La autorÃa pertenece a cada versión, no se muestra "Dueño: ..." en la barra superior
412 if (nodeLabelEl) { 413 nodeLabelEl.textContent = nodeName ? `${isEn ? 'Song' : 'Canción'} - "${nodeName}"` : (isEn ? 'Song' : 'Canción'); 414 } 415 if (ownerLabelEl) ownerLabelEl.classList.add('hidden'); 416 if (ownerNameEl) ownerNameEl.classList.add('hidden'); 417 418 if (delegateBtn) delegateBtn.remove(); 419 if (reclaimBtn) reclaimBtn.remove(); 420 if (requestOwnerBtn) requestOwnerBtn.remove(); 421 } else { 422 // Para BANDAS, ÃLBUMES y PLAYLISTS: Mostrar claramente el gestor/dueño de la rama 423 let labelType = isEn ? 'Song' : 'Canción'; 424 if (nodeTypeLower === 'playlist') labelType = 'Playlist'; 425 else if (nodeTypeLower === 'band') labelType = isEn ? 'Band' : 'Banda'; 426 else if (nodeTypeLower === 'album') labelType = 'Album'; 427 428 if (nodeLabelEl) { 429 nodeLabelEl.textContent = nodeName ? `${labelType} - "${nodeName}" |` : `${labelType} |`; 430 } 431 if (ownerLabelEl) ownerLabelEl.classList.remove('hidden'); 432 if (ownerNameEl) ownerNameEl.classList.remove('hidden'); 433 434 if (isOwner) { 435 const suffix = isUserActuallyTheAdmin ? (isEn ? ' (You - Admin)' : ' (Tú - Admin)') : (isEn ? ' (You)' : ' (Tú)'); 436 ownerNameEl.textContent = `${currentUserName}${suffix}`; 437 ownerNameEl.className = 'text-green-600 dark:text-green-400 font-bold ml-0.5'; 438 } else if (isAdminNode) { 439 if (isUserActuallyTheAdmin) { 440 ownerNameEl.textContent = `${currentUserName}${isEn ? ' (You - Admin)' : ' (Tú - Admin)'}`; 441 ownerNameEl.className = 'text-green-600 dark:text-green-400 font-bold ml-0.5'; 442 } else { 443 ownerNameEl.textContent = 'Admin'; 444 ownerNameEl.className = 'text-blue-600 dark:text-blue-400 font-bold ml-0.5'; 445 } 446 } else if (officialOwner) { 447 ownerNameEl.className = 'text-indigo-600 dark:text-indigo-400 font-bold ml-0.5'; 448 const ownerStr = String(officialOwner); 449 ownerNameEl.textContent = `ID: ${ownerStr.substring(0, 8)}`; 450 451 if (!this.userNamesCache) this.userNamesCache = {}; 452 if (this.userNamesCache[officialOwner]) { 453 const name = this.userNamesCache[officialOwner]; 454 const isSelf = (currentUid === officialOwner); 455 ownerNameEl.textContent = isSelf ? `${name}${isEn ? ' (You)' : ' (Tú)'}` : name; 456 } else { 457 import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js").then(({ doc, getDoc }) => { 458 const db = getDb(); 459 if (!db) return; 460 getDoc(doc(db, 'users', ownerStr)).then(uSnap => { 461 if (uSnap.exists() && uSnap.data().displayName) { 462 const name = uSnap.data().displayName; 463 this.userNamesCache[officialOwner] = name; 464 if (this.currentOwnerId === officialOwner) { 465 const isSelf = (currentUid === officialOwner); 466 ownerNameEl.textContent = isSelf ? `${name}${isEn ? ' (You)' : ' (Tú)'}` : name; 467 } 468 } else { 469 this.userNamesCache[officialOwner] = `ID: ${ownerStr.substring(0, 8)}`; 470 } 471 }).catch(e => console.error("Error fetching user info:", e)); 472 }); 473 } 474 } else { 475 ownerNameEl.textContent = isEn ? 'Unregistered (New)' : 'Sin registrar (Nuevo)'; 476 ownerNameEl.className = 'text-gray-500 dark:text-gray-400 font-medium ml-0.5'; 477 } 478 } 479 480 // Botones de Delegar / Recuperar / Solicitar en Oficial 481 const nodeTypeLower = (nodeType || 'song').toLowerCase(); 482 483 let isParentBandOwner = false;
484 if (nodeTypeLower === 'album' && parentName && typeof window.findNodeInLibrary === 'function' && currentUid) { 485 const parentBand = window.findNodeInLibrary('band', parentName); 486 if (parentBand && (parentBand.ownerId === currentUid || (parentBand.ownerId === 'admin' && isUserActuallyTheAdmin))) { 487 isParentBandOwner = true; 488 } 489 } 490 491 const canReclaim = (isUserActuallyTheAdmin || isParentBandOwner) && !isOwner && officialOwner; 492 493 if (isOwner && (nodeTypeLower === 'band' || nodeTypeLower === 'album') && officialId) { 494 if (requestOwnerBtn) requestOwnerBtn.remove(); 495 if (reclaimBtn) reclaimBtn.remove(); 496 if (!delegateBtn) { 497 delegateBtn = document.createElement('button'); 498 delegateBtn.id = 'delegate-branch-btn'; 499 delegateBtn.className = 'ml-1.5 text-[9px] bg-indigo-100 text-indigo-700 dark:bg-indigo-900/60 dark:text-indigo-300 px-1.5 py-0.5 rounded hover:bg-indigo-200 dark:hover:bg-indigo-800 transition font-bold cursor-pointer'; 500 badgeEl.appendChild(delegateBtn); 501 } 502 delegateBtn.textContent = isEn ? 'Delegate' : 'Delegar'; 503 delegateBtn.title = isEn ? 'Delegate this branch and its sub-items' : 'Delegar esta rama y todos sus elementos hijos'; 504 delegateBtn.onclick = () => this.delegateBranch(nodeTypeLower, officialId); 505 } else if (canReclaim && (nodeTypeLower === 'band' || nodeTypeLower === 'album') && officialId) { 506 if (requestOwnerBtn) requestOwnerBtn.remove(); 507 if (!reclaimBtn) { 508 reclaimBtn = document.createElement('button'); 509 reclaimBtn.id = 'reclaim-branch-btn'; 510 reclaimBtn.className = 'ml-1.5 text-[9px] bg-rose-100 text-rose-700 dark:bg-rose-900/60 dark:text-rose-300 px-1.5 py-0.5 rounded hover:bg-rose-200 dark:hover:bg-rose-800 transition font-bold cursor-pointer'; 511 badgeEl.appendChild(reclaimBtn); 512 } 513 reclaimBtn.textContent = isEn ? 'Reclaim' : 'Recuperar'; 514 reclaimBtn.title = isEn ? 'Reclaim ownership of this branch and all its child albums/songs' : 'Recuperar la propiedad de esta rama y todos sus álbumes/canciones'; 515 reclaimBtn.onclick = () => this.reclaimBranch(nodeTypeLower, officialId, nodeName); 516 517 if (isUserActuallyTheAdmin) { 518 if (!delegateBtn) { 519 delegateBtn = document.createElement('button'); 520 delegateBtn.id = 'delegate-branch-btn'; 521 delegateBtn.className = 'ml-1.5 text-[9px] bg-indigo-100 text-indigo-700 dark:bg-indigo-900/60 dark:text-indigo-300 px-1.5 py-0.5 rounded hover:bg-indigo-200 dark:hover:bg-indigo-800 transition font-bold cursor-pointer'; 522 badgeEl.appendChild(delegateBtn); 523 } 524 delegateBtn.textContent = isEn ? 'Delegate' : 'Delegar'; 525 delegateBtn.title = isEn ? 'Delegate this branch and its sub-items' : 'Delegar esta rama y todos sus elementos hijos'; 526 delegateBtn.onclick = () => this.delegateBranch(nodeTypeLower, officialId); 527 } else if (delegateBtn) { 528 delegateBtn.remove(); 529 } 530 } else if (!isOwner && !canReclaim && (nodeTypeLower === 'band' || nodeTypeLower === 'album') && officialId && currentUser) { 531 if (delegateBtn) delegateBtn.remove(); 532 if (reclaimBtn) reclaimBtn.remove(); 533 if (!requestOwnerBtn) { 534 requestOwnerBtn = document.createElement('button'); 535 requestOwnerBtn.id = 'request-ownership-btn'; 536 requestOwnerBtn.className = 'ml-1.5 text-[9px] bg-amber-100 text-amber-800 dark:bg-amber-900/60 dark:text-amber-300 px-1.5 py-0.5 rounded hover:bg-amber-200 dark:hover:bg-amber-800 transition font-bold cursor-pointer'; 537 badgeEl.appendChild(requestOwnerBtn); 538 } 539 requestOwnerBtn.textContent = isEn ? 'Request' : 'Solicitar'; 540 requestOwnerBtn.title = isEn ? 'Request ownership of this branch' : 'Solicitar propiedad de esta rama'; 541 requestOwnerBtn.onclick = () => this.handleRequestOwnership(nodeTypeLower, officialId, nodeName); 542 } else { 543 if (delegateBtn) delegateBtn.remove(); 544 if (reclaimBtn) reclaimBtn.remove(); 545 if (requestOwnerBtn) requestOwnerBtn.remove(); 546 } 547 548 // Mostrar selector de versiones y estrellas en oficial si es canción registrada 549 if (nodeTypeLower === 'song' && officialId) { 550 document.getElementById('version-selector-container')?.classList.remove('hidden'); 551 document.getElementById('star-rating-display')?.classList.remove('hidden'); 552 } else { 553 document.getElementById('version-selector-container')?.classList.add('hidden'); 554 document.getElementById('star-rating-display')?.classList.add('hidden'); 555 } 556 557 // Permisos de Guardar y Borrar en Oficial 558 let isAuthorOfActiveVersion = false;
559 let userHasExistingVersion = false; 560 if (nodeTypeLower === 'song') { 561 const currentVerId = document.getElementById('version-select')?.value || this.currentMasterVersionId; 562 const currentVer = this.versions?.find(v => v.id === currentVerId) || this.versions?.[0]; 563 const verAuthorUid = currentVer?.authorId || currentVer?.ownerId || currentVer?.userId; 564 if (currentUid && (verAuthorUid === currentUid || (verAuthorUid === 'admin' && isUserActuallyTheAdmin))) { 565 isAuthorOfActiveVersion = true; 566 } 567 userHasExistingVersion = !!(currentUid && this.versions?.some(v => (v.authorId === currentUid || v.ownerId === currentUid || v.userId === currentUid))); 568 } 569 570 const hasWritePermission = (isUserActuallyTheAdmin || isOwner || isAuthorOfActiveVersion || userHasExistingVersion); 571 const canDeletePermission = (isUserActuallyTheAdmin || isOwner || isAuthorOfActiveVersion); 572 573 if (saveBtn) { 574 if (hasWritePermission) { 575 saveBtn.disabled = false; 576 saveBtn.className = 'px-2.5 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 cursor-pointer'; 577 saveBtn.title = isAuthorOfActiveVersion 578 ? (isEn ? 'Save / update changes to your version' : 'Guardar y actualizar cambios en tu versión') 579 : (isEn ? 'Save changes directly to the official catalog' : 'Guardar cambios directamente en el catálogo oficial'); 580 saveBtn.innerHTML = `<span>ð¾</span> <span>${isEn ? 'Save' : 'Grabar'}</span>`; 581 } else if (currentUser) { 582 saveBtn.disabled = false; 583 saveBtn.className = 'px-2.5 py-1 bg-amber-600 hover:bg-amber-700 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 cursor-pointer'; 584 if (nodeTypeLower === 'song') { 585 saveBtn.title = isEn ? 'Propose a new chord version (or switch to Personal Library to save privately)' : 'Proponer una nueva versión de acordes (o cambia a Biblioteca Particular para guardarla en privado)'; 586 saveBtn.innerHTML = `<span>ð¡</span> <span>${isEn ? 'Propose version' : 'Proponer versión'}</span>`; 587 } else { 588 saveBtn.title = isEn ? 'Submit branch proposal to owner/admin (or switch to Personal Library to save privately)' : 'Enviar propuesta al dueño/admin (o cambia a Biblioteca Particular para guardarla en privado)'; 589 saveBtn.innerHTML = `<span>ð¡</span> <span>${isEn ? 'Propose' : 'Proponer'}</span>`; 590 } 591 } else { 592 saveBtn.disabled = false; 593 saveBtn.className = 'px-2.5 py-1 bg-gray-400 dark:bg-gray-600 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 opacity-70 cursor-pointer'; 594 saveBtn.title = isEn ? 'Log in with Google to propose in the official catalog (or switch to Personal Library)' : 'Inicia sesión con Google para el catálogo oficial (o cambia a Biblioteca Particular)'; 595 saveBtn.innerHTML = `<span>ð</span> <span>${isEn ? 'Save' : 'Grabar'}</span>`; 596 } 597 } 598 599 // Permisos de Borrar en Oficial 600 if (deleteBtn) { 601 if (canDeletePermission && officialId) { 602 deleteBtn.disabled = false; 603 deleteBtn.className = 'px-2.5 py-1 bg-rose-600 hover:bg-rose-700 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 cursor-pointer'; 604 deleteBtn.title = isAuthorOfActiveVersion 605 ? (isEn ? 'Delete your version permanently' : 'Eliminar tu versión permanentemente') 606 : (isEn ? 'Delete this branch permanently from the official catalog' : 'Eliminar esta rama permanentemente del catálogo oficial'); 607 deleteBtn.innerHTML = `<span>ðï¸</span> <span>${isEn ? 'Delete' : 'Borrar'}</span>`;
608 } else { 609 deleteBtn.disabled = false; 610 deleteBtn.className = 'px-2.5 py-1 bg-gray-400 dark:bg-gray-600 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 opacity-60 cursor-pointer'; 611 deleteBtn.title = isEn ? 'No permission to delete from the official catalog (only owner or admin)' : 'Sin permisos para borrar del catálogo oficial (solo dueño o admin)'; 612 deleteBtn.innerHTML = `<span>ðï¸</span> <span>${isEn ? 'Delete' : 'Borrar'}</span>`; 613 } 614 } 615 } 616 } 617 618 async handleSaveNode() { 619 const rawText = document.getElementById('chordInput')?.value || ''; 620 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 621 622 if (!rawText || !rawText.trim()) { 623 if (window.showToast) window.showToast(isEn ? "The editor is empty or has no valid content." : "El editor está vacÃo o no contiene formato válido.", "warning"); 624 return; 625 } 626 627 const isPersonal = (window.personalLibraryManager && window.personalLibraryManager.activeView === 'personal') || 628 (sessionStorage.getItem('chordlib_active_tab') === 'personal'); 629 const isPlaylistMode = (window.playlistManager && window.playlistManager.activeMode === 'playlist') || 630 (sessionStorage.getItem('chordlib_active_mode') === 'playlist'); 631 632 let nodeType = (window._activeViewerSelection?.type || this.currentNodeType || '').toLowerCase(); 633 let nodeName = window._activeViewerSelection?.name || this.currentNodeName; 634 let parentName = window._activeViewerSelection?.parentName || this.parentName || null; 635 let grandparentName = window._activeViewerSelection?.grandparentName || this.grandparentName || null; 636 const matchPlaylist = rawText.match(/^\s*\(Playlist\)\s*(.*)/im); 637 638 if (!nodeName && matchPlaylist) { 639 nodeType = isPlaylistMode ? 'playlist' : 'song'; 640 nodeName = matchPlaylist[1].trim(); 641 } else if (!nodeName && window.parsedData && window.parsedData.length > 0) { 642 nodeType = (window.parsedData[0].type || (isPlaylistMode ? 'playlist' : 'band')).toLowerCase(); 643 nodeName = window.parsedData[0].name; 644 } 645 646 this.currentNodeType = nodeType; 647 this.currentNodeName = nodeName; 648 this.parentName = parentName; 649 this.grandparentName = grandparentName; 650 651 // 0. MODO PLAYLIST: Grabar la playlist completa 652 if (isPlaylistMode) { 653 const playlistName = (matchPlaylist && matchPlaylist[1].trim()) || (nodeType === 'playlist' ? nodeName : 'Playlist'); 654 if (isPersonal) { 655 if (window.playlistManager) { 656 if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? `Saving playlist "${playlistName}"â¦` : `Guardando playlist "${playlistName}"â¦`); 657 try { 658 if (typeof window.playlistManager.saveSinglePlaylist === 'function') { 659 await window.playlistManager.saveSinglePlaylist(playlistName, rawText, true); 660 } else { 661 await window.playlistManager.savePersonalPlaylists(rawText); 662 } 663 window.isDirty = false; 664 window._lastCleanEditorText = document.getElementById('chordInput')?.value || rawText; 665 if (window.showToast) window.showToast(isEn ? `Playlist "${playlistName}" saved to your personal library!` : `¡Playlist "${playlistName}" guardada en tu biblioteca particular!`, "success"); 666 } finally { 667 if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal(); 668 } 669 } 670 } else { 671 const auth = getFirebaseAuth(); 672 const currentUser = auth?.currentUser; 673 const isAdminEmail = checkAdminEmail; 674 const isSuperAdmin = (currentUser?.uid === 'admin' || isAdminEmail(currentUser?.email)); 675 if (!isSuperAdmin) { 676 if (window.showToast) window.showToast(isEn ? "Only administrators can save official playlists." : "Solo los administradores pueden guardar playlists en el catálogo oficial.", "warning"); 677 return; 678 } 679 if (window.playlistManager) { 680 if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? `Saving playlist "${playlistName}"â¦` : `Guardando playlist "${playlistName}"â¦`); 681 try { 682 if (typeof window.playlistManager.saveSinglePlaylist === 'function') { 683 await window.playlistManager.saveSinglePlaylist(playlistName, rawText, false); 684 } else { 685 await window.playlistManager.saveOfficialPlaylists(rawText, playlistName); 686 } 687 window.isDirty = false;
688 window._lastCleanEditorText = document.getElementById('chordInput')?.value || rawText; 689 if (window.showToast) window.showToast(isEn ? `Playlist "${playlistName}" saved to official catalog!` : `¡Playlist "${playlistName}" guardada en el catálogo oficial!`, "success"); 690 } finally { 691 if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal(); 692 } 693 } 694 } 695 return; 696 } 697 698 // 1. MODO CANCIÃN: Guardar canción, banda o álbum 699 const isFromPlaylist = !!matchPlaylist || (grandparentName === 'Playlist') || (/^[^\(]+\([^\)]+\)$/.test(nodeName || '')) || (!/^\s*\(Band\)/im.test(rawText)); 700 701 const extractBodyForActiveSong = () => { 702 let body = ''; 703 if (window.parsedData && window.parsedData.length > 0) { 704 const selIdx = window._activeViewerSelection?.globalIndex; 705 let currentGlobalSongIndex = 0; 706 for (const band of window.parsedData) { 707 const isPlaylist = band.type === 'Playlist' || !band.albums; 708 const albumsList = isPlaylist 709 ? [{ name: band.name, isPlaylistContainer: true, songs: band.songs || [] }] 710 : (band.albums || []); 711 for (const album of albumsList) { 712 const songList = album.songs || []; 713 for (let i = 0; i < songList.length; i++) { 714 const s = songList[i]; 715 if ((selIdx !== undefined && selIdx !== null && currentGlobalSongIndex === selIdx) || 716 (nodeName && s.name && s.name.toLowerCase() === nodeName.toLowerCase())) { 717 if (window.playlistManager && typeof window.playlistManager.extractSongBody === 'function') { 718 body = window.playlistManager.extractSongBody(s); 719 } 720 if (s.name && (!nodeName || nodeName === 'Playlist')) nodeName = s.name; 721 break; 722 } 723 currentGlobalSongIndex++; 724 } 725 if (body) break; 726 } 727 if (body) break; 728 } 729 } 730 if (!body) body = rawText; 731 return body; 732 }; 733 734 if (isPersonal) { 735 // ==================== MODO PARTICULAR ==================== 736 // En biblioteca particular NO hay gestión de versiones: se guarda/actualiza directamente la canción 737 if (nodeType === 'song' || isFromPlaylist) { 738 const songBody = extractBodyForActiveSong(); 739 const deduced = (typeof window.deduceSongTarget === 'function') 740 ? window.deduceSongTarget(nodeName, songBody, true, grandparentName, parentName) 741 : { songTitle: nodeName, bandName: grandparentName || 'Mis Canciones', albumName: parentName || 'Singles', body: songBody, isExisting: false }; 742 743 const destName = isEn ? 'Personal Library' : 'Biblioteca Particular'; 744 const actionLabel = deduced.isExisting 745 ? (isEn ? 'update this song' : 'actualizar esta canción') 746 : (isEn ? 'save this song' : 'guardar esta canción'); 747 748 const msg = isEn 749 ? `Do you want to ${actionLabel} in your ${destName}?\n\n⢠Song: "${deduced.songTitle}"\n⢠Artist / Band: "${deduced.bandName}"\n⢠Album: "${deduced.albumName}"` 750 : `¿Deseas ${actionLabel} en tu ${destName}?\n\n⢠Canción: "${deduced.songTitle}"\n⢠Artista / Banda: "${deduced.bandName}"\n⢠Ãlbum: "${deduced.albumName}"`; 751 752 if (typeof window.openConfirmModal === 'function') { 753 window.openConfirmModal(msg, async () => { 754 if (window.personalLibraryManager) { 755 if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? "Saving to personal libraryâ¦" : "Guardando en b
755iblioteca particularâ¦"); 756 try { 757 const formattedSongText = `(Band) ${deduced.bandName}\n(Album) ${deduced.albumName}\n(Song) ${deduced.songTitle}\n${deduced.body}\n`; 758 await window.personalLibraryManager.mergeSongIntoPersonal(formattedSongText); 759 window.isDirty = false; 760 window._lastCleanEditorText = document.getElementById('chordInput')?.value || rawText; 761 if (typeof window.personalLibraryManager.renderActiveLibrary === 'function') { 762 window.personalLibraryManager.renderActiveLibrary(); 763 } 764 if (window.showToast) window.showToast(isEn ? `Song "${deduced.songTitle}" saved to your Personal Library!` : `¡Canción "${deduced.songTitle}" guardada en tu Biblioteca Particular!`, "success"); 765 } finally { 766 if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal(); 767 } 768 } 769 }, isEn ? 'Confirm Save' : 'Confirmar Guardar Canción', isEn ? 'Save' : 'Grabar'); 770 } 771 return; 772 } 773 774 if (window.personalLibraryManager) { 775 if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? "Saving to personal libraryâ¦" : "Guardando en biblioteca particularâ¦"); 776 try { 777 await window.personalLibraryManager.mergeSongIntoPersonal(rawText); 778 window.isDirty = false; 779 window._lastCleanEditorText = document.getElementById('chordInput')?.value || rawText; 780 if (typeof window.personalLibraryManager.renderActiveLibrary === 'function') { 781 window.personalLibraryManager.renderActiveLibrary(); 782 } 783 if (window.showToast) window.showToast(isEn ? "Changes saved to your Personal Library!" : "¡Cambios guardados en tu Biblioteca Particular!", "success"); 784 } finally { 785 if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal(); 786 } 787 } 788 return; 789 } 790 791 // 2. MODO OFICIAL (FIRESTORE) 792 const auth = getFirebaseAuth(); 793 const currentUser = auth?.currentUser; 794 if (!currentUser) { 795 if (window.showToast) window.showToast("Debes iniciar sesión con Google para guardar en el catálogo oficial.", "info"); 796 return; 797 } 798 799 const isAdminEmail = checkAdminEmail; 800 const isSuperAdmin = (currentUser.uid === 'admin' || isAdminEmail(currentUser.email)); 801 const isOwner = (currentUser.uid === this.currentOwnerId); 802 803 // Si es canción y proviene de playlist o tÃtulo deducible 804 if (nodeType === 'song' && isFromPlaylist) { 805 const songBody = extractBodyForActiveSong(); 806 const deduced = (typeof window.deduceSongTarget === 'function') 807 ? window.deduceSongTarget(nodeName, songBody, false, grandparentName, parentName) 808 : { songTitle: nodeName, bandName: grandparentName || 'Mis Canciones', albumName: parentName || 'Singles', body: songBody, isExisting: false }; 809 810 const formattedSongText = `(Band) ${deduced.bandName}\n(Album) ${deduced.albumName}\n(Song) ${deduced.songTitle}\n${deduced.body}\n`; 811 812 if (isSuperAdmin || isOwner) { 813 const msg = isEn 814 ? `Do you want to save changes for "${deduced.songTitle}" directly to the Official Catalog?\n\n⢠Song: "${deduced.songTitle}"\n⢠Band: "${deduced.bandName}"\n⢠Album: "${deduced.albumName}"` 815 : `¿Deseas guardar los cambios de "${deduced.songTitle}" directamente en el Catálogo Oficial?\n\n⢠Canción: "${deduced.songTitle}"\n⢠Banda: "${deduced.bandName}"\n⢠Ãlbum: "${deduced.albumName}"`; 816 817 if (typeof window.openConfirmModal === 'function') { 818 window.openConfirmModal(msg, () => { 819 const chordInput = document.getElementById('chordInput'); 820 if (chordInput) chordInput.value = formattedSongText; 821 if (typeof window.startOfficialCloudUpload === 'function') { 822 window.startOfficialCloudUpload(); 823 } 824 }, isEn ? 'Confirm Save' : 'Confirmar Guardar', isEn ? 'Save' : 'Grabar'); 825 } 826 return; 827 } else { 828 const msg = isEn
829 ? `Do you want to submit a version proposal for "${deduced.songTitle}" to the Official Catalog?\n\n⢠Song: "${deduced.songTitle}"\n⢠Band: "${deduced.bandName}"\n⢠Album: "${deduced.albumName}"` 830 : `¿Deseas enviar una propuesta de versión para "${deduced.songTitle}" al Catálogo Oficial?\n\n⢠Canción: "${deduced.songTitle}"\n⢠Banda: "${deduced.bandName}"\n⢠Ãlbum: "${deduced.albumName}"`; 831 832 if (typeof window.openConfirmModal === 'function') { 833 window.openConfirmModal(msg, async () => { 834 await this.submitProposal(deduced.isExisting ? 'edit_song' : 'new_song', formattedSongText); 835 }, isEn ? 'Submit Proposal' : 'Enviar Propuesta', isEn ? 'Submit' : 'Proponer'); 836 } 837 return; 838 } 839 } 840 841 // 2.A: SuperAdmin o Dueño de la rama -> Sube directo con resumen de cambios 842 if (isSuperAdmin || isOwner) { 843 if (typeof window.startOfficialCloudUpload === 'function') { 844 window.startOfficialCloudUpload(); 845 } 846 return; 847 } 848 849 // 2.B: BANDA y NO es SuperAdmin/Dueño -> Diálogo de propuesta de modificaciones 850 if (nodeType === 'band') { 851 const isExistingBand = !!this.currentSongId || !!this.officialNodeId || !!this.officialOwnerId || 852 (typeof window.findNodeInLibrary === 'function' && !!window.findNodeInLibrary('band', nodeName)); 853 const msg = isExistingBand 854 ? `¿Deseas enviar las modificaciones de la banda "${nodeName}" como propuesta para que el dueño/administrador las revise?` 855 : `¿Deseas proponer la creación de la nueva banda "${nodeName}" para que el Administrador la integre en el catálogo oficial?`; 856 857 if (typeof window.openConfirmModal === 'function') { 858 window.openConfirmModal(msg, async () => { 859 await this.submitProposal(isExistingBand ? 'edit_band' : 'new_band', rawText); 860 }); 861 } 862 return; 863 } 864 865 // 2.C: ÃLBUM en banda ajena -> Diálogo de propuesta al Dueño 866 if (nodeType === 'album') { 867 const isExistingAlbum = !!this.currentSongId || !!this.officialNodeId || 868 (typeof window.findNodeInLibrary === 'function' && !!window.findNodeInLibrary('album', nodeName, parentName)); 869 const msg = isExistingAlbum 870 ? `¿Deseas enviar las modificaciones del álbum "${nodeName}" como propuesta al dueño para su revisión?` 871 : `¿Deseas proponer este nuevo álbum "${nodeName}" como propuesta para que el dueño de la banda lo integre en su catálogo oficial?`; 872 873 if (typeof window.openConfirmModal === 'function') { 874 window.openConfirmModal(msg, async () => { 875 await this.submitProposal(isExistingAlbum ? 'edit_album' : 'new_album', rawText); 876 }); 877 } 878 return; 879 } 880 881 // 2.D: CANCIÃN en rama ajena -> Guardar como versión (LÃmite estricto de 5 versiones en total, máximo 1 por usuario) 882 if (nodeType === 'song') { 883 if (!this.currentSongId && typeof window.findNodeInLibrary === 'function') { 884 const libSong = window.findNodeInLibrary('song', nodeName, parentName, grandparentName); 885 if (libSong?.id) this.currentSongId = libSong.id; 886 } 887 888 if (!this.currentSongId) { 889 const msg = `¿Deseas proponer la nueva canción "${nodeName}" para que se integre en el catálogo oficial?`; 890 if (typeof window.openConfirmModal === 'function') { 891 window.openConfirmModal(msg, async () => { 892 await this.submitProposal('new_song', rawText); 893 }); 894 } 895 return; 896 } 897 898 const auth = getFirebaseAuth(); 899 const currentUserId = auth?.currentUser?.uid; 900 const existingUserVersion = this.versions && this.versions.find(v => (v.ownerId === currentUserId || v.authorId === currentUserId)); 901 902 // Si el usuario NO tiene aún una versión y ya se alcanzaron las 5 versiones totales, ofrecer propuesta de desborde 903 if (!existingUserVersion && this.versions && this.versions.length >= 5) { 904 const limitMsg = `Se ha alcanzado el lÃmite máximo de 5 versiones para "${nodeName}". ¿Deseas enviarla como propuesta para que el dueño sustituya o archive una de las versiones existentes?`; 905 if (typeof window.openConfirmModal === 'function') { 906 window.openConfirmModal(limitMsg, async () => { 907 await this.submitProposal('overflow_song_version', rawText); 908 }); 909 } else if (window.showToast) { 910 window.showToast(limitMsg, "warning"); 911 } 912 return; 913 } 914 915 await this.submitSongVersion(rawText, existingUserVersion?.id); 916 } 917 } 918 919 async submitProposal(type, rawText) { 920 const db = getDb(); 921 const auth = getFirebaseAuth(); 922 if (!db || !auth?.currentUser) return; 923 924 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 925 if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? "Submitting proposalâ¦" : "Enviando propuestaâ¦"); 926 927 try { 928 const { collection, addDoc, serverTimestamp }
928 = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js"); 929 await addDoc(collection(db, 'proposals'), { 930 type, 931 rawText, 932 targetNodeId: this.currentSongId || null, 933 targetOwnerId: this.currentOwnerId || null, 934 targetNodeType: this.currentNodeType || null, 935 submittedBy: auth.currentUser.uid, 936 submittedByName: auth.currentUser.displayName || auth.currentUser.email, 937 createdAt: serverTimestamp(), 938 status: 'pending' 939 }); 940 941 if (window.showToast) { 942 window.showToast("Propuesta enviada correctamente para revisión.", "success"); 943 } 944 } catch (err) { 945 console.error("Error submitting proposal:", err); 946 if (window.showToast) window.showToast("Error al enviar la propuesta.", "error"); 947 } finally { 948 if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal(); 949 } 950 } 951 952 async submitSongVersion(rawText, existingVersionId = null) { 953 const db = getDb(); 954 const auth = getFirebaseAuth(); 955 if (!db || !auth?.currentUser || !this.currentSongId) return; 956 957 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 958 if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? "Submitting versionâ¦" : "Publicando versiónâ¦"); 959 960 try { 961 const { collection, doc, setDoc, updateDoc, serverTimestamp } = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js"); 962 const authorName = auth.currentUser.displayName || auth.currentUser.email?.split('@')[0] || 'Comunidad'; 963 const currentUserId = auth.currentUser.uid; 964 965 // Determinar si ya existe una versión previa de este usuario 966 let targetVersionId = existingVersionId; 967 if (!targetVersionId && this.versions) { 968 const myVersion = this.versions.find(v => (v.ownerId === currentUserId || v.authorId === currentUserId)); 969 if (myVersion) targetVersionId = myVersion.id; 970 } 971 972 if (targetVersionId) { 973 // Sobrescribir la versión existente del mismo usuario 974 const existingVer = this.versions?.find(v => v.id === targetVersionId); 975 let updateLabel = existingVer?.label || `Realizada por ${authorName}`; 976 if (existingVer?.status === 'approved' && updateLabel.startsWith('Propuesta por')) { 977 updateLabel = updateLabel.replace('Propuesta por', 'Realizada por'); 978 } 979 980 const versionRef = doc(db, 'versions', targetVersionId); 981 await updateDoc(versionRef, { 982 content: rawText, 983 label: updateLabel, 984 ownerId: currentUserId, 985 authorId: currentUserId, 986 updatedAt: serverTimestamp() 987 }); 988 989 if (window.showToast) { 990 window.showToast(isEn ? "Your chord version has been updated!" : "¡Tu versión de acordes ha sido actualizada!", "success"); 991 } 992 } else { 993 // Crear versión única para este usuario 994 const isAdminEmail = checkAdminEmail; 995 const isUserActuallyTheAdmin = (currentUserId === 'admin' || isAdminEmail(auth.currentUser?.email)); 996 const isBranchOwner = !!(this.currentOwnerId && currentUserId === this.currentOwnerId); 997 const canAutoApprove = (isUserActuallyTheAdmin || isBranchOwner); 998 999 const versionId = `${this.currentSongId}_contrib_${currentUserId}`; 1000 const versionRef = doc(db, 'versions', versionId); 1001 1002 await setDoc(versionRef, { 1003 id: versionId, 1004 songId: this.currentSongId, 1005 content: rawText, 1006 label: canAutoApprove ? `Realizada por ${authorName}` : `Propuesta por ${authorName}`, 1007 ownerId: currentUserId, 1008 authorId: currentUserId,
1009 status: canAutoApprove ? 'approved' : 'pending', 1010 starsAvg: 3.0, 1011 ratingsCount: 1, 1012 createdAt: serverTimestamp() 1013 }, { merge: true }); 1014 1015 if (window.showToast) { 1016 window.showToast(isEn ? (canAutoApprove ? "New chord version published successfully!" : "New chord version submitted as proposal!") : (canAutoApprove ? "¡Nueva versión de acordes publicada con éxito!" : "¡Nueva versión de acordes enviada como propuesta!"), "success"); 1017 } 1018 } 1019 1020 await this.loadSongVersions(this.currentSongId, this.currentOwnerId, this.currentNodeType, this.currentNodeName, this.parentName, this.grandparentName, true); 1021 } catch (err) { 1022 console.error("Error saving version:", err); 1023 if (window.showToast) window.showToast("Error al guardar la versión.", "error"); 1024 } finally { 1025 if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal(); 1026 } 1027 } 1028 1029 async handleDeleteNode() { 1030 const isPersonal = (window.personalLibraryManager && window.personalLibraryManager.activeView === 'personal') || 1031 (sessionStorage.getItem('chordlib_active_tab') === 'personal'); 1032 const isPlaylistMode = (window.playlistManager && window.playlistManager.activeMode === 'playlist') || 1033 (sessionStorage.getItem('chordlib_active_mode') === 'playlist'); 1034 let nodeType = (window._activeViewerSelection?.type || this.currentNodeType || 'song').toLowerCase(); 1035 let nodeName = window._activeViewerSelection?.name || this.currentNodeName; 1036 let parentName = window._activeViewerSelection?.parentName || this.parentName || null; 1037 let grandparentName = window._activeViewerSelection?.grandparentName || this.grandparentName || null; 1038 1039 const rawText = document.getElementById('chordInput')?.value || ''; 1040 const matchPlaylist = rawText.match(/^\s*\(Playlist\)\s*(.*)/im); 1041 1042 if (!nodeName && matchPlaylist) { 1043 nodeType = isPlaylistMode ? 'playlist' : 'song'; 1044 nodeName = matchPlaylist[1].trim(); 1045 } else if (!nodeName && window.parsedData && window.parsedData.length > 0) { 1046 nodeType = (window.parsedData[0].type || (isPlaylistMode ? 'playlist' : 'band')).toLowerCase(); 1047 nodeName = window.parsedData[0].name; 1048 } 1049 1050 this.currentNodeType = nodeType; 1051 this.currentNodeName = nodeName; 1052 this.parentName = parentName; 1053 this.grandparentName = grandparentName; 1054 1055 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 1056 1057 // 0. CONTEXTO PLAYLIST: distinguir entre borrar una canción de la playlist vs borrar la playlist entera 1058 if (isPlaylistMode) { 1059 const currentPlaylistName = (matchPlaylist && matchPlaylist[1].trim()) || (nodeType === 'playlist' ? nodeName : 'Playlist'); 1060 1061 // Si hay una canción seleccionada dentro de la playlist, eliminar la canción â NO la playlist entera 1062 if (nodeType === 'song' && nodeName) { 1063 const songIndex = window._activeViewerSelection?.globalIndex ?? null; 1064 if (window.playlistManager && typeof window.playlistManager.confirmDeleteSongFromPlaylist === 'function') { 1065 window.playlistManager.confirmDeleteSongFromPlaylist(currentPlaylistName, nodeName, songIndex, isPersonal); 1066 } 1067 return; 1068 } 1069 1070 // Nada concreto seleccionado o el nodo playlist en sà â borrar la playlist entera 1071 const playlistName = (nodeType === 'playlist' ? nodeName : null) || currentPlaylistName; 1072 if (!playlistName) { 1073 if (window.showToast) window.showToast(isEn ? "No playlist selected to delete." : "No se ha seleccionado ninguna playlist para borrar.", "warning"); 1074 return; 1075 } 1076 1077 if (window.playlistManager && typeof window.playlistManager.confirmDeletePlaylist === 'function') { 1078 window.playlistManager.confirmDeletePlaylist(playlistName); 1079 } 1080 return; 1081 } 1082 1083 // 1. MODO PARTICULAR 1084 if (isPersonal) { 1085 if (!nodeName) { 1086 if (window.showToast) window.showToast(isEn ? "Select an item to delete." : "Selecciona una canción o banda para borrar.", "warning"); 1087 return; 1088 } 1089 1090 const typeLabel = nodeType === 'band' ? (isEn ? 'band' : 'banda') : (nodeType === 'album' ? 'álbum' : (isEn ? 'song' : 'canción')); 1091 const msg = isEn 1092 ? `â ï¸ Are you sure you want to delete ${typeLabel} "${nodeName}" from your personal library?`
1093 : `â ï¸ Â¿Seguro que deseas borrar este ${typeLabel} "${nodeName}" de tu biblioteca particular?`; 1094 1095 const onConfirmDeletePersonal = async () => { 1096 let bandName = null, albumName = null, songName = null; 1097 1098 if (nodeType === 'band') { 1099 bandName = nodeName; 1100 } else if (nodeType === 'album') { 1101 bandName = parentName || (window.parsedData && window.parsedData[0]?.name) || null; 1102 albumName = nodeName; 1103 } else { 1104 songName = nodeName; 1105 albumName = parentName || null; 1106 grandparentName = grandparentName || (window.parsedData && window.parsedData[0]?.name) || null; 1107 bandName = grandparentName; 1108 } 1109 1110 if (window.personalLibraryManager) { 1111 if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? "Deleting itemâ¦" : "Eliminando elementoâ¦"); 1112 try { 1113 await window.personalLibraryManager.deleteNodeFromPersonal({ bandName, albumName, songName }); 1114 if (typeof window.personalLibraryManager.renderActiveLibrary === 'function') { 1115 window.personalLibraryManager.renderActiveLibrary(); 1116 } 1117 if (window.showToast) window.showToast(isEn ? "Item deleted from personal library." : "Elemento eliminado de la biblioteca particular.", "info"); 1118 1119 // Si el elemento borrado era la banda completa o la única canción en el visor, limpiar el visor 1120 const chordInput = document.getElementById('chordInput'); 1121 if (chordInput && (nodeType === 'band' || (window.parsedData && window.parsedData.length <= 1))) { 1122 chordInput.value = ''; 1123 window._lastCleanEditorText = ''; 1124 window.isDirty = false; 1125 if (typeof window.processChords === 'function') window.processChords(); 1126 } 1127 } finally { 1128 if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal(); 1129 } 1130 } 1131 }; 1132 1133 if (typeof window.openConfirmModal === 'function') { 1134 window.openConfirmModal(msg, onConfirmDeletePersonal); 1135 } 1136 return; 1137 } 1138 1139 // 2. MODO OFICIAL (FIRESTORE) 1140 const auth = getFirebaseAuth(); 1141 const currentUser = auth?.currentUser; 1142 if (!currentUser) { 1143 if (window.showToast) window.showToast("Debes iniciar sesión para borrar elementos del catálogo oficial.", "warning"); 1144 return; 1145 } 1146 1147 const isAdminEmail = checkAdminEmail; 1148 const isSuperAdmin = (currentUser.uid === 'admin' || isAdminEmail(currentUser.email)); 1149 const isOwner = (currentUser.uid === this.currentOwnerId); 1150 1151 // Si es una canción, comprobar si el usuario es el autor de la versión activa 1152 if (nodeType === 'song') { 1153 const activeVer = this.versions?.find(v => v.id === (document.getElementById('version-select')?.value || this.currentMasterVersionId)) || this.versions?.[0]; 1154 const verAuthorUid = activeVer?.authorId || activeVer?.ownerId || activeVer?.userId; 1155 const isAuthorOfActiveVersion = !!(currentUser && (verAuthorUid === currentUser.uid || (verAuthorUid === 'admin' && isSuperAdmin))); 1156 1157 if (!isSuperAdmin && !isOwner && !isAuthorOfActiveVersion) { 1158 if (window.showToast) window.showToast("No tienes permisos para borrar esta versión del catálogo oficial.", "error"); 1159 return; 1160 } 1161 1162 if (activeVer) { 1163 const msg = isEn ? `â ï¸ Are you sure you want to delete this version ("${activeVer.label || 'Versión'}")?` : `â ï¸ Â¿Seguro que deseas eliminar esta versión ("${activeVer.label || 'Versión'}")?`; 1164 if (typeof window.openConfirmModal === 'function') { 1165 window.openConfirmModal(msg, () => this.rejectProposal(activeVer.id)); 1166 } 1167 return; 1168 } 1169 } 1170 1171 if (!isSuperAdmin && !isOwner) { 1172 if (window.showToast) window.showToast("No tienes permisos para borrar esta rama del catálogo oficial.", "error"); 1173 return; 1174 } 1175 1176 const msg = `â ï¸ ATENCIÃN: Esta acción eliminará permanentemente la rama ${nodeType.toUpperCase()} del catálogo oficial en la nube. ¿Estás seguro?`; 1177 const onConfirmDeleteOfficial = async () => { 1178 const db = getDb(); 1179 if (!db || !this.currentSongId) return; 1180 if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? "Deleting from cloudâ¦" : "Eliminando del catálogo oficialâ¦"); 1181 try { 1182 const { deleteDoc, doc }
1182 = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js"); 1183 const collName = nodeType === 'band' ? 'bands' : (nodeType === 'album' ? 'albums' : 'songs'); 1184 await deleteDoc(doc(db, collName, this.currentSongId)); 1185 1186 if (window.syncManager) await window.syncManager.setMetadata('lastUpdated', 0); 1187 if (typeof window.triggerSync === 'function') await window.triggerSync(true); 1188 1189 if (window.showToast) window.showToast("Rama eliminada del catálogo oficial.", "success"); 1190 } catch (e) { 1191 console.error("Error deleting branch from Firestore:", e); 1192 if (window.showToast) window.showToast("Error al eliminar de Firestore.", "error"); 1193 } finally { 1194 if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal(); 1195 } 1196 }; 1197 1198 if (typeof window.openConfirmModal === 'function') { 1199 window.openConfirmModal(msg, onConfirmDeleteOfficial); 1200 } 1201 } 1202 1203 async handleRequestOwnership(nodeType, nodeId, nodeName, targetOwnerId = null) { 1204 const auth = getFirebaseAuth(); 1205 if (!auth?.currentUser) { 1206 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 1207 if (window.showToast) window.showToast(isEn ? "You must sign in to request branch ownership." : "Debes iniciar sesión para solicitar la propiedad.", "info"); 1208 return; 1209 } 1210 1211 const effectiveNodeId = nodeId || (nodeName ? nodeName.toLowerCase().replace(/\s+/g, '_') : this.currentSongId); 1212 let effectiveOwnerId = targetOwnerId || this.currentOwnerId || this.officialOwnerId; 1213 if (!effectiveOwnerId && typeof window.findNodeInLibrary === 'function') { 1214 const libNode = window.findNodeInLibrary(nodeType, nodeName); 1215 if (libNode && libNode.ownerId) effectiveOwnerId = libNode.ownerId; 1216 } 1217 effectiveOwnerId = effectiveOwnerId || 'admin'; 1218 1219 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 1220 const typeLabel = (nodeType === 'band') ? (isEn ? 'band' : 'banda') : (isEn ? 'album' : 'álbum'); 1221 const msg = isEn 1222 ? `Do you want to send a request to the current owner to transfer ownership and management of ${typeLabel} "${nodeName || ''}" to you?` 1223 : `¿Deseas enviar una solicitud al dueño actual para que te transfiera la propiedad de la ${typeLabel} "${nodeName || ''}"?`; 1224 1225 if (typeof window.openConfirmModal === 'function') { 1226 window.openConfirmModal(msg, async () => { 1227 const db = getDb(); 1228 if (!db) return; 1229 try { 1230 const { collection, addDoc, serverTimestamp } = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js"); 1231 await addDoc(collection(db, 'ownership_requests'), { 1232 nodeId: effectiveNodeId, 1233 nodeType, 1234 nodeName, 1235 targetOwnerId: effectiveOwnerId, 1236 requesterUid: auth.currentUser.uid, 1237 requesterName: auth.currentUser.displayName || auth.currentUser.email, 1238 status: 'pending', 1239 createdAt: serverTimestamp() 1240 }); 1241 1242 if (window.showToast) window.showToast(isEn ? "Ownership request sent to owner." : "Solicitud de propiedad enviada al dueño.", "success"); 1243 } catch (e) { 1244 console.error("Error requesting ownership:", e); 1245 if (window.showToast) window.showToast(isEn ? "Error requesting ownership." : "Error al solicitar propiedad.", "error"); 1246 } 1247 }); 1248 } 1249 } 1250 1251 async updateBranchOwnershipRecursively(nodeType, nodeId, newOwnerId, isReclaim = false, reclaimerName = null, branchDisplayName = null) { 1252 const db = getDb(); 1253 if (!db) return; 1254 1255 const { doc, collection, query, where, getDocs, getDoc, writeBatch }
1255 = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js"); 1256 1257 let batch = writeBatch(db); 1258 let count = 0; 1259 const evictedUids = new Set(); 1260 const collectionName = nodeType === 'band' ? 'bands' : (nodeType === 'album' ? 'albums' : 'songs'); 1261 1262 if (isReclaim) { 1263 try { 1264 const rootDocSnap = await getDoc(doc(db, collectionName, nodeId)); 1265 if (rootDocSnap.exists()) { 1266 const prevOwner = rootDocSnap.data()?.ownerId; 1267 if (prevOwner && prevOwner !== newOwnerId && prevOwner !== 'admin') { 1268 evictedUids.add(prevOwner); 1269 } 1270 } 1271 } catch (e) { 1272 console.warn("Could not check root doc owner before reclaim:", e); 1273 } 1274 } 1275 1276 const commitBatchIfNeeded = async () => { 1277 if (count >= 400) { 1278 await batch.commit(); 1279 batch = writeBatch(db); 1280 count = 0; 1281 } 1282 }; 1283 1284 if (nodeType === 'band') { 1285 // 1. Update band doc 1286 batch.update(doc(db, 'bands', nodeId), { ownerId: newOwnerId }); 1287 count++; 1288 await commitBatchIfNeeded(); 1289 1290 // 2. Query and update all child albums in this band 1291 try { 1292 const albumsQuery = query(collection(db, 'albums'), where('bandId', '==', nodeId)); 1293 const albumsSnap = await getDocs(albumsQuery); 1294 for (const aDoc of albumsSnap.docs) { 1295 if (isReclaim) { 1296 const prevOwner = aDoc.data()?.ownerId; 1297 if (prevOwner && prevOwner !== newOwnerId && prevOwner !== 'admin') { 1298 evictedUids.add(prevOwner); 1299 } 1300 } 1301 batch.update(aDoc.ref, { ownerId: newOwnerId }); 1302 count++; 1303 await commitBatchIfNeeded(); 1304 } 1305 } catch (e) { 1306 console.warn("Error querying albums for recursive ownership update:", e); 1307 } 1308 1309 // 3. Query and update all child songs in this band 1310 try { 1311 const songsQuery = query(collection(db, 'songs'), where('bandId', '==', nodeId)); 1312 const songsSnap = await getDocs(songsQuery); 1313 for (const sDoc of songsSnap.docs) { 1314 if (isReclaim) { 1315 const prevOwner = sDoc.data()?.ownerId; 1316 if (prevOwner && prevOwner !== newOwnerId && prevOwner !== 'admin') { 1317 evictedUids.add(prevOwner); 1318 } 1319 } 1320 batch.update(sDoc.ref, { ownerId: newOwnerId }); 1321 count++; 1322 await commitBatchIfNeeded(); 1323 } 1324 } catch (e) { 1325 console.warn("Error querying songs for recursive ownership update:", e); 1326 } 1327 } else if (nodeType === 'album') { 1328 // 1. Update album doc 1329 batch.update(doc(db, 'albums', nodeId), { ownerId: newOwnerId }); 1330 count++; 1331 await commitBatchIfNeeded(); 1332 1333 // 2. Query and update all child songs in this album 1334 try { 1335 const songsQuery = query(collection(db, 'songs'), where('albumId', '==', nodeId)); 1336 const songsSnap = await getDocs(songsQuery); 1337 for (const sDoc of songsSnap.docs) { 1338 if (isReclaim) { 1339 const prevOwner = sDoc.data()?.ownerId; 1340 if (prevOwner && prevOwner !== newOwnerId && prevOwner !== 'admin') { 1341 evictedUids.add(prevOwner); 1342 } 1343 } 1344 batch.update(sDoc.ref, { ownerId: newOwnerId }); 1345 count++; 1346 await commitBatchIfNeeded(); 1347 } 1348 } catch (e) { 1349 console.warn("Error querying songs for album recursive ownership update:", e); 1350 } 1351 } else if (nodeType === 'song') { 1352 // Update single song doc 1353 batch.update(doc(db, 'songs', nodeId), { ownerId: newOwnerId }); 1354 count++; 1355 await commitBatchIfNeeded(); 1356 } 1357 1358 // 4. Bump sync_info metadata 1359 batch.set(doc(db, 'metadata', 'sync_info'), { lastUpdated: new Date() }, { merge: true }); 1360 count++; 1361 1362 if (count > 0) { 1363 await batch.commit(); 1364 } 1365 1366 // 5. Send eviction notifications to any users who lost ownership 1367 if (isReclaim && evictedUids.size > 0) { 1368 try { 1369 const { addDoc, serverTimestamp }
1369 = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js"); 1370 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 1371 const typeLabel = nodeType === 'band' ? (isEn ? 'band' : 'banda') : (isEn ? 'album' : 'álbum'); 1372 const authorName = reclaimerName || 'El Administrador'; 1373 const branchName = branchDisplayName || nodeId; 1374 1375 for (const evictedUid of evictedUids) { 1376 await addDoc(collection(db, 'user_notifications'), { 1377 userId: evictedUid, 1378 type: 'branch_reclaimed', 1379 nodeType, 1380 nodeId, 1381 nodeName: branchName, 1382 reclaimedBy: newOwnerId, 1383 reclaimedByName: authorName, 1384 title: isEn ? `Branch reclaimed: "${branchName}"` : `Propiedad de rama recuperada: "${branchName}"`, 1385 message: isEn 1386 ? `The ownership and management of the ${typeLabel} "${branchName}" (and all its sub-items) has been reclaimed by ${authorName}.\n\nYour direct editing access for this branch has ended.` 1387 : `La propiedad y gestión de la ${typeLabel} "${branchName}" (y todos sus álbumes y canciones) ha sido recuperada por ${authorName}.\n\nTus permisos de gestión directa sobre esta rama han finalizado.`, 1388 read: false, 1389 createdAt: serverTimestamp() 1390 }); 1391 } 1392 } catch (e) { 1393 console.warn("Could not save eviction notifications:", e); 1394 } 1395 } 1396 } 1397 1398 async delegateBranch(nodeType, nodeId) { 1399 let effectiveNodeId = nodeId || this.currentSongId; 1400 if (!effectiveNodeId && typeof window.findNodeInLibrary === 'function') { 1401 const libNode = window.findNodeInLibrary(nodeType, this.currentNodeName, this.parentName, this.grandparentName); 1402 if (libNode?.id) effectiveNodeId = libNode.id; 1403 } 1404 if (!effectiveNodeId && this.currentNodeName) { 1405 effectiveNodeId = this.currentNodeName.toLowerCase().replace(/\s+/g, '_'); 1406 } 1407 const nodeName = this.currentNodeName || effectiveNodeId; 1408 1409 const onConfirmDelegation = async (newOwnerId, newOwnerDisplayName) => { 1410 if (!newOwnerId || newOwnerId.trim() === "") return; 1411 1412 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 1413 if (typeof window.showLoadingModal === 'function') { 1414 window.showLoadingModal(isEn ? "Delegating branch ownershipâ¦" : "Delegando propiedad de la rama y contenidosâ¦"); 1415 } 1416 1417 try { 1418 await this.updateBranchOwnershipRecursively(nodeType, effectiveNodeId, newOwnerId.trim(), false, null, nodeName); 1419 1420 // Send notification to the newly assigned owner 1421 try { 1422 const db = getDb(); 1423 const auth = getFirebaseAuth(); 1424 if (db) { 1425 const { addDoc, collection, serverTimestamp } = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js"); 1426 const delegatorName = auth?.currentUser?.displayName || auth?.currentUser?.email || 'El Administrador'; 1427 const delegatorUid = auth?.currentUser?.uid || 'admin'; 1428 const typeLabel = nodeType === 'band' ? (isEn ? 'band' : 'banda') : (isEn ? 'album' : 'álbum'); 1429 1430 await addDoc(collection(db, 'user_notifications'), { 1431 userId: newOwnerId.trim(), 1432 type: 'branch_delegated', 1433 nodeType, 1434 nodeId: effectiveNodeId, 1435 nodeName: nodeName, 1436 delegatedBy: delegatorUid, 1437 delegatedByName: delegatorName, 1438 title: isEn ? `Branch delegated: "${nodeName}"` : `Nueva rama delegada: "${nodeName}"`, 1439 message: isEn 1440 ? `${delegatorName} has delegated the management of the ${typeLabel} "${nodeName}" (and all its child albums/songs) to you.\n\nâ ï¸ Important Notice: This delegation is revocable at any time by the administrator or original owner.` 1441 : `${delegatorName} te ha delegado la gestión de la ${typeLabel} "${nodeName}" (y todos sus álbumes y canciones hijos).\n\nâ ï¸ Aviso importante: Esta cesión es revocable en cualquier momento por el administrador o dueño original.`, 1442 read: false, 1443 createdAt: serverTimestamp() 1444 }); 1445 } 1446 } catch (ne) { 1447 console.warn("Could not save delegation notification:", ne); 1448 } 1449 1450 // Force local sync to update the tree immediately 1451 if (window.syncManager) { 1452 await window.syncManager.setMetadata('lastUpdated', 0); 1453 } 1454 1455 const displayName = newOwnerDisplayName || newOwnerId.substring(0, 8); 1456 const typeLabel = nodeType === 'band' 1457 ? (isEn ? 'band and all its albums/songs' : 'banda y todos sus álbumes y canciones') 1458 : (isEn ? 'album and all its songs' : 'álbum y todas sus canciones'); 1459 const successMsg = isEn 1460 ? `Ownership of ${typeLabel} delegated to ${displayName} successfully.` 1461 : `Propiedad de la ${typeLabel} delegada a ${displayName} con éxito.`; 1462 1463 if (window.showToast) window.showToast(successMsg, 'success'); 1464 1465 // Trigger background sync to refresh UI 1466 if (window.triggerSync) { 1467 await window.triggerSync(true); 1468 } else { 1469 setTimeout(() => location.reload(), 1200); 1470 } 1471 } catch (error) { 1472 console.error("Error delegating branch:", error); 1473 const errMsg = isEn 1474 ? "Error delegating. Check your permissions." 1475 : "Error al delegar. Asegúrate de tener permisos."; 1476 if (window.showToast) window.showToast(errMsg, 'error'); 1477 } finally { 1478 if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal(); 1479 } 1480 }; 1481 1482 if (typeof window.openDelegateModal === 'function') { 1483 window.openDelegateModal(nodeType, effectiveNodeId, nodeName, onConfirmDelegation); 1484 } else { 1485 const title = typeof getLanguage === 'function' && getLanguage() === 'en' ? "Delegate Ownership" : "Delegar Propiedad"; 1486 const placeholder = typeof getLanguage === 'function' && getLanguage() === 'en' ? "Enter new owner UID" : "Introduce el UID del nuevo dueño"; 1487 window.openPromptModal(title, placeholder, onConfirmDelegation); 1488 } 1489 } 1490 1491 async reclaimBranch(nodeType, nodeId, nodeName) { 1492 const auth = getFirebaseAuth(); 1493 if (!auth?.currentUser) { 1494 if (window.showToast) window.showToast("Debes iniciar sesión para recuperar la rama.", "info"); 1495 return; 1496 } 1497 1498 let effectiveNodeId = nodeId || this.currentSongId; 1499 if (!effectiveNodeId && typeof window.findNodeInLibrary === 'function') { 1500 const libNode = window.findNodeInLibrary(nodeType, this.currentNodeName, this.parentName, this.grandparentName); 1501 if (libNode?.id) effectiveNodeId = libNode.id; 1502 } 1503 if (!effectiveNodeId && this.currentNodeName) { 1504 effectiveNodeId = this.currentNodeName.toLowerCase().replace(/\s+/g, '_'); 1505 } 1506 1507 const typeLabel = nodeType === 'band' ? 'Banda' : 'Ãlbum'; 1508 const msg = `¿Deseas recuperar la propiedad de la ${typeLabel} "${nodeName || ''}" y devolver el control recursivo de todos sus álbumes y canciones a tu cuenta?`; 1509 1510 if (typeof window.openConfirmModal === 'function') { 1511 window.openConfirmModal(msg, async () => { 1512 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 1513 if (typeof window.showLoadingModal === 'function') { 1514 window.showLoadingModal(isEn ? "Reclaiming branch ownershipâ¦" : "Recuperando propiedad de la rama y contenidosâ¦"); 1515 } 1516 1517 try { 1518 const reclaimerName = auth.currentUser.displayName || auth.currentUser.email || 'El Administrador'; 1519 // Reclaim ownership to the current user's UID (Admin or Band owner) and notify evicted users 1520 await this.updateBranchOwnershipRecursively(nodeType, effectiveNodeId, auth.currentUser.uid, true, reclaimerName, nodeName); 1521 1522 // Force local sync to update the tree immediately 1523 if (window.syncManager) { 1524 await window.syncManager.setMetadata('lastUpdated', 0); 1525 } 1526 1527 const successMsg = isEn 1528 ? `Branch "${nodeName}" and all child items reclaimed successfully.` 1529 : `Rama "${nodeName}" y todos sus elementos hijos recuperados con éxito.`; 1530 1531 if (window.showToast) window.showToast(successMsg, 'success'); 1532 1533 if (window.triggerSync) { 1534 await window.triggerSync(true); 1535 } else { 1536 setTimeout(() => location.reload(), 1200); 1537 } 1538 } catch (error) { 1539 console.error("Error reclaiming branch:", error); 1540 const errMsg = isEn 1541 ? "Error reclaiming branch. Check your permissions." 1542 : "Error al recuperar la rama. Asegúrate de tener permisos."; 1543 if (window.showToast) window.showToast(errMsg, 'error'); 1544 } finally { 1545 if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal(); 1546 } 1547 }); 1548 } 1549 } 1550 1551 async checkUserNotifications(user) { 1552 if (!user || !user.uid) return; 1553 const db = getDb(); 1554 if (!db) return; 1555 1556 try { 1557 const { collection, query, where, getDocs, updateDoc, doc }
1557 = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js"); 1558 const notifQuery = query( 1559 collection(db, 'user_notifications'), 1560 where('userId', '==', user.uid), 1561 where('read', '==', false) 1562 ); 1563 const snap = await getDocs(notifQuery); 1564 if (!snap.empty) { 1565 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 1566 const notifications = snap.docs.map(d => ({ id: d.id, ...d.data() })); 1567 1568 // Mark them as read in Firestore 1569 for (const notif of notifications) { 1570 try { 1571 await updateDoc(doc(db, 'user_notifications', notif.id), { read: true }); 1572 } catch (e) { 1573 console.warn("Could not mark notification as read:", e); 1574 } 1575 } 1576 1577 // Display notification to the user 1578 if (notifications.length === 1) { 1579 const notif = notifications[0]; 1580 const title = notif.title || (isEn ? "Ownership Notification" : "Aviso de Propiedad"); 1581 if (typeof window.openInfoModal === 'function') { 1582 window.openInfoModal(title, notif.message); 1583 } else if (window.showToast) { 1584 window.showToast(notif.message, notif.type === 'branch_reclaimed' ? 'warning' : 'info'); 1585 } 1586 } else { 1587 const combinedTitle = isEn ? "Ownership Notifications" : "Avisos de Propiedad y Delegación"; 1588 const combinedMessage = notifications.map(n => `ð¢ ${n.title}:\n${n.message}`).join('\n\nââââââââââââââ\n\n'); 1589 if (typeof window.openInfoModal === 'function') { 1590 window.openInfoModal(combinedTitle, combinedMessage); 1591 } else if (window.showToast) { 1592 window.showToast(combinedMessage, 'info'); 1593 } 1594 } 1595 } 1596 } catch (e) { 1597 console.warn("Error checking user notifications:", e); 1598 } 1599 1600 // Always check for pending proposals / unapproved versions 1601 await this.checkPendingApprovals(user); 1602 } 1603 1604 async checkPendingApprovals(user) { 1605 if (!user || !user.uid) return; 1606 const db = getDb(); 1607 if (!db) return; 1608 1609 try { 1610 const { collection, query, where, getDocs, doc, getDoc } = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js"); 1611 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 1612 const pendingList = []; 1613 1614 const isAdminEmail = checkAdminEmail; 1615 const isSuperAdmin = (user.uid === 'admin' || isAdminEmail(user.email)); 1616 1617 // 1. Check pending proposals in 'proposals' collection 1618 try { 1619 const proposalsQ = isSuperAdmin 1620 ? query(collection(db, 'proposals'), where('status', '==', 'pending')) 1621 : query(collection(db, 'proposals'), where('targetOwnerId', '==', user.uid), where('status', '==', 'pending')); 1622 1623 const propSnap = await getDocs(proposalsQ); 1624 propSnap.docs.forEach(d => { 1625 const data = d.data(); 1626 const typeLabel = data.type === 'overflow_song_version' 1627 ? (isEn ? 'Versión extra (cupo 5/5 lleno)' : 'Versión extra (cupo 5/5 lleno)') 1628 : (data.type === 'new_song' ? (isEn ? 'Nueva canción propuesta' : 'Nueva canción propuesta') : (isEn ? 'Propuesta' : 'Propuesta')); 1629 const author = data.submittedByName || 'Comunidad'; 1630 pendingList.push({ 1631 type: 'proposal', 1632 id: d.id, 1633 title: typeLabel, 1634 author: author, 1635 songName: data.targetNodeId || '' 1636 }); 1637 }); 1638 } catch (pe) { 1639 console.warn("Could not query proposals collection:", pe); 1640 } 1641 1642 // 2. Check pending versions in 'versions' collection directly 1643 try { 1644 const versionsQ = query( 1645 collection(db, 'versions'), 1646 where('status', '==', 'pending') 1647 ); 1648 const versSnap = await getDocs(versionsQ); 1649 1650 if (!versSnap.empty) { 1651 for (const vDoc of versSnap.docs) { 1652 const vData = vDoc.data(); 1653 if (!vData.songId) continue; 1654 1655 let songTitle = vData.songId; 1656 let bandTitle = ''; 1657 let effectiveOwner = null; 1658 1659 // Check in active library tree if present 1660 if (window.fullLibraryData) { 1661 for (const b of window.fullLibraryData) { 1662 for (const a of (b.children || [])) { 1663 for (const s of (a.children || [])) { 1664 if (s.id === vData.songId) { 1665 songTitle = s.name || songTitle; 1666 bandTitle = b.name || ''; 1667 effectiveOwner = s.ownerId || a.ownerId || b.ownerId; 1668 break; 1669 } 1670 } 1671 if (effectiveOwner) break; 1672 } 1673 if (effectiveOwner) break; 1674 } 1675 } 1676 1677 // If not in library cache, fetch song doc from Firestore 1678 if (!effectiveOwner) { 1679 try { 1680 const sSnap = await getDoc(doc(db, 'songs', vData.songId)); 1681 if (sSnap.exists()) { 1682 const sData = sSnap.data(); 1683 effectiveOwner = sData.ownerId; 1684 songTitle = sData.name || songTitle; 1685 if (sData.bandId) bandTitle = sData.bandId; 1686 } 1687 } catch (se) { 1688 console.warn("Could not fetch song doc for pending version:", se); 1689 } 1690 } 1691 1692 if (isSuperAdmin || (effectiveOwner && effectiveOwner === user.uid)) { 1693 pendingList.push({ 1694 type: 'version', 1695 id: vDoc.id, 1696 songName: songTitle, 1697 bandName: bandTitle, 1698 author: vData.label || (vData.authorId ? `Usuario ${vData.authorId.substring(0, 6)}` : 'Colaborador'), 1699 title: bandTitle ? `${bandTitle} - ${songTitle}` : songTitle 1700 }); 1701 } 1702 } 1703 } 1704 } catch (ve) { 1705 console.warn("Could not query pending versions:", ve); 1706 } 1707 1708 if (pendingList.length > 0) { 1709 // Check debounce in sessionStorage 1710 const signature = `${user.uid}_${pendingList.map(p => p.id || p.songName).join('|')}`; 1711 if (sessionStorage.getItem('last_seen_pending_signature') === signature) { 1712 return; 1713 } 1714 sessionStorage.setItem('last_seen_pending_signature', signature); 1715 1716 const modalTitle = isEn 1717 ? `Pending Approvals (${pendingList.length})` 1718 : `Aprobaciones Pendientes (${pendingList.length})`; 1719 1720 let messageLines = [ 1721 isEn 1722 ? `You have ${pendingList.length} pending proposal(s) or version(s) waiting for your review:\n` 1723 : `Tienes ${pendingList.length} propuesta(s) o versión(es) pendiente(s) de moderación:\n` 1724 ]; 1725 1726 pendingList.forEach((item, idx) => { 1727 if (item.bandName && item.songName) { 1728 messageLines.push(`${idx + 1}. ðµ ${item.bandName} â "${item.songName}" (${item.author})`); 1729 } else { 1730 messageLines.push(`${idx + 1}. ð ${item.title} (${item.author})`); 1731 } 1732 }); 1733 1734 messageLines.push(isEn
1735 ? "\nOpen any song in the navigation tree to test the chords and select 'Set as Master', 'Approve Variant', or 'Discard'." 1736 : "\nAbre la canción en el catálogo para revisar los acordes y elegir 'Hacer Master', 'Aprobar Variante' o 'Descartar'."); 1737 1738 const fullMessage = messageLines.join('\n'); 1739 1740 if (typeof window.openInfoModal === 'function') { 1741 setTimeout(() => { 1742 window.openInfoModal(modalTitle, fullMessage); 1743 }, 500); 1744 } else if (window.showToast) { 1745 window.showToast(fullMessage, 'info'); 1746 } 1747 } 1748 } catch (e) { 1749 console.warn("Error checking pending approvals:", e); 1750 } 1751 } 1752 1753 populateVersionSelector(preferredVersionId = null) { 1754 const select = document.getElementById('version-select'); 1755 if (!select) return; 1756 1757 select.innerHTML = ''; 1758 1759 const auth = getFirebaseAuth(); 1760 const currentUser = auth?.currentUser; 1761 const currentUid = currentUser?.uid; 1762 const currentDisplayName = (currentUser?.displayName || '').trim().toLowerCase(); 1763 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 1764 1765 this.versions.forEach(v => { 1766 const opt = document.createElement('option'); 1767 opt.value = v.id; 1768 1769 // Clean any existing historical tags 1770 let baseLabel = (v.label || 'Versión') 1771 .replace(/\s*\((Master|Variante|Propuesta)\)/gi, '') 1772 .replace(/â |â/g, '') 1773 .trim(); 1774 1775 if (v.status === 'approved') { 1776 if (baseLabel.toLowerCase().startsWith('propuesta por')) { 1777 baseLabel = baseLabel.replace(/^propuesta por/i, 'Realizada por'); 1778 } else if (baseLabel.toLowerCase().startsWith('propuesta (')) { 1779 baseLabel = baseLabel.replace(/^propuesta \(/i, '').replace(/\)$/, '').trim(); 1780 } else if (baseLabel.toLowerCase().startsWith('propuesta:')) { 1781 baseLabel = baseLabel.replace(/^propuesta:\s*/i, '').trim(); 1782 } 1783 } 1784 1785 // Check if current user is the author/creator of this version 1786 const isAdminEmail = checkAdminEmail; 1787 const isUserActuallyTheAdmin = (currentUid === 'admin' || isAdminEmail(currentUser?.email)); 1788 1789 const versionAuthorUid = v.authorId || v.ownerId || v.userId; 1790 let isUserAuthor = false; 1791 1792 if (currentUid) { 1793 if (versionAuthorUid) { 1794 if (versionAuthorUid === currentUid) { 1795 isUserAuthor = true; 1796 } else if (versionAuthorUid === 'admin' && isUserActuallyTheAdmin) { 1797 isUserAuthor = true; 1798 } 1799 } else if (isUserActuallyTheAdmin) { 1800 isUserAuthor = true; 1801 } 1802 } 1803 1804 if (isUserAuthor && !baseLabel.toLowerCase().includes('(tú)') && !baseLabel.toLowerCase().includes('(you)')) { 1805 baseLabel = `${baseLabel} (${isEn ? 'You' : 'Tú'})`; 1806 } 1807 1808 const isMaster = (v.id === this.currentMasterVersionId) || 1809 (!this.currentMasterVersionId && v.id.endsWith('_v1')); 1810 1811 let displayLabel = ''; 1812 if (isMaster) { 1813 displayLabel = `${baseLabel} (Master) â `; 1814 } else if (v.status === 'approved') { 1815 displayLabel = `${baseLabel} (Variante)`; 1816 } else { 1817 displayLabel = baseLabel.toLowerCase().startsWith('propuesta') ? baseLabel : `Propuesta (${baseLabel})`; 1818 } 1819 1820 opt.textContent = displayLabel; 1821 select.appendChild(opt); 1822 }); 1823 1824 select.onchange = (e) => { 1825 const version = this.versions.find(v => v.id === e.target.value); 1826 if (version) { 1827 this.displayVersion(version); 1828 } 1829 }; 1830 1831 if (this.versions.length > 0) { 1832 let active = null; 1833 if (preferredVersionId) { 1834 active = this.versions.find(v => v.id === preferredVersionId); 1835 } 1836 if (!active && this.currentMasterVersionId) { 1837 active = this.versions.find(v => v.id === this.currentMasterVersionId); 1838 } 1839 if (!active) { 1840 active = this.versions.find(v => v.status === 'approved') || this.versions[0]; 1841 } 1842 select.value = active.id; 1843 this.updateStarsUI(active.starsAvg || 0, active.ratingsCount || 0, active.id); 1844 } 1845 } 1846 1847 displayVersion(version) { 1848 if (!version) return; 1849 1850 let updatedInParsedData = false;
1851 1852 // Prepare text to parse: ensure it has enclosing (Band) and (Album) if not present 1853 let textToParse = version.content || ''; 1854 if (!textToParse.match(/^\s*\((Band|Playlist)\)/im)) { 1855 if (textToParse.match(/^\s*\(Album\)/im)) { 1856 textToParse = `(Band) ${this.grandparentName || 'Band'}\n` + textToParse; 1857 } else if (textToParse.match(/^\s*\(Song\)/im)) { 1858 textToParse = `(Band) ${this.grandparentName || 'Band'}\n(Album) ${this.parentName || 'Album'}\n` + textToParse; 1859 } else { 1860 textToParse = `(Band) ${this.grandparentName || 'Band'}\n(Album) ${this.parentName || 'Album'}\n(Song) ${this.currentNodeName || 'Song'}\n` + textToParse; 1861 } 1862 } 1863 1864 let parsedSong = null; 1865 if (typeof window.parseChords === 'function') { 1866 const tempParsed = window.parseChords(textToParse); 1867 if (tempParsed && tempParsed.length > 0) { 1868 for (const b of tempParsed) { 1869 for (const a of (b.albums || [])) { 1870 for (const s of (a.songs || [])) { 1871 if (s) { parsedSong = s; break; } 1872 } 1873 if (parsedSong) break; 1874 } 1875 if (parsedSong) break; 1876 } 1877 } 1878 } 1879 1880 if (window.parsedData && window.parsedData.length > 0 && this.currentNodeName && parsedSong) { 1881 const targetSongName = (this.currentNodeName || '').trim().toLowerCase(); 1882 const targetAlbumName = this.parentName ? this.parentName.trim().toLowerCase() : null; 1883 const targetBandName = this.grandparentName ? this.grandparentName.trim().toLowerCase() : null; 1884 1885 for (let b = 0; b < window.parsedData.length; b++) { 1886 const band = window.parsedData[b]; 1887 if (targetBandName && (band.name || '').trim().toLowerCase() !== targetBandName) continue; 1888 1889 for (let a = 0; a < (band.albums || []).length; a++) { 1890 const album = band.albums[a]; 1891 if (targetAlbumName && (album.name || '').trim().toLowerCase() !== targetAlbumName) continue; 1892 1893 for (let s = 0; s < (album.songs || []).length; s++) { 1894 const song = album.songs[s]; 1895 if ((song.name || '').trim().toLowerCase() === targetSongName) { 1896 song.tonalityGroups = parsedSong.tonalityGroups; 1897 song.lyrics = parsedSong.lyrics; 1898 song._originalLyrics = parsedSong._originalLyrics; 1899 song.transpose = parsedSong.transpose || 0; 1900 updatedInParsedData = true; 1901 break; 1902 } 1903 } 1904 if (updatedInParsedData) break; 1905 } 1906 if (updatedInParsedData) break; 1907 } 1908 } 1909 1910 const chordInput = document.getElementById('chordInput'); 1911 if (chordInput) { 1912 const treeState = typeof window.captureTreeState === 'function' ? window.captureTreeState() : null; 1913 1914 if (updatedInParsedData && typeof window.generateRawText === 'function') { 1915 chordInput.value = window.generateRawText(window.parsedData); 1916 } else if ((version.content || '').match(/^\s*\((Band|Playlist)\)/im)) { 1917 chordInput.value = version.content; 1918 } else { 1919 chordInput.value = textToParse; 1920 } 1921 1922 // Trigger the viewer update (chordviewer.js) 1923 if (typeof window.processChords === 'function') { 1924 window.processChords(); 1925 window.isDirty = false; // Reset dirty state as we just loaded a version 1926 window._lastCleanEditorText = chordInput.value || ''; 1927 } 1928 1929 if (treeState && typeof window.restoreTreeState === 'function') { 1930 window.restoreTreeState(treeState); 1931 } else if (typeof window.expandPathToElement === 'function') { 1932 setTimeout(() => window.expandPathToElement('song', 0, 0, 0), 50); 1933 } 1934 } 1935 this.updateStarsUI(version.starsAvg || 0, version.ratingsCount || 0, version.id); 1936 } 1937 1938 updateStarsUI(avg, count, currentVersionId) { 1939 const avgEl = document.getElementById('version-stars-avg'); 1940 const countEl = document.getElementById('version-ratings-count'); 1941 const starsRow = document.getElementById('stars-row'); 1942 const starContainer = document.getElementById('star-rating-display'); 1943 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 1944 1945 const avgNum = typeof avg === 'number' ? avg : (parseFloat(avg) || 3.0); 1946 const countNum = typeof count === 'number' ? count : (parseInt(count, 10) || 1); 1947
1948 if (avgEl) avgEl.textContent = `${avgNum.toFixed(1)}/5`; 1949 if (countEl) countEl.textContent = `(${countNum})`; 1950 1951 const tooltip = isEn 1952 ? `Average rating: ${avgNum.toFixed(1)}/5 (${countNum} total rating${countNum === 1 ? '' : 's'}). Click stars to submit your rating.` 1953 : `Valoración media: ${avgNum.toFixed(1)}/5 (${countNum} valoración${countNum === 1 ? '' : 'es'} en total). Haz clic en las estrellas para puntuar.`; 1954 1955 if (starContainer) starContainer.setAttribute('title', tooltip); 1956 1957 const auth = getFirebaseAuth() || (window.getFirebaseAuth && window.getFirebaseAuth()); 1958 const currentUser = auth?.currentUser || window.currentUser; 1959 const currentUid = currentUser?.uid; 1960 const isAdminEmail = checkAdminEmail; 1961 const isUserActuallyTheAdmin = (currentUid === 'admin' || isAdminEmail(currentUser?.email)); 1962 1963 const activeVer = this.versions?.find(v => v.id === currentVersionId) || this.versions?.[0]; 1964 const verAuthorUid = activeVer?.authorId || activeVer?.ownerId || activeVer?.userId; 1965 const isSelfVersion = !!(currentUid && (verAuthorUid === currentUid || (verAuthorUid === 'admin' && isUserActuallyTheAdmin))); 1966 1967 if (starsRow) { 1968 starsRow.innerHTML = ''; 1969 const rounded = Math.round(avgNum); 1970 1971 const renderStars = (activeScore) => { 1972 starsRow.querySelectorAll('span.star-item').forEach((star, idx) => { 1973 const starIndex = idx + 1; 1974 if (starIndex <= activeScore) { 1975 star.textContent = 'â '; 1976 star.className = isSelfVersion 1977 ? 'star-item px-0.5 text-yellow-500 cursor-default' 1978 : 'star-item cursor-pointer transition-transform hover:scale-125 px-0.5 text-yellow-500 hover:text-yellow-400'; 1979 } else { 1980 star.textContent = 'â'; 1981 star.className = isSelfVersion 1982 ? 'star-item px-0.5 text-gray-300 dark:text-gray-600 cursor-default' 1983 : 'star-item cursor-pointer transition-transform hover:scale-125 px-0.5 text-gray-300 dark:text-gray-600 hover:text-yellow-400'; 1984 } 1985 }); 1986 }; 1987 1988 for (let i = 1; i <= 5; i++) { 1989 const s = document.createElement('span'); 1990 s.textContent = i <= rounded ? 'â ' : 'â'; 1991 s.style.fontSize = '1.15rem'; 1992 1993 if (isSelfVersion) { 1994 s.className = `star-item px-0.5 ${i <= rounded ? 'text-yellow-500' : 'text-gray-300 dark:text-gray-600'} cursor-default`; 1995 s.title = isEn ? `Your version (Average rating: ${avgNum.toFixed(1)}/5)` : `Tu versión (Valoración media: ${avgNum.toFixed(1)}/5)`; 1996 s.onclick = (e) => { 1997 e.stopPropagation(); 1998 if (window.showToast) window.showToast(isEn ? "You cannot rate your own version." : "No puedes valorar tu propia versión.", "info"); 1999 }; 2000 } else { 2001 s.className = `star-item cursor-pointer transition-transform hover:scale-125 px-0.5 ${i <= rounded ? 'text-yellow-500' : 'text-gray-300 dark:text-gray-600'} hover:text-yellow-400`; 2002 s.title = isEn ? `Rate ${i} of 5 stars` : `Puntuar con ${i} de 5 estrellas`; 2003 2004 // Hover preview 2005 s.onmouseenter = () => renderStars(i); 2006 2007 // Click to vote 2008 s.onclick = (e) => { 2009 e.stopPropagation(); 2010 const effectiveVerId = currentVersionId || this.currentMasterVersionId || document.getElementById('version-select')?.value || (this.currentSongId ? `${this.currentSongId}_v1` : 'master_v1'); 2011 this.submitRating(effectiveVerId, i); 2012 }; 2013 } 2014 2015 starsRow.appendChild(s); 2016 } 2017 2018 if (!isSelfVersion) { 2019 starsRow.onmouseleave = () => renderStars(rounded); 2020 } 2021 } 2022 2023 this.checkOwnershipAndShowControls(currentVersionId); 2024 } 2025 2026 checkOwnershipAndShowControls(versionId) { 2027 const auth = getFirebaseAuth(); 2028 const currentUser = auth?.currentUser; 2029 const isAdminEmail = checkAdminEmail; 2030 const isSuperAdmin = (currentUser?.uid === 'admin' || isAdminEmail(currentUser?.email)); 2031 const isOwner = !!(currentUser?.uid && this.currentOwnerId && currentUser.uid === this.currentOwnerId); 2032 const canModerate = isSuperAdmin || isOwner; 2033 2034 const container = document.getElementById('version-selector-container'); 2035 if (!container) return; 2036 2037 // Limpiar controles previos 2038 container.querySelectorAll('.admin-ctrl-btn').forEach(b => b.remove()); 2039 2040 const saveBtn = document.getElementById('save-node-btn'); 2041 const deleteBtn = document.getElementById('delete-node-btn'); 2042 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 2043 2044 const isPersonal = (window.personalLibraryManager && window.personalLibraryManager.activeView === 'personal') || 2045 (sessionStorage.getItem('chordlib_active_tab') === 'personal'); 2046 2047 if (!isPersonal) { 2048 const currentVer = this.versions.find(v => v.id === versionId) || this.versions?.[0]; 2049 const verAuthorUid = currentVer?.authorId || currentVer?.ownerId || currentVer?.userId; 2050 const isAuthorOfActiveVersion = !!(currentUser?.uid && (verAuthorUid === currentUser.uid || (verAuthorUid === 'admin' && isSuperAdmin))); 2051 const userHasExistingVersion = !!(currentUser?.uid && this.versions?.some(v => (v.authorId === currentUser.uid || v.ownerId === currentUser.uid || v.userId === currentUser.uid))); 2052 2053 const hasWrite = (isSuperAdmin || isOwner || isAuthorOfActiveVersion || userHasExistingVersion); 2054 const canDelete = (isSuperAdmin || isOwner || isAuthorOfActiveVersion); 2055 2056 if (saveBtn) { 2057 saveBtn.disabled = false;
2058 saveBtn.className = hasWrite 2059 ? 'px-2.5 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 cursor-pointer' 2060 : 'px-2.5 py-1 bg-amber-600 hover:bg-amber-700 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 cursor-pointer'; 2061 saveBtn.title = hasWrite 2062 ? (isEn ? 'Save / update changes to your version' : 'Guardar y actualizar cambios en tu versión') 2063 : (isEn ? 'Submit chord version proposal to owner/admin' : 'Proponer una nueva versión de acordes al dueño/administrador'); 2064 saveBtn.innerHTML = `<span>${hasWrite ? 'ð¾' : 'ð¡'}</span> <span>${hasWrite ? (isEn ? 'Save' : 'Grabar') : (isEn ? 'Propose version' : 'Proponer versión')}</span>`; 2065 } 2066 2067 if (deleteBtn) { 2068 deleteBtn.disabled = false; 2069 deleteBtn.className = (canDelete && (versionId || this.currentSongId)) 2070 ? 'px-2.5 py-1 bg-rose-600 hover:bg-rose-700 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 cursor-pointer' 2071 : 'px-2.5 py-1 bg-gray-400 dark:bg-gray-600 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center space-x-1 opacity-70 cursor-pointer'; 2072 deleteBtn.title = (canDelete && (versionId || this.currentSongId)) 2073 ? (isEn ? 'Delete version from official catalog' : 'Eliminar versión del catálogo oficial') 2074 : (isEn ? 'No permission to delete (only version author, branch owner or admin)' : 'Sin permiso para borrar (solo autor de la versión, dueño o admin)'); 2075 deleteBtn.innerHTML = `<span>ðï¸</span> <span>${isEn ? 'Delete' : 'Borrar'}</span>`; 2076 } 2077 } 2078 2079 if (canModerate) { 2080 const currentVersion = this.versions.find(v => v.id === versionId); 2081 if (!currentVersion) return; 2082 2083 const isMaster = (versionId === this.currentMasterVersionId) || 2084 (!this.currentMasterVersionId && versionId.endsWith('_v1')); 2085 2086 const btnClass = "admin-ctrl-btn text-[10px] px-2.5 py-1 rounded-lg font-bold transition ml-2 cursor-pointer shadow-sm flex items-center space-x-1"; 2087 2088 if (isMaster) { 2089 // Es el Master oficial actual 2090 const masterBadge = document.createElement('button'); 2091 masterBadge.className = 'admin-ctrl-btn ml-2 text-[10px] bg-emerald-600 text-white px-2.5 py-1 rounded-lg font-bold cursor-default shadow-sm'; 2092 masterBadge.textContent = 'â Master Actual'; 2093 masterBadge.disabled = true; 2094 container.appendChild(masterBadge); 2095 } else if (currentVersion.status === 'approved') { 2096 // Es una variante aprobada (no es Master) -> El dueño/admin puede promoverla a Master voluntariamente en cualquier momento 2097 const masterBtn = document.createElement('button'); 2098 masterBtn.className = `${btnClass} bg-amber-500 hover:bg-amber-600 text-white`; 2099 masterBtn.innerHTML = '<span>â </span> <span>Hacer Master</span>'; 2100 masterBtn.title = 'Establecer esta versión como la Master oficial para todos los usuarios'; 2101 masterBtn.onclick = () => { 2102 const msg = `¿Deseas establecer esta versión ("${currentVersion.label || 'Variante'}") como la nueva versión Master oficial de la canción?`; 2103 if (typeof window.openConfirmModal === 'function') { 2104 window.openConfirmModal(msg, () => this.setAsMaster(versionId)); 2105 } else { 2106 this.setAsMaster(versionId); 2107 } 2108 }; 2109 container.appendChild(masterBtn); 2110 2111 const deleteVarBtn = document.createElement('button'); 2112 deleteVarBtn.className = `${btnClass} bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-950/60 dark:text-rose-300`; 2113 deleteVarBtn.textContent = 'Eliminar Variante'; 2114 deleteVarBtn.title = 'Eliminar esta variante alternativa permanentemente'; 2115 deleteVarBtn.onclick = () => { 2116 const msg = 'â ï¸ Â¿Seguro que deseas eliminar esta variante permanentemente?'; 2117 if (typeof window.openConfirmModal === 'function') { 2118 window.openConfirmModal(msg, () => this.rejectProposal(versionId)); 2119 } 2120 }; 2121 container.appendChild(deleteVarBtn); 2122 } else { 2123 // Es una propuesta pendiente 2124 const masterBtn = document.createElement('button'); 2125 masterBtn.className = `${btnClass} bg-emerald-600 hover:bg-emerald-700 text-white`; 2126 masterBtn.textContent = 'Hacer Master';
2127 masterBtn.title = 'Aprobar y convertir directamente en la versión Master oficial'; 2128 masterBtn.onclick = () => this.setAsMaster(versionId); 2129 container.appendChild(masterBtn); 2130 2131 const varBtn = document.createElement('button'); 2132 varBtn.className = `${btnClass} bg-blue-600 hover:bg-blue-700 text-white`; 2133 varBtn.textContent = 'Aprobar Variante'; 2134 varBtn.title = 'Aprobar manteniéndola como alternativa pública no-oficial'; 2135 varBtn.onclick = () => this.approveVariant(versionId); 2136 container.appendChild(varBtn); 2137 2138 const rejectBtn = document.createElement('button'); 2139 rejectBtn.className = `${btnClass} bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-950/60 dark:text-rose-300`; 2140 rejectBtn.textContent = 'Descartar'; 2141 rejectBtn.title = 'Rechazar y eliminar esta propuesta permanentemente'; 2142 rejectBtn.onclick = () => { 2143 const msg = 'â ï¸ Â¿Seguro que deseas rechazar y eliminar esta propuesta permanentemente?'; 2144 if (typeof window.openConfirmModal === 'function') { 2145 window.openConfirmModal(msg, () => this.rejectProposal(versionId)); 2146 } 2147 }; 2148 container.appendChild(rejectBtn); 2149 } 2150 } 2151 } 2152 2153 async setAsMaster(versionId) { 2154 const db = getDb(); 2155 if (!db || !this.currentSongId) return; 2156 2157 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 2158 if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? 'Setting master versionâ¦' : 'Estableciendo como Masterâ¦'); 2159 2160 try { 2161 const { updateDoc, getDocs, collection, query, where, writeBatch, doc } = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js"); 2162 const batch = writeBatch(db); 2163 2164 // 1. Update Song metadata to point to this version 2165 const songRef = doc(db, 'songs', this.currentSongId); 2166 const version = this.versions.find(v => v.id === versionId); 2167 2168 let cleanSongContent = version?.content || ''; 2169 const songIdx = cleanSongContent.search(/^\s*\(Song\)/im); 2170 if (songIdx !== -1) { 2171 cleanSongContent = cleanSongContent.substring(songIdx); 2172 } 2173 2174 batch.update(songRef, { 2175 masterVersionId: versionId, 2176 content: cleanSongContent, 2177 hasChords: !!(cleanSongContent && window.detectChordsInText && window.detectChordsInText(cleanSongContent)) 2178 }); 2179 2180 // 2. Update version label and status 2181 const versionRef = doc(db, 'versions', versionId); 2182 let newLabel = version?.label || ''; 2183 if (newLabel.startsWith('Propuesta por ')) { 2184 newLabel = newLabel.replace('Propuesta por ', 'Realizada por '); 2185 } 2186 batch.update(versionRef, { status: 'approved', label: newLabel }); 2187 2188 // 3. Bump sync metadata 2189 batch.set(doc(db, 'metadata', 'sync_info'), { lastUpdated: new Date() }, { merge: true }); 2190 2191 await batch.commit(); 2192 2193 if (window.showToast) window.showToast("Versión establecida como Master.", 'success'); 2194 2195 // Force full tree refresh 2196 if (window.syncManager) await window.syncManager.setMetadata('lastUpdated', 0); 2197 if (typeof window.triggerSync === 'function') await window.triggerSync(true); 2198 2199 await this.loadSongVersions(this.currentSongId, this.currentOwnerId, this.currentNodeType, this.currentNodeName, this.parentName, this.grandparentName, true, versionId); 2200 } catch (error) { 2201 console.error("Error setting master version:", error); 2202 if (window.showToast) window.showToast("No tienes permiso o hubo un error.", 'error'); 2203 } finally { 2204 if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal(); 2205 } 2206 } 2207 2208 async approveVariant(versionId) { 2209 const db = getDb(); 2210 if (!db) return; 2211 2212 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 2213 if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? 'Approving variantâ¦' : 'Aprobando varianteâ¦'); 2214 2215 try { 2216 const { updateDoc, doc, writeBatch }
2216 = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js"); 2217 const versionRef = doc(db, 'versions', versionId); 2218 const version = this.versions.find(v => v.id === versionId); 2219 2220 let newLabel = version?.label || ''; 2221 if (newLabel.startsWith('Propuesta por ')) { 2222 newLabel = newLabel.replace('Propuesta por ', 'Realizada por '); 2223 } 2224 await updateDoc(versionRef, { status: 'approved', label: newLabel }); 2225 2226 const batch = writeBatch(db); 2227 batch.set(doc(db, 'metadata', 'sync_info'), { lastUpdated: new Date() }, { merge: true }); 2228 await batch.commit(); 2229 2230 if (window.showToast) window.showToast("Variante aprobada para visualización pública.", 'success'); 2231 2232 if (window.syncManager) await window.syncManager.setMetadata('lastUpdated', 0); 2233 if (typeof window.triggerSync === 'function') await window.triggerSync(true); 2234 2235 await this.loadSongVersions(this.currentSongId, this.currentOwnerId, this.currentNodeType, this.currentNodeName, this.parentName, this.grandparentName, true, versionId); 2236 } catch (error) { 2237 console.error("Error approving variant:", error); 2238 if (window.showToast) window.showToast("Hubo un error al aprobar la variante.", 'error'); 2239 } finally { 2240 if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal(); 2241 } 2242 } 2243 2244 loadParentNodeAfterSongDeletion() { 2245 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 2246 const pAlbum = (this.parentName && typeof window.findNodeInLibrary === 'function') 2247 ? window.findNodeInLibrary('album', this.parentName, this.grandparentName) 2248 : null; 2249 if (pAlbum && typeof window.loadSongIntoViewer === 'function') { 2250 window.loadSongIntoViewer(pAlbum.copyText, { 2251 type: 'album', 2252 name: pAlbum.name, 2253 parentName: pAlbum.parentName, 2254 grandparentName: pAlbum.grandparentName, 2255 library: 'official' 2256 }); 2257 if (typeof selectLibraryNode === 'function') { 2258 selectLibraryNode('album', pAlbum.name, pAlbum.parentName, pAlbum.grandparentName); 2259 } 2260 this.updateOwnershipUI(pAlbum.ownerId, 'album', pAlbum.id, pAlbum.name, pAlbum.parentName, pAlbum.grandparentName); 2261 return; 2262 } 2263 2264 const pBand = (this.grandparentName && typeof window.findNodeInLibrary === 'function') 2265 ? window.findNodeInLibrary('band', this.grandparentName) 2266 : null; 2267 if (pBand && typeof window.loadSongIntoViewer === 'function') { 2268 window.loadSongIntoViewer(pBand.copyText, { 2269 type: 'band', 2270 name: pBand.name, 2271 library: 'official' 2272 }); 2273 if (typeof selectLibraryNode === 'function') { 2274 selectLibraryNode('band', pBand.name); 2275 } 2276 this.updateOwnershipUI(pBand.ownerId, 'band', pBand.id, pBand.name, null, null); 2277 return; 2278 } 2279 2280 // Si no se encuentra rama padre, limpiar editor 2281 const chordInput = document.getElementById('chordInput'); 2282 if (chordInput) { 2283 chordInput.value = ''; 2284 window._lastCleanEditorText = ''; 2285 window.isDirty = false; 2286 if (typeof window.processChords === 'function') window.processChords(); 2287 } 2288 this.resetUI(); 2289 } 2290 2291 async rejectProposal(versionId) { 2292 const db = getDb(); 2293 if (!db) return; 2294 2295 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 2296 if (typeof window.showLoadingModal === 'function') window.showLoadingModal(isEn ? 'Deleting versionâ¦' : 'Eliminando versiónâ¦'); 2297 2298 try { 2299 const { deleteDoc, doc, getDocs, collection, query, where, writeBatch } = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js"); 2300 await deleteDoc(doc(db, 'versions', versionId)); 2301 2302 // Cleanup: Si era la única versión y se rechaza, borrar también la metadata de la canción 2303 const versionsLeftSnap = await getDocs(query(collection(db, 'versions'), where('songId', '==', this.currentSongId))); 2304 const isSongDeleted = versionsLeftSnap.empty; 2305 if (isSongDeleted) { 2306 await deleteDoc(doc(db, 'songs', this.currentSongId)); 2307 } 2308 2309 const batch = writeBatch(db); 2310 batch.set(doc(db, 'metadata', 'sync_info'), { lastUpdated: new Date() }, { merge: true }); 2311 await batch.commit(); 2312 2313 if (window.showToast) window.showToast(isEn ? "Version deleted successfully." : "Versión eliminada.", 'success'); 2314 2315 if (window.syncManager) await window.syncManager.setMetadata('lastUpdated', 0); 2316 if (typeof window.triggerSync === 'function') await window.triggerSync(true); 2317 2318 if (isSongDeleted) { 2319 this.loadParentNodeAfterSongDeletion(); 2320 } else { 2321 await this.loadSongVersions(this.currentSongId, this.currentOwnerId, this.currentNodeType, this.currentNodeName, this.parentName, this.grandparentName, true); 2322 if (this.versions.length > 0) { 2323 const active = (this.currentMasterVersionId && this.versions.find(v => v.id === this.currentMasterVersionId)) || 2324 this.versions.find(v => v.status === 'approved') || 2325 this.versions[0]; 2326 if (active) { 2327 this.displayVersion(active); 2328 } 2329 } 2330 } 2331 } catch (error) { 2332 console.error("Error rejecting proposal:", error); 2333 if (window.showToast) window.showToast(isEn ? "Error deleting version." : "Hubo un error al eliminar la versión.", 'error'); 2334 } finally { 2335 if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal(); 2336 } 2337 } 2338 2339 async submitRating(versionId, score) { 2340 if (!versionId) { 2341 versionId = this.currentMasterVersionId || document.getElementById('version-select')?.value || (this.currentSongId ? `${this.currentSongId}_v1` : 'master_v1'); 2342 } 2343 if (!versionId) return; 2344 let db = getDb(); 2345 if (!db) { 2346 try { 2347 const { initFirebase } = await import('./firebase_manager.js'); 2348 await initFirebase(); 2349 db = getDb(); 2350 } catch (e) { 2351 console.warn("Could not auto-init firebase in submitRating:", e); 2352 } 2353 } 2354 if (!db) { 2355 if (window.showToast) window.showToast("Error de conexión a la base de datos.", 'error'); 2356 return; 2357 } 2358 2359 const auth = getFirebaseAuth() || (window.getFirebaseAuth && window.getFirebaseAuth()); 2360 const currentUser = auth?.currentUser || window.currentUser; 2361 const currentUid = currentUser?.uid; 2362 const isEn = typeof getLanguage === 'function' && getLanguage() === 'en'; 2363 2364 const isAdminEmail = checkAdminEmail; 2365 const isUserActuallyTheAdmin = (currentUid === 'admin' || isAdminEmail(currentUser?.email)); 2366 2367 const activeVer = this.versions?.find(v => v.id === versionId || v.id === String(versionId).replace(/[\/\s#$[\]]/g, '_')); 2368 const verAuthorUid = activeVer?.authorId || activeVer?.ownerId || activeVer?.userId; 2369 const isSelfVote = !!(currentUid && (verAuthorUid === currentUid || (verAuthorUid === 'admin' && isUserActuallyTheAdmin))); 2370 2371 if (isSelfVote) { 2372 if (window.showToast) { 2373 window.showToast(isEn ? "You cannot rate your own version." : "No puedes valorar tu propia versión.", "info"); 2374 } 2375 return; 2376 } 2377 2378 let userId = currentUid; 2379 if (!userId) { 2380 userId = localStorage.getItem('chordlib_anon_voter_id'); 2381 if (!userId) { 2382 userId = 'anon_' + Math.random().toString(36).substring(2, 11); 2383 localStorage.setItem('chordlib_anon_voter_id', userId); 2384 } 2385 } 2386 2387 const safeVersionId = String(versionId).replace(/[\/\s#$[\]]/g, '_'); 2388 const safeRatingId = `${userId}_${safeVersionId}`.replace(/[\/\s#$[\]]/g, '_'); 2389 2390 if (typeof window.showLoadingModal === 'function') { 2391 window.showLoadingModal(isEn ? 'Submitting ratingâ¦' : 'Guardando valoraciónâ¦'); 2392 } 2393 2394 try { 2395 const { doc, getDoc, setDoc, serverTimestamp }
2395 = await import("https://www.gstatic.com/firebasejs/9.22.1/firebase-firestore.js"); 2396 const ratingRef = doc(db, 'ratings', safeRatingId); 2397 let prevRatingSnap = null; 2398 try { 2399 prevRatingSnap = await getDoc(ratingRef); 2400 } catch (snapErr) { 2401 console.warn("Could not fetch previous rating doc:", snapErr); 2402 } 2403 2404 const prevExists = !!(prevRatingSnap && (typeof prevRatingSnap.exists === 'function' ? prevRatingSnap.exists() : prevRatingSnap.exists)); 2405 let oldScore = null; 2406 let existingCreatedAt = null; 2407 2408 if (prevExists && typeof prevRatingSnap.data === 'function') { 2409 const pData = prevRatingSnap.data(); 2410 oldScore = (pData && typeof pData.score === 'number') ? pData.score : (parseFloat(pData?.score) || null); 2411 existingCreatedAt = pData?.createdAt || null; 2412 } 2413 2414 // 1. Save / update individual user rating 2415 const ratingPayload = { 2416 userId: userId, 2417 versionId: safeVersionId, 2418 score: Number(score), 2419 updatedAt: serverTimestamp() 2420 }; 2421 if (existingCreatedAt) { 2422 ratingPayload.createdAt = existingCreatedAt; 2423 } else { 2424 ratingPayload.createdAt = serverTimestamp(); 2425 } 2426 2427 await setDoc(ratingRef, ratingPayload, { merge: true }); 2428 2429 // 2. Fetch version document to recalculate average accurately 2430 const versionRef = doc(db, 'versions', safeVersionId); 2431 let vSnap = null; 2432 try { 2433 vSnap = await getDoc(versionRef); 2434 } catch (vErr) { 2435 console.warn("Could not fetch version doc:", vErr); 2436 } 2437 2438 const vExists = !!(vSnap && (typeof vSnap.exists === 'function' ? vSnap.exists() : vSnap.exists)); 2439 let newAvg = Number(score); 2440 let newCount = 1; 2441 2442 if (vExists && typeof vSnap.data === 'function') { 2443 const vData = vSnap.data() || {}; 2444 const currentCount = Number(vData.ratingsCount) || 1; 2445 const currentAvg = Number(vData.starsAvg) || 3.0; 2446 2447 if (oldScore !== null) { 2448 // Update of previous rating by same user 2449 const totalScore = (currentAvg * currentCount) - oldScore + Number(score); 2450 newCount = Math.max(1, currentCount); 2451 newAvg = totalScore / newCount; 2452 } else { 2453 // Brand new rating from this user 2454 newCount = currentCount + 1; 2455 newAvg = ((currentAvg * currentCount) + Number(score)) / newCount; 2456 } 2457 2458 newAvg = Math.max(1, Math.min(5, Math.round(newAvg * 10) / 10)); 2459 2460 await setDoc(versionRef, { 2461 starsAvg: newAvg, 2462 ratingsCount: newCount 2463 }, { merge: true }); 2464 } else { 2465 newCount = 2; 2466 newAvg = Math.max(1, Math.min(5, Math.round(((3.0 * 1 + Number(score)) / 2) * 10) / 10)); 2467 await setDoc(versionRef, { 2468 id: safeVersionId, 2469 songId: this.currentSongId || '', 2470 starsAvg: newAvg, 2471 ratingsCount: newCount, 2472 status: 'approved', 2473 createdAt: serverTimestamp() 2474 }, { merge: true }); 2475 } 2476 2477 // Update local version cache 2478 const cachedVer = this.versions.find(v => v.id === versionId || v.id === safeVersionId); 2479 if (cachedVer) { 2480 cachedVer.starsAvg = newAvg; 2481 cachedVer.ratingsCount = newCount; 2482 } 2483 2484 // Refresh UI 2485 this.updateStarsUI(newAvg, newCount, versionId); 2486 2487 if (window.showToast) { 2488 window.showToast(isEn ? `Rating updated: ${score}/5 stars.` : `¡Gracias! Has valorado esta versión con ${score} de 5 estrellas.`, 'success'); 2489 } 2490 } catch (error) { 2491 console.error("Error al votar:", error); 2492 const errDetail = error?.message ? `: ${error.message}` : ''; 2493 if (window.showToast) { 2494 window.showToast((isEn ? "Error submitting rating" : "Error al guardar tu valoración") + errDetail, 'error'); 2495 } 2496 } finally { 2497 if (typeof window.hideLoadingModal === 'function') window.hideLoadingModal(); 2498 } 2499 } 2500} 2501 2502// Export a singleton instance 2503window.versionManager = new VersionManager(); 2504 2505if (typeof window !== 'undefined') { 2506 if (document.readyState === 'loading') { 2507 document.addEventListener('DOMContentLoaded', () => { 2508 window.versionManager?.syncWithCurrentViewerSelection(); 2509 }); 2510 } else { 2511 window.versionManager?.syncWithCurrentViewerSelection(); 2512 } 2513} 2514
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.