1// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2// js/index.js â Module principal de la page d'accueil 3// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4 5// ââ Imports ââââââââââââââââââââââââââââââââââââââââââââââ 6import { doc, getDoc, setDoc, addDoc, updateDoc, deleteDoc, 7 collection, query, where, orderBy, onSnapshot, serverTimestamp } 8 from 'https://www.gstatic.com/firebasejs/10.12.2/firebase-firestore.js'; 9import { CONFIG } from './config.js'; 10import { db } from './firebase-init.js'; 11import { userState, getDisplayName, getUserPhoto, 12 renderNavUser, initAuthUI, initAuthStateListener } from './auth.js'; 13import { showToast, initHamburger, initAvatarDropdownClose, initScrollReveal, escapeHtml } from './utils.js'; 14import { logActivity, listenActivity, initNotificationBell, initBugReport } from './activity.js'; 15import { registerRoute, processCurrentHash, setHash, clearHash, onDataReady, waitForData } from './router.js'; 16 17// ââ State (page-specific) ââââââââââââââââââââââââââââââââ 18const state = { 19 members: [], 20 sessions: [], 21 pastSessions: [], 22 proposals: [], 23 selectedBook: null, 24 searchResults: [], 25 expandedSessions: new Set(), 26 expandedPastSessions: new Set(), 27 themes: [], 28 tips: [], 29 tipSelectedBook: null, 30 limits: { monthlyProposals: 15, monthlyTips: 15, maxThemes: 20, freeThemes: 2, maxProposalsTotal: 100, anonProposals: 2 }, 31 currentBook: null, 32 currentComments: [], 33 currentCritiques: [], 34 pendingCritiques: [], 35}; 36 37// ââ Types de session âââââââââââââââââââââââââââââââââââââ 38const SESSION_TYPES = { 39 theme: { label: 'Thème libre', icon: 'ð' }, 40 'lecture-commune': { label: 'Lecture commune', icon: 'ð' }, 41 'rencontre-auteur': { label: 'Rencontre auteur', icon: 'âï¸' }, 42 debat: { label: 'Débat', icon: 'âï¸' } 43}; 44 45function getSessionType(s) { return s.type || 'theme'; } 46 47function sessionTypeBadge(type) { 48 if (type === 'theme') return ''; 49 const t = SESSION_TYPES[type]; 50 return t ? `<span class="session-type-badge type-${type}">${t.icon} ${t.label}</span>` : ''; 51} 52 53function getSessionTitle(s) { 54 const type = getSessionType(s); 55 switch (type) { 56 case 'lecture-commune': { 57 const b = (s.books || [])[0]; 58 return b ? b.title : (s.theme || 'Lecture commune'); 59 } 60 case 'rencontre-auteur': return s.authorName || s.theme || 'Rencontre auteur'; 61 case 'debat': return s.debateQuestion || s.theme || 'Débat'; 62 default: return s.theme || ''; 63 } 64} 65 66function getSessionSubtitle(s) { 67 const type = getSessionType(s); 68 switch (type) { 69 case 'lecture-commune': { 70 const b = (s.books || [])[0]; 71 return b ? b.author : (s.themeDescription || ''); 72 } 73 case 'rencontre-auteur': return s.authorBio ? (s.authorBio.length > 150 ? s.authorBio.substring(0, 150) + 'â¦' : s.authorBio) : ''; 74 case 'debat': return s.debateDescription || s.themeDescription || ''; 75 default: return s.themeDescription || ''; 76 } 77} 78 79function heroLabel(s) { 80 const type = getSessionType(s); 81 switch (type) { 82 case 'lecture-commune': return 'Prochaine lecture communeâ¦'; 83 case 'rencontre-auteur': return 'Prochaine rencontre avecâ¦'; 84 case 'debat': return 'Prochain débatâ¦'; 85 default: return s.date ? 'Prochaine session sur le thèmeâ¦' : 'Prochaine session'; 86 } 87} 88 89function heroThemeText(s) { 90 const type = getSessionType(s); 91 const title = getSessionTitle(s); 92 if (!title) return ''; 93 if (type === 'rencontre-auteur') return title; 94 return `« ${title} »`; 95} 96 97// Contenu de la zone livres pour la session principale (fond sombre) 98function renderFirstSessionBooks(s) { 99 const type = getSessionType(s); 100 const books = s.books || []; 101 102 if (type === 'lecture-commune') { 103 const b = books[0]; 104 if (!b) return `<p style="color:rgba(255,255,255,0.35);font-style:italic;
104font-family:'Playfair Display',serif;">Le livre sera annoncé bientôt.</p>`; 105 return ` 106 <div class="lc-featured"> 107 <div class="lc-book-main clickable" data-sid="${s.id}" data-stype="sessions" data-bi="0"> 108 <div class="lc-cover">${b.coverUrl ? `<img src="${b.coverUrl}" alt="">` : '<span class="lc-cover-ph">ð</span>'}</div> 109 <div class="lc-details"> 110 <div class="lc-title">${escapeHtml(b.title)}</div> 111 <div class="lc-author">${escapeHtml(b.author)}</div> 112 ${b.presenter ? `<div class="book-presenter">Présenté par ${escapeHtml(b.presenter)}</div>` : ''} 113 ${b.description ? `<div class="lc-desc">${escapeHtml(b.description).substring(0, 300)}${b.description.length > 300 ? 'â¦' : ''}</div>` : ''} 114 </div> 115 </div> 116 ${s.discussionGuide ? ` 117 <div class="lc-guide"> 118 <div class="lc-guide-label">Guide de discussion</div> 119 <div class="lc-guide-text">${escapeHtml(s.discussionGuide)}</div> 120 </div>` : ''} 121 </div>`; 122 } 123 124 if (type === 'rencontre-auteur') { 125 return ` 126 <div class="ra-featured"> 127 <div class="ra-author-card"> 128 ${s.authorPhoto ? `<img class="ra-photo" src="${s.authorPhoto}" alt="">` : `<div class="ra-photo-ph">âï¸</div>`} 129 <div class="ra-info"> 130 <div class="ra-name">${escapeHtml(s.authorName || '')}</div> 131 ${s.authorBio ? `<div class="ra-bio">${escapeHtml(s.authorBio)}</div>` : ''} 132 </div> 133 </div> 134 ${books.length > 0 ? ` 135 <div class="ra-bib-label">Bibliographie sélective</div> 136 <div class="books-grid"> 137 ${books.map((b, i) => ` 138 <div class="book-card clickable" data-sid="${s.id}" data-stype="sessions" data-bi="${i}"> 139 <div class="book-card-content"> 140 <div class="book-cover">${b.coverUrl ? `<img src="${b.coverUrl}" alt="">` : 'ð'}</div> 141 <div class="book-info"> 142 <div class="book-title">${escapeHtml(b.title)}</div> 143 <div class="book-author">${escapeHtml(b.author)}</div> 144 </div> 145 </div> 146 </div>`).join('')} 147 </div>` : ''} 148 </div>`; 149 } 150 151 if (type === 'debat') { 152 const pour = books[0]; 153 const contre = books[1]; 154 return ` 155 <div class="debat-arena"> 156 <div class="debat-side debat-pour"> 157 <div class="debat-side-label">Pour</div> 158 ${pour ? ` 159 <div class="book-card clickable" data-sid="${s.id}" data-stype="sessions" data-bi="0"> 160 <div class="book-card-content"> 161 <div class="book-cover">${pour.coverUrl ? `<img src="${pour.coverUrl}" alt="">` : 'ð'}</div> 162 <div class="book-info"> 163 <div class="book-title">${escapeHtml(pour.title)}</div> 164 <div class="book-author">${escapeHtml(pour.author)}</div> 165 ${pour.presenter ? `<div class="book-presenter">Présenté par ${escapeHtml(pour.presenter)}</div>` : ''} 166 </div> 167 </div> 168 </div>` : `<div class="debat-empty">Livre à annoncer</div>`} 169 </div> 170 <div class="debat-vs">VS</div> 171 <div class="debat-side debat-contre"> 172 <div class="debat-side-label">Contre</div> 173 ${contre ? ` 174 <div class="book-card clickable" data-sid="${s.id}" data-stype="sessions" data-bi="1"> 175 <div class="book-card-content"> 176 <div class="book-cover">${contre.coverUrl ? `<img src="${contre.coverUrl}" alt="">` : 'ð'}</div> 177 <div class="book-info"> 178 <div class="book-title">${escapeHtml(contre.title)}</div> 179 <div class="book-author">${escapeHtml(contre.author)}</div> 180 ${contre.presenter ? `<div class="book-presenter">Présenté par ${escapeHtml(contre.presenter)}</div>` : ''} 181 </div> 182 </div> 183 </div>` : `<div class="debat-empty">Livre à annoncer</div>`} 184 </div> 185 </div> 186 ${s.debateDescription ? `<div class="debat-desc">${escapeHtml(s.debateDescription)}</div>` : ''}`; 187 } 188 189 // type === 'theme' : retourne null â le code existant gère l'affichage 190 return null; 191} 192 193// Contenu carte compacte pour sessions suivantes (fond sombre) 194function renderUpcomingCardContent(s) { 195 const type = getSessionType(s); 196 const books = s.books || []; 197 198 if (type === 'lecture-commune') { 199 const b = books[0]; 200 return ` 201 ${sessionTypeBadge(type)} 202 <div class="upcoming-theme">
202${escapeHtml(b ? b.title : 'Livre à annoncer')}</div> 203 ${b ? `<div class="upcoming-desc">${escapeHtml(b.author)}</div>` : ''}`; 204 } 205 206 if (type === 'rencontre-auteur') { 207 return ` 208 ${sessionTypeBadge(type)} 209 <div class="upcoming-theme">${escapeHtml(s.authorName || 'Auteur à annoncer')}</div> 210 ${books.length > 0 ? `<div class="upcoming-desc">${books.length} livre${books.length > 1 ? 's' : ''} à découvrir</div>` : ''}`; 211 } 212 213 if (type === 'debat') { 214 const pour = books[0]; 215 const contre = books[1]; 216 return ` 217 ${sessionTypeBadge(type)} 218 <div class="upcoming-theme">${escapeHtml(s.debateQuestion || 'Question à annoncer')}</div> 219 ${pour && contre ? `<div class="upcoming-debat-mini"><span>${escapeHtml(pour.title)}</span> <span class="upcoming-vs">vs</span> <span>${escapeHtml(contre.title)}</span></div>` : ''}`; 220 } 221 222 return null; // theme : code existant 223} 224 225// Contenu pour la dernière session featured (fond clair) 226function renderFeaturedPastContent(s) { 227 const type = getSessionType(s); 228 const books = s.books || []; 229 230 if (type === 'lecture-commune') { 231 const b = books[0]; 232 return ` 233 ${b ? ` 234 <div class="lc-featured lc-featured-past"> 235 <div class="lc-book-main clickable" data-pid="${s.id}" data-bi="0"> 236 <div class="lc-cover">${b.coverUrl ? `<img src="${b.coverUrl}" alt="">` : '<span class="lc-cover-ph">ð</span>'}</div> 237 <div class="lc-details"> 238 <div class="lc-title">${escapeHtml(b.title)}</div> 239 <div class="lc-author">${escapeHtml(b.author)}</div> 240 ${b.presenter ? `<div class="book-presenter" style="color:var(--green);">Présenté par ${escapeHtml(b.presenter)}</div>` : ''} 241 </div> 242 </div> 243 ${s.discussionGuide ? ` 244 <div class="lc-guide lc-guide-past"> 245 <div class="lc-guide-label">Guide de discussion</div> 246 <div class="lc-guide-text">${escapeHtml(s.discussionGuide)}</div> 247 </div>` : ''} 248 </div>` : ''}`; 249 } 250 251 if (type === 'rencontre-auteur') { 252 return ` 253 <div class="ra-featured ra-featured-past"> 254 <div class="ra-author-card"> 255 ${s.authorPhoto ? `<img class="ra-photo" src="${s.authorPhoto}" alt="">` : `<div class="ra-photo-ph">âï¸</div>`} 256 <div class="ra-info"> 257 <div class="ra-name" style="color:var(--text);">${escapeHtml(s.authorName || '')}</div> 258 ${s.authorBio ? `<div class="ra-bio" style="color:var(--text-mid);">${escapeHtml(s.authorBio)}</div>` : ''} 259 </div> 260 </div> 261 ${books.length > 0 ? ` 262 <div class="ra-bib-label" style="color:var(--text-mid);">Bibliographie sélective</div> 263 <div class="last-session-books"> 264 ${books.map((b, i) => ` 265 <div class="book-card clickable" data-pid="${s.id}" data-bi="${i}"> 266 <div class="book-card-content"> 267 <div class="book-cover">${b.coverUrl ? `<img src="${b.coverUrl}" alt="">` : 'ð'}</div> 268 <div class="book-info"> 269 <div class="book-title">${escapeHtml(b.title)}</div> 270 <div class="book-author">${escapeHtml(b.author)}</div> 271 </div> 272 </div> 273 </div>`).join('')} 274 </div>` : ''} 275 </div>`; 276 } 277 278 if (type === 'debat') { 279 const pour = books[0]; 280 const contre = books[1]; 281 return ` 282 <div class="debat-arena debat-arena-past"> 283 <div class="debat-side debat-pour"> 284 <div class="debat-side-label" style="color:var(--green);">Pour</div> 285 ${pour ? ` 286 <div class="book-card clickable" data-pid="${s.id}" data-bi="0"> 287 <div class="book-card-content"> 288 <div class="book-cover">${pour.coverUrl ? `<img src="${pour.coverUrl}" alt="">` : 'ð'}</div> 289 <div class="book-info"> 290 <div class="book-title">${escapeHtml(pour.title)}</div> 291 <div class="book-author">${escapeHtml(pour.author)}</div> 292 </div> 293 </div> 294 </div>` : ''} 295 </div> 296 <div class="debat-vs" style="color:var(--terra);">VS</div> 297 <div class="debat-side debat-contre"> 298 <div class="debat-side-label" style="color:var(--terra);">Contre</div> 299 ${contre ? ` 300 <div class="book-card clickable" data-pid="${s.id}" data-bi="1"> 301 <div class="book-card-content"> 302 <div class="book-cover">${contre.coverUrl ? `<img src="${contre.coverUrl}" alt="">` : 'ð'}</div> 303 <div class="book-info"> 304 <div class="book-title">${escapeHtml(contre.title)}</div> 305 <div class="book-author">${escapeHtml(contre.author)}</div> 306 </div> 307 </div> 308 </div>` : ''} 309 </div> 310 </div> 311 ${s.debateDescription ? `<div class="debat-desc" style="color:var(--text-mid);">${escapeHtml(s.debateDescription)}</div>` : ''}`; 312 } 313 314 return null; // theme : code existant 315} 316 317// Contenu carte compacte passée (fond clair) 318function renderPastCardContent(s) { 319 const type = getSessionType(s); 320 const books = s.books || []; 321 322 if (type === 'lecture-commune') { 323 const b = books[0]; 324 return ` 325 <div class="past-date">${escapeHtml(s.date)}</div> 326 ${sessionTypeBadge(type)} 327 <div class="past-theme">${escapeHtml(b ? b.title : 'Lecture commune')}</div> 328 ${b ? `<div class="past-theme-desc">${escapeHtml(b.author)}</div>` : ''}
329 ${s.recap ? `<div class="past-recap">${s.recap}</div>` : ''}`; 330 } 331 332 if (type === 'rencontre-auteur') { 333 return ` 334 <div class="past-date">${escapeHtml(s.date)}</div> 335 ${sessionTypeBadge(type)} 336 <div class="past-theme">${escapeHtml(s.authorName || 'Rencontre auteur')}</div> 337 ${books.length > 0 ? `<ul class="past-books">${books.map((b, i) => 338 `<li class="clickable" data-pid="${s.id}" data-bi="${i}"><span><strong>${escapeHtml(b.title)}</strong> â ${escapeHtml(b.author)}</span></li>` 339 ).join('')}</ul>` : ''} 340 ${s.recap ? `<div class="past-recap">${s.recap}</div>` : ''}`; 341 } 342 343 if (type === 'debat') { 344 const pour = books[0]; 345 const contre = books[1]; 346 return ` 347 <div class="past-date">${escapeHtml(s.date)}</div> 348 ${sessionTypeBadge(type)} 349 <div class="past-theme">${escapeHtml(s.debateQuestion || 'Débat')}</div> 350 ${pour && contre ? `<div class="past-debat-mini"><span class="past-debat-pour">${escapeHtml(pour.title)}</span> <span class="past-debat-vs">vs</span> <span class="past-debat-contre">${escapeHtml(contre.title)}</span></div>` : ''} 351 ${s.recap ? `<div class="past-recap">${s.recap}</div>` : ''}`; 352 } 353 354 return null; // theme : code existant 355} 356 357// ââ Limites configurables ââââââââââââââââââââââââââââââââ 358async function loadLimits() { 359 try { 360 const snap = await getDoc(doc(db, 'settings', 'limits')); 361 if (snap.exists()) Object.assign(state.limits, snap.data()); 362 } catch(e) { /* garde les défauts */ } 363} 364async function saveLimits(data) { 365 await setDoc(doc(db, 'settings', 'limits'), data, { merge: true }); 366 Object.assign(state.limits, data); 367 showToast('Limites mises à jour !'); 368} 369 370// ââ Firestore : membres officiels ââââââââââââââââââââââââ 371function listenMembers() { 372 return onSnapshot(doc(db, 'settings', 'members'), snap => { 373 state.members = snap.exists() ? (snap.data().emails || []) : []; 374 userState.isMember = !!(userState.user && (state.members.includes(userState.user.email) || userState.isAdmin)); 375 }, () => { state.members = []; userState.isMember = false; showToast('Erreur de chargement des membres.'); }); 376} 377 378async function addMember(email) { 379 const members = [...new Set([...state.members, email.toLowerCase().trim()])]; 380 await setDoc(doc(db, 'settings', 'members'), { emails: members }); 381 showToast('Membre ajouté !'); 382} 383 384async function removeMember(email) { 385 const members = state.members.filter(e => e !== email); 386 await setDoc(doc(db, 'settings', 'members'), { emails: members }); 387 showToast('Membre retiré.'); 388} 389 390// ââ Firestore : sessions à venir âââââââââââââââââââââââââ 391function listenSessions() { 392 const q = query(collection(db, 'sessions'), orderBy('order', 'asc')); 393 return onSnapshot(q, snap => { 394 state.sessions = snap.docs.map(d => ({ id: d.id, ...d.data() })); 395 renderHero(); 396 renderAllSessions(); 397 renderSuggestSection(); 398 // Re-render admin si le panel est ouvert 399 if (!document.getElementById('admin-overlay').classList.contains('hidden')) { 400 renderAdminSessions(); 401 } 402 onDataReady('sessions'); 403 }, () => { 404 state.sessions = []; 405 renderHero(); 406 renderAllSessions(); 407 showToast('Erreur de chargement des sessions.'); 408 }); 409} 410 411async function saveSession(id, data) { 412 await setDoc(doc(db, 'sessions', id), data, { merge: true }); 413} 414 415async function addSession(type = 'theme') { 416 const maxOrder = state.sessions.reduce((m, s) => Math.max(m, s.order || 0), 0); 417 const base = { type, date: 'â', heure: 'â', lieu: CONFIG.club.lieu, books: [], order: maxOrder + 1 }; 418 switch (type) { 419 case 'lecture-commune': 420 Object.assign(base, { theme: 'Lecture commune', themeDescription: '', discussionGuide: '' }); 421 break; 422 case 'rencontre-auteur': 423 Object.assign(base, { theme: 'Rencontre auteur', themeDescription: '', authorName: '', authorBio: '', authorPhoto: '' }); 424 break; 425 case 'debat': 426 Object.assign(base, { theme: 'Débat', themeDescription: '', debateQuestion: '', debateDescription: '' }); 427 break; 428 default: 429 Object.assign(base, { theme: 'à définir', themeDescription: '' }); 430 } 431 await addDoc(collection(db, 'sessions'), base); 432 showToast('Nouvelle session créée !'); 433} 434 435async function archiveSession(id) { 436 const s = state.sessions.find(s => s.id === id); 437 if (!s || !confirm('Archiver cette session ?')) return; 438 const { id: _, ...data } = s; 439 await addDoc(collection(db, 'pastSessions'), { ...data, archivedAt: serverTimestamp() }); 440 await deleteDoc(doc(db, 'sessions', id)); 441 showToast('Session archivée !'); 442} 443 444async function deleteSession(id) { 445 if (!confirm('Supprimer cette session ? Cette action est irréversible.')) return; 446 await deleteDoc(doc(db, 'sessions', id)); 447 showToast('Session supprimée.'); 448} 449 450async function removeBookFromSession(sessionId, bookIndex) { 451 const s = state.sessions.find(s => s.id === sessionId); 452 if (!s) return; 453 const books = [...(s.books || [])]; 454 books.splice(bookIndex, 1); 455 await saveSession(sessionId, { books }); 456} 457 458async function swapSessionOrder(id1, id2) { 459 const s1 = state.sessions.find(s => s.id === id1); 460 const s2 = state.sessions.find(s => s.id === id2); 461 if (!s1 || !s2) return; 462 await saveSession(id1, { order: s2.order }); 463 await saveSession(id2, { order: s1.order }); 464} 465 466// ââ Firestore : sessions passées âââââââââââââââââââââââââ 467function listenPastSessions() { 468 const q = query(collection(db, 'pastSessions'), orderBy('archivedAt', 'desc')); 469 return onSnapshot(q, snap => { 470 state.pastSessions = snap.docs.map(d => ({ id: d.id, ...d.data() })); 471 renderPastSessions(); 472 if (!document.getElementById('admin-overlay').classList.contains('hidden')) { 473 renderAdminPastSessions(); 474 } 475 onDataReady('pastSessions'); 476 }, () => { 477 state.pastSessions = []; 478 renderPastSessions(); 479 showToast('Erreur de chargement des sessions passées.'); 480 }); 481} 482 483async function savePastSession(id, data) { 484 await setDoc(doc(db, 'pastSessions', id), data, { merge: true }); 485} 486 487async function deletePastSession(id) { 488 if (!confirm('Supprimer cette session archivée ?')) return; 489 await deleteDoc(doc(db, 'pastSessions', id)); 490 showToast('Session archivée supprimée.'); 491} 492 493async function addBookToPastSession(id, book) { 494 const s = state.pastSessions.find(s => s.id === id); 495 if (!s) return; 496 const books = [...(s.books || [])]; 497 books.push(book); 498 await savePastSession(id, { books }); 499 showToast('Livre ajouté !'); 500} 501 502async function removeBookFromPastSession(id, bookIndex) { 503 const s = state.pastSessions.find(s => s.id === id); 504 if (!s) return; 505 const books = [...(s.books || [])]; 506 books.splice(bookIndex, 1); 507 await savePastSession(id, { books }); 508} 509 510async function swapPastSessionOrder(id1, id2) { 511 const s1 = state.pastSessions.find(s => s.id === id1); 512 const s2 = state.pastSessions.find(s => s.id === id2); 513 if (!s1 || !s2) return; 514 await savePastSession(id1, { archivedAt: s2.archivedAt });
515 await savePastSession(id2, { archivedAt: s1.archivedAt }); 516} 517 518// ââ Firestore : thèmes / sondage âââââââââââââââââââââââââ 519function listenThemes() { 520 const q = query(collection(db, 'themes'), orderBy('votes', 'desc')); 521 return onSnapshot(q, snap => { 522 state.themes = snap.docs.map(d => ({ id: d.id, ...d.data() })); 523 renderPoll(); 524 }, err => { 525 console.error('Erreur chargement thèmes :', err); 526 state.themes = []; 527 renderPoll(); 528 }); 529} 530 531// Helper : normalise voters (supporte l'ancien format array d'UIDs et le nouveau format map {uid: name}) 532function getVotersMap(theme) { 533 const v = theme.voters || {}; 534 if (Array.isArray(v)) { 535 const map = {}; 536 v.forEach(uid => { map[uid] = 'Membre'; }); 537 return map; 538 } 539 return v; 540} 541function getVoteCount(theme) { 542 return Object.keys(getVotersMap(theme)).length; 543} 544 545async function voteTheme(id) { 546 if (!userState.user) { showToast('Connecte-toi pour voter.'); return; } 547 const theme = state.themes.find(t => t.id === id); 548 if (!theme) return; 549 const voters = getVotersMap(theme); 550 if (voters[userState.user.uid]) { showToast('Tu as déjà voté pour ce thème.'); return; } 551 voters[userState.user.uid] = getDisplayName(); 552 await updateDoc(doc(db, 'themes', id), { votes: Object.keys(voters).length, voters }); 553} 554 555async function unvoteTheme(id) { 556 if (!userState.user) return; 557 const theme = state.themes.find(t => t.id === id); 558 if (!theme) return; 559 const voters = getVotersMap(theme); 560 delete voters[userState.user.uid]; 561 await updateDoc(doc(db, 'themes', id), { votes: Object.keys(voters).length, voters }); 562} 563 564async function suggestTheme(name) { 565 if (!userState.user) { showToast('Connecte-toi pour proposer un thème.'); return; } 566 if (state.themes.length >= state.limits.maxThemes) { showToast(`Le nombre maximum de thèmes (${state.limits.maxThemes}) est atteint.`); return; } 567 const myThemes = state.themes.filter(t => t.proposedBy === userState.user.uid); 568 if (myThemes.length >= state.limits.freeThemes) { 569 // Au-delà de 2, passage en validation admin 570 await addDoc(collection(db, 'themes'), { name, votes: 0, voters: {}, proposedBy: userState.user.uid, status: 'pending' }); 571 showToast('Thème soumis à validation admin !'); 572 } else { 573 await addDoc(collection(db, 'themes'), { name, votes: 0, voters: {}, proposedBy: userState.user.uid, status: 'approved' }); 574 showToast('Thème proposé !'); 575 } 576} 577 578async function addTheme(name) { 579 // Admin : ajout direct 580 await addDoc(collection(db, 'themes'), { name, votes: 0, voters: {}, proposedBy: userState.user?.uid || null, status: 'approved' }); 581 showToast('Thème ajouté !'); 582} 583 584async function deleteTheme(id) { 585 if (!confirm('Supprimer ce thème ?')) return; 586 await deleteDoc(doc(db, 'themes', id)); 587 showToast('Thème supprimé.'); 588} 589 590async function resetAllVotes() { 591 if (!confirm('Remettre tous les votes à zéro ?')) return; 592 for (const t of state.themes) { 593 await updateDoc(doc(db, 'themes', t.id), { votes: 0, voters: {} }); 594 } 595 showToast('Votes remis à zéro !'); 596} 597 598// ââ Firestore : propositions âââââââââââââââââââââââââââââ 599let unsubProposals = null; 600let unsubPendingCritiques = null; 601function listenProposals() { 602 const q = query(collection(db, 'proposals'), where('status', '==', 'pending'), orderBy('proposedAt', 'desc')); 603 return onSnapshot(q, snap => { 604 state.proposals = snap.docs.map(d => ({ id: d.id, ...d.data() })); 605 renderAdminProposals(); 606 }, err => { 607 console.error('Erreur chargement propositions :', err); 608 state.proposals = []; 609 renderAdminProposals(); 610 }); 611} 612 613async function submitProposal(data) { 614 await addDoc(collection(db, 'proposals'), { 615 ...data, 616 proposedBy: { 617 uid: userState.user?.uid || null, 618 name: data.proposerName || (userState.user ? getDisplayName() : 'Anonyme'), 619 email: userState.user?.email || null, 620 photo: userState.user ? getUserPhoto() : null 621 }, 622 proposedAt: serverTimestamp(), 623 status: 'pending' 624 }); 625} 626 627async function approveProposal(p, sessionId) { 628 const session = state.sessions.find(s => s.id === sessionId); 629 if (!session) { showToast('Aucune session sélectionnée.'); return; } 630 const { id, title, author, description, coverUrl, googleBooksId } = p; 631 const books = [...(session.books || [])]; 632 books.push({ title, author, description: description || '', coverUrl: coverUrl || '', googleBooksId: googleBooksId || '', presenter: p.proposedBy?.name || '', note: p.note || '' }); 633 await saveSession(sessionId, { books }); 634 await updateDoc(doc(db, 'proposals', id), { status: 'approved' }); 635 showToast(`â « ${title} » ajouté à la session !`); 636 logActivity('proposal', `« ${title} » ajouté à la prochaine session`, `#book/${sessionId}/${books.length - 1}`); 637} 638 639async function rejectProposal(id) { 640 await updateDoc(doc(db, 'proposals', id), { status: 'rejected' }); 641 showToast('Proposition refusée.'); 642} 643 644// ââ Recherche de livres (Google Books + Open Library) ââââ 645let searchTimer = null; 646async function searchBooks(q) { 647 const key = CONFIG.googleBooksApiKey ? `&key=${CONFIG.googleBooksApiKey}` : ''; 648 const url = `https://www.googleapis.com/books/v1/volumes?q=${encodeURIComponent(q)}&maxResults=6${key}`; 649 try { 650 const res = await fetch(url); 651 const data = await res.json(); 652 return (data.items || []).map(item => { 653 const v = item.volumeInfo; 654 return { 655 googleBooksId: item.id, 656 title: v.title || '', 657 author: (v.authors || []).join(', '), 658 description: v.description || '', 659 coverUrl: (v.imageLinks?.thumbnail || '').replace('http://', 'https://') 660 }; 661 }); 662 } catch { return []; } 663} 664 665async function searchOpenLibrary(q) { 666 const url = `https://openlibrary.org/search.json?q=${encodeURIComponent(q)}&limit=6&fields=key,title,author_name,cover_i,first_publish_year`; 667 try { 668 const res = await fetch(url); 669 const data = await res.json(); 670 return (data.docs || []).map(d => ({ 671 openLibraryId: d.key || '', 672 title: d.title || '', 673 author: (d.author_name || []).join(', '), 674 description: '', 675 coverUrl: d.cover_i ? `https://covers.openlibrary.org/b/id/${d.cover_i}-M.jpg` : '' 676 })); 677 } catch { return []; } 678} 679
680function renderSearchResults(results, dropdown, query, onSelect) { 681 function bindResults(items) { 682 dropdown.querySelectorAll('.search-result-item').forEach(el => { 683 el.addEventListener('click', () => onSelect(items[+el.dataset.i])); 684 }); 685 } 686 687 function renderItems(items) { 688 dropdown.innerHTML = items.map((r, i) => ` 689 <div class="search-result-item" data-i="${i}"> 690 ${r.coverUrl ? `<img src="${r.coverUrl}" alt="">` : '<div class="no-cover">ð</div>'} 691 <div> 692 <div class="result-title">${escapeHtml(r.title)}</div> 693 <div class="result-author">${escapeHtml(r.author || 'Auteur inconnu')}</div> 694 </div> 695 </div>`).join(''); 696 bindResults(items); 697 } 698 699 function addOpenLibraryLink(label) { 700 const altLink = document.createElement('div'); 701 altLink.className = 'search-alt-link'; 702 altLink.textContent = label; 703 altLink.addEventListener('click', async (e) => { 704 e.stopPropagation(); 705 dropdown.innerHTML = '<div class="search-loading">Recherche sur Open Libraryâ¦</div>'; 706 const olResults = await searchOpenLibrary(query); 707 if (!olResults.length) { dropdown.innerHTML = '<div class="search-loading">Aucun résultat.</div>'; return; } 708 renderItems(olResults); 709 }); 710 dropdown.appendChild(altLink); 711 } 712 713 if (!results.length) { 714 dropdown.innerHTML = '<div class="search-loading">Aucun résultat sur Google Books.</div>'; 715 addOpenLibraryLink('ð Cliquer ici pour effectuer la recherche sur Open Library'); 716 return; 717 } 718 renderItems(results); 719 addOpenLibraryLink('ð Cliquer ici pour effectuer la recherche sur Open Library'); 720} 721 722// ââ Firestore : conseils lecture âââââââââââââââââââââââââ 723function listenTips() { 724 const q = query(collection(db, 'tips'), orderBy('createdAt', 'desc')); 725 return onSnapshot(q, snap => { 726 state.tips = snap.docs.map(d => ({ id: d.id, ...d.data() })); 727 renderTips(); 728 onDataReady('tips'); 729 }, () => { state.tips = []; renderTips(); showToast('Erreur de chargement des conseils.'); }); 730} 731 732async function addTip(data) { 733 const isAdm = userState.isAdmin; 734 const docRef = await addDoc(collection(db, 'tips'), { 735 ...data, 736 recommendedBy: { 737 uid: userState.user?.uid || null, 738 name: data.recommenderName || getDisplayName(), 739 isAdmin: isAdm 740 }, 741 status: isAdm ? 'approved' : 'pending', 742 createdAt: serverTimestamp() 743 }); 744 showToast(isAdm ? 'Conseil ajouté !' : 'Conseil envoyé, en attente de validation !'); 745 if (isAdm) logActivity('tip', `Nouveau conseil : « ${data.title || 'un livre'} »`, `#tip/${docRef.id}`); 746} 747 748async function approveTip(id) { 749 await setDoc(doc(db, 'tips', id), { status: 'approved' }, { merge: true }); 750 showToast('Conseil approuvé !'); 751 const tip = state.tips.find(t => t.id === id); 752 logActivity('tip', `Nouveau conseil : « ${tip?.title || 'un livre'} »`, `#tip/${id}`); 753} 754 755async function rejectTip(id) { 756 if (!confirm('Refuser ce conseil ?')) return; 757 await deleteDoc(doc(db, 'tips', id)); 758 showToast('Conseil refusé.'); 759} 760 761async function deleteTip(id) { 762 if (!confirm('Supprimer ce conseil ?')) return; 763 await deleteDoc(doc(db, 'tips', id)); 764 showToast('Conseil supprimé.'); 765} 766 767// ââ Firestore : commentaires âââââââââââââââââââââââââââââ 768let unsubComments = null; 769 770function listenComments(sessionId, sessionType, bookIndex) { 771 if (unsubComments) { unsubComments(); unsubComments = null; } 772 const q = query( 773 collection(db, 'comments'), 774 where('sessionId', '==', sessionId), 775 orderBy('createdAt', 'asc') 776 ); 777 unsubComments = onSnapshot(q, snap => { 778 // Filtrer côté client par sessionType et bookIndex 779 state.currentComments = snap.docs 780 .map(d => ({ id: d.id, ...d.data() })) 781 .filter(c => c.sessionType === sessionType && c.bookIndex === bookIndex); 782 renderModalComments(); 783 }, err => { 784 console.error('Erreur chargement commentaires :', err); 785 state.currentComments = []; 786 renderModalComments(); 787 }); 788} 789 790async function addComment(sessionId, sessionType, bookIndex, text) { 791 await addDoc(collection(db, 'comments'), { 792 sessionId, sessionType, bookIndex, text, 793 author: { uid: userState.user.uid, name: getDisplayName(), photo: getUserPhoto() }, 794 createdAt: serverTimestamp() 795 }); 796 const book = state.currentBook; 797 const deepLink = `#${sessionType === 'pastSessions' ? 'pastbook' : 'book'}/${sessionId}/${bookIndex}`;
798 logActivity('comment', `Commentaire sur « ${book?.title || 'un livre'} »`, deepLink); 799} 800 801async function deleteComment(commentId) { 802 await deleteDoc(doc(db, 'comments', commentId)); 803} 804 805// ââ Firestore : critiques ââââââââââââââââââââââââââââââââ 806let unsubCritiques = null; 807 808function listenCritiques(sessionId, sessionType, bookIndex) { 809 if (unsubCritiques) { unsubCritiques(); unsubCritiques = null; } 810 if (!sessionId) { state.currentCritiques = []; return; } 811 // Query simple par sessionId, filtre côté client pour éviter un index composite complexe 812 const q = query( 813 collection(db, 'critiques'), 814 where('sessionId', '==', sessionId), 815 orderBy('createdAt', 'asc') 816 ); 817 unsubCritiques = onSnapshot(q, snap => { 818 state.currentCritiques = snap.docs 819 .map(d => ({ id: d.id, ...d.data() })) 820 .filter(c => c.sessionType === sessionType && c.bookIndex === bookIndex && c.status === 'approved'); 821 renderModalCritiques(); 822 }, err => { 823 console.error('Erreur critiques (index manquant ?) :', err); 824 state.currentCritiques = []; 825 renderModalCritiques(); 826 }); 827} 828 829async function submitCritique(sessionId, sessionType, bookIndex, authorName, text) { 830 const directPublish = userState.isAdmin || userState.isMember; 831 const data = { 832 sessionId, sessionType, bookIndex, authorName, text, 833 status: directPublish ? 'approved' : 'pending', 834 createdAt: serverTimestamp() 835 }; 836 if (userState.user) data.authorUid = userState.user.uid; 837 await addDoc(collection(db, 'critiques'), data); 838 if (directPublish) { 839 const book = state.currentBook; 840 const deepLink = `#${sessionType === 'pastSessions' ? 'pastbook' : 'book'}/${sessionId}/${bookIndex}`; 841 logActivity('critique', `Critique de « ${book?.title || 'un livre'} » par ${authorName}`, deepLink); 842 } 843} 844 845async function updateCritique(id, data) { 846 await setDoc(doc(db, 'critiques', id), data, { merge: true }); 847} 848 849async function deleteCritique(id) { 850 if (!confirm('Supprimer cette critique ?')) return; 851 await deleteDoc(doc(db, 'critiques', id)); 852 showToast('Critique supprimée.'); 853} 854 855function listenPendingCritiques() { 856 const q = query(collection(db, 'critiques'), where('status', '==', 'pending'), orderBy('createdAt', 'asc')); 857 return onSnapshot(q, snap => { 858 state.pendingCritiques = snap.docs.map(d => ({ id: d.id, ...d.data() })); 859 const badge = document.getElementById('critiques-pending-count'); 860 if (badge) badge.textContent = state.pendingCritiques.length > 0 ? `(${state.pendingCritiques.length})` : ''; 861 if (!document.getElementById('admin-overlay').classList.contains('hidden')) { 862 renderAdminCritiques(); 863 } 864 }, err => { 865 console.error('Erreur critiques pending (index manquant ?) :', err); 866 state.pendingCritiques = []; 867 }); 868} 869 870async function rejectCritique(id) { 871 if (!confirm('Refuser cette critique ?')) return; 872 await deleteDoc(doc(db, 'critiques', id)); 873 showToast('Critique refusée.'); 874} 875 876// ââ Firestore : réactions ââââââââââââââââââââââââââââââââ 877const REACTION_TYPES = [ 878 { key: 'loved', icon: 'â¤ï¸', label: "J'ai adoré" }, 879 { key: 'wantRead', icon: 'ð', label: 'Envie de le lire' }, 880 { key: 'notForMe', icon: 'ð ', label: 'Pas pour moi' } 881]; 882 883// Chaque réaction est stockée comme un objet { uid: displayName } 884async function toggleReaction(sessionId, bookIndex, reactionKey) { 885 if (!userState.user) return; 886 const uid = userState.user.uid; 887 const name = getDisplayName(); 888 const docRef = doc(db, 'reactions', `${sessionId}_${bookIndex}`); 889 const snap = await getDoc(docRef); 890 const data = snap.exists() ? snap.data() : {}; 891 // Construire le document complet avec les 3 types de réactions 892 const fullDoc = {}; 893 for (const rt of REACTION_TYPES) { 894 fullDoc[rt.key] = { ...(data[rt.key] || {}) }; 895 // Retirer l'utilisateur de ce type 896 delete fullDoc[rt.key][uid]; 897 } 898 // Si c'est un toggle-on (l'utilisateur n'était pas sur cette réaction), l'ajouter 899 const wasActive = !!(data[reactionKey] || {})[uid]; 900 if (!wasActive) { 901 fullDoc[reactionKey][uid] = name; 902 } 903 // Ãcriture complète (pas de merge) pour éviter les doublons 904 await setDoc(docRef, fullDoc); 905} 906 907let unsubReactions = null; 908let currentReactionsData = {}; 909 910function listenReactions(sessionId, bookIndex) { 911 if (unsubReactions) { unsubReactions(); unsubReactions = null; } 912 currentReactionsData = {}; 913 if (!sessionId) { renderModalReactions(); return; } 914 const docRef = doc(db, 'reactions', `${sessionId}_${bookIndex}`); 915 unsubReactions = onSnapshot(docRef, snap => { 916 currentReactionsData = snap.exists() ? snap.data() : {}; 917 renderModalReactions(); 918 }, () => { 919 currentReactionsData = {}; 920 renderModalReactions(); 921 showToast('Erreur de chargement des réactions.'); 922 }); 923} 924 925// ââ Modale livre âââââââââââââââââââââââââââââââââââââââââ 926function openBookModal(book, sessionId, sessionType, bookIndex) { 927 state.currentBook = { ...book, sessionId, sessionType, bookIndex }; 928 if (sessionId) { 929 setHash(`${sessionType === 'pastSessions' ? 'pastbook' : 'book'}/${sessionId}/${bookIndex}`); 930 } 931 const modal = document.getElementById('book-modal'); 932 const cover = document.getElementById('book-modal-cover'); 933 if (book.coverUrl) { cover.src = book.coverUrl; cover.style.display = ''; } 934 else { cover.style.display = 'none'; } 935 document.getElementById('book-modal-title').textContent = book.title; 936 document.getElementById('book-modal-author').textContent = book.author; 937 document.getElementById('book-modal-presenter').textContent = book.presenter ? `Présenté par ${book.presenter}` : ''; 938 const hasDesc = !!book.description; 939 document.getElementById('book-modal-desc-label').style.display = hasDesc ? '' : 'none'; 940 document.getElementById('book-modal-desc').textContent = hasDesc ? book.description : 'Aucun résumé éditeur disponible.'; 941 modal.classList.remove('hidden'); 942 const isPast = sessionType === 'pastSessions'; 943 listenComments(sessionId, sessionType, bookIndex);
944 renderModalCommentForm(); 945 if (isPast && sessionId) { 946 listenCritiques(sessionId, sessionType, bookIndex); 947 listenReactions(sessionId, bookIndex); 948 renderModalCritiqueForm(); 949 } else { 950 document.getElementById('book-modal-reactions').innerHTML = ''; 951 document.getElementById('book-modal-critiques').innerHTML = ''; 952 document.getElementById('book-modal-critique-form').innerHTML = ''; 953 } 954} 955 956function closeBookModal() { 957 document.getElementById('book-modal').classList.add('hidden'); 958 if (unsubComments) { unsubComments(); unsubComments = null; } 959 if (unsubCritiques) { unsubCritiques(); unsubCritiques = null; } 960 if (unsubReactions) { unsubReactions(); unsubReactions = null; } 961 clearHash(); 962 state.currentBook = null; 963 state.currentComments = []; 964 state.currentCritiques = []; 965 currentReactionsData = {}; 966} 967 968function renderModalComments() { 969 const el = document.getElementById('book-modal-comments'); 970 if (state.currentComments.length === 0) { 971 el.innerHTML = '<p class="no-comments">Aucun commentaire pour le moment.</p>'; 972 return; 973 } 974 el.innerHTML = state.currentComments.map(c => { 975 const date = c.createdAt?.toDate ? c.createdAt.toDate().toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' }) : ''; 976 const canDelete = userState.user && (userState.user.uid === c.author?.uid || userState.isAdmin); 977 return ` 978 <div class="comment-item"> 979 ${c.author?.photo 980 ? `<img class="comment-avatar" src="${c.author.photo}" alt="">` 981 : `<div class="comment-avatar-ph">ð¤</div>`} 982 <div class="comment-body"> 983 <div class="comment-meta"> 984 <strong>${escapeHtml(c.author?.name || 'Anonyme')}</strong> · ${date} 985 ${canDelete ? `<button class="comment-delete" data-cid="${c.id}" title="Supprimer">â</button>` : ''} 986 </div> 987 <div class="comment-text">${escapeHtml(c.text)}</div> 988 </div> 989 </div>`; 990 }).join(''); 991 992 el.querySelectorAll('.comment-delete').forEach(btn => { 993 btn.addEventListener('click', () => { 994 if (confirm('Supprimer ce commentaire ?')) deleteComment(btn.dataset.cid); 995 }); 996 }); 997} 998
999function renderModalCommentForm() { 1000 const el = document.getElementById('book-modal-comment-form'); 1001 if (!userState.user) { 1002 el.innerHTML = '<p class="comment-login">Connecte-toi pour laisser un commentaire.</p>'; 1003 return; 1004 } 1005 el.innerHTML = ` 1006 <div class="comment-form"> 1007 <textarea id="comment-input" placeholder="Ton commentaireâ¦"></textarea> 1008 <button id="btn-send-comment">Envoyer</button> 1009 </div>`; 1010 document.getElementById('btn-send-comment').addEventListener('click', async () => { 1011 const text = document.getElementById('comment-input').value.trim(); 1012 if (!text) return; 1013 const b = state.currentBook; 1014 await addComment(b.sessionId, b.sessionType, b.bookIndex, text); 1015 document.getElementById('comment-input').value = ''; 1016 }); 1017} 1018 1019// ââ Render : réactions (modale) ââââââââââââââââââââââââââ 1020function renderModalReactions() { 1021 const el = document.getElementById('book-modal-reactions'); 1022 const b = state.currentBook; 1023 if (!b || b.sessionType !== 'pastSessions' || !b.sessionId) { el.innerHTML = ''; return; } 1024 const uid = userState.user?.uid; 1025 if (!uid) { 1026 el.innerHTML = `<p class="reactions-login">Connecte-toi pour réagir à ce livre.</p>`; 1027 return; 1028 } 1029 el.innerHTML = `<div class="reactions-bar"> 1030 ${REACTION_TYPES.map(rt => { 1031 const voters = currentReactionsData[rt.key] || {}; 1032 const names = Object.values(voters); 1033 const count = names.length; 1034 const active = !!voters[uid]; 1035 const tooltip = count > 0 ? names.join(', ') : ''; 1036 return `<button class="reaction-btn ${active ? 'active' : ''}" data-rk="${rt.key}" ${tooltip ? `title="${escapeHtml(tooltip)}"` : ''}> 1037 <span class="reaction-icon">${rt.icon}</span> 1038 <span>${rt.label}</span> 1039 ${count >
1039 0 ? `<span class="reaction-count">${count}</span>` : ''} 1040 </button>`; 1041 }).join('')} 1042 </div>`; 1043 el.querySelectorAll('.reaction-btn').forEach(btn => { 1044 btn.addEventListener('click', async () => { 1045 btn.disabled = true; 1046 await toggleReaction(b.sessionId, b.bookIndex, btn.dataset.rk); 1047 btn.disabled = false; 1048 }); 1049 }); 1050} 1051 1052// ââ Render : critiques (modale) ââââââââââââââââââââââââââ 1053function renderModalCritiques() { 1054 const el = document.getElementById('book-modal-critiques'); 1055 if (state.currentCritiques.length === 0) { el.innerHTML = ''; return; } 1056 const uid = userState.user?.uid; 1057 el.innerHTML = ` 1058 <div class="critiques-section"> 1059 <p class="critiques-title">Critiques & présentations</p> 1060 ${state.currentCritiques.map(c => { 1061 const isAuthor = uid && c.authorUid && c.authorUid === uid; 1062 const canManage = userState.isAdmin || isAuthor; 1063 return ` 1064 <div class="critique-item" data-cid="${c.id}"> 1065 <div class="critique-header"> 1066 <div class="critique-author">${escapeHtml(c.authorName || 'Anonyme')}</div> 1067 ${canManage ? `<div class="critique-actions"> 1068 <button class="critique-action-btn critique-edit-btn" data-cid="${c.id}" title="Modifier">â</button> 1069 <button class="critique-action-btn critique-delete-btn" data-cid="${c.id}" title="Supprimer">â</button> 1070 </div>` : ''} 1071 </div> 1072 <div class="critique-text" id="critique-text-${c.id}">${escapeHtml(c.text)}</div> 1073 <div class="critique-edit-form hidden" id="critique-edit-${c.id}"> 1074 <textarea class="critique-edit-textarea">${escapeHtml(c.text)}</textarea> 1075 <div class="critique-edit-btns"> 1076 <button class="critique-save-btn" data-cid="${c.id}">Enregistrer</button> 1077 <button class="critique-cancel-btn" data-cid="${c.id}">Annuler</button> 1078 </div> 1079 ${isAuthor && !userState.isAdmin && !userState.isMember ? `<p class="critique-hint">La modification sera re-soumise à validation.</p>` : ''} 1080 </div> 1081 </div>`; 1082 }).join('')} 1083 </div>`; 1084 1085 // Supprimer 1086 el.querySelectorAll('.critique-delete-btn').forEach(btn => { 1087 btn.addEventListener('click', () => deleteCritique(btn.dataset.cid)); 1088 }); 1089 // Ouvrir l'édition 1090 el.querySelectorAll('.critique-edit-btn').forEach(btn => { 1091 btn.addEventListener('click', () => { 1092 const cid = btn.dataset.cid; 1093 document.getElementById(`critique-text-${cid}`).classList.add('hidden'); 1094 document.getElementById(`critique-edit-${cid}`).classList.remove('hidden'); 1095 btn.closest('.critique-actions').classList.add('hidden'); 1096 }); 1097 }); 1098 // Annuler l'édition 1099 el.querySelectorAll('.critique-cancel-btn').forEach(btn => { 1100 btn.addEventListener('click', () => { 1101 const cid = btn.dataset.cid; 1102 document.getElementById(`critique-text-${cid}`).classList.remove('hidden'); 1103 document.getElementById(`critique-edit-${cid}`).classList.add('hidden'); 1104 btn.closest('.critique-item')?.querySelector('.critique-actions')?.classList.remove('hidden'); 1105 }); 1106 }); 1107 // Sauvegarder 1108 el.querySelectorAll('.critique-save-btn').forEach(btn => { 1109 btn.addEventListener('click', async () => { 1110 const cid = btn.dataset.cid; 1111 const textarea = document.getElementById(`critique-edit-${cid}`).querySelector('textarea'); 1112 const newText = textarea.value.trim(); 1113 if (!newText) return; 1114 const c = state.currentCritiques.find(x => x.id === cid); 1115 const isAuthor = uid && c?.authorUid && c.authorUid === uid; 1116 if (userState.isAdmin || userState.isMember) { 1117 // Admin ou membre officiel : modification directe, reste approved 1118 await updateCritique(cid, { text: newText }); 1119 showToast('Critique modifiée.'); 1120 } else if (isAuthor) { 1121 // Auteur non-membre : re-soumis à validation 1122 await updateCritique(cid, { text: newText, status: 'pending' }); 1123 showToast('Modification soumise à validation.'); 1124 } 1125 }); 1126 }); 1127} 1128 1129function renderModalCritiqueForm() { 1130 const el = document.getElementById('book-modal-critique-form'); 1131 const b = state.currentBook; 1132 if (!b || b.sessionType !== 'pastSessions' || !b.sessionId) { el.innerHTML = ''; return; } 1133 el.innerHTML = ` 1134 <div class="critique-form"> 1135 <p class="critique-form-title">Soumettre une critique ou présentation</p> 1136 <input type="text" id="critique-author" placeholder="Votre nom" value="${userState.user ? escapeHtml(getDisplayName()) : ''}"> 1137 <textarea id="critique-text" placeholder="Votre critique ou présentation du livreâ¦"></textarea> 1138 ${userState.isAdmin || userState.isMember 1139 ? `<p class="critique-hint">En tant que membre, votre texte sera publié directement.</p>` 1140 : `<p class="critique-hint">Votre texte sera soumis à validation avant publication.</p>`} 1141 <button id="btn-send-critique">${userState.isAdmin || userState.isMember ? 'Publier' : 'Soumettre'}</button> 1142 </div>`; 1143 document.getElementById('btn-send-critique').addEventListener('click', async () => { 1144 const author = document.getElementById('critique-author').value.trim(); 1145 const text = document.getElementById('critique-text').value.trim(); 1146 if (!author || !text) { showToast('Veuillez remplir tous les champs.'); return; } 1147 try { 1148 await submitCritique(b.sessionId, b.sessionType, b.bookIndex, author, text); 1149 document.getElementById('critique-text').value = ''; 1150 showToast(userState.isAdmin || userState.isMember ? 'Critique publiée !' : 'Critique soumise
1150! Elle sera visible après validation.'); 1151 } catch (err) { 1152 console.error('Erreur soumission critique :', err); 1153 showToast('Erreur lors de la soumission. Vérifiez la console.'); 1154 } 1155 }); 1156} 1157 1158// ââ Render : admin critiques âââââââââââââââââââââââââââââ 1159function renderAdminCritiques() { 1160 const el = document.getElementById('admin-critiques-list'); 1161 const pending = state.pendingCritiques; 1162 if (pending.length === 0) { 1163 el.innerHTML = `<p style="font-size:0.85rem;color:var(--text-light);font-style:italic;text-align:center;padding:1.5rem;">Aucune critique en attente.</p>`; 1164 return; 1165 } 1166 el.innerHTML = pending.map(c => { 1167 const sessionLabel = findSessionLabel(c.sessionId, c.bookIndex); 1168 return ` 1169 <div class="admin-critique-card" data-cid="${c.id}"> 1170 <div class="admin-critique-meta"><strong>${escapeHtml(c.authorName || 'Anonyme')}</strong> â ${escapeHtml(sessionLabel)}</div> 1171 <textarea class="admin-critique-edit" data-cid="${c.id}">${escapeHtml(c.text)}</textarea> 1172 <div class="admin-critique-actions"> 1173 <button class="btn-approve-critique" data-cid="${c.id}">â Approuver</button> 1174 <button class="btn-reject-critique" data-cid="${c.id}">â Refuser</button> 1175 </div> 1176 </div>`; 1177 }).join(''); 1178 el.querySelectorAll('.btn-approve-critique').forEach(btn => { 1179 btn.addEventListener('click', async () => { 1180 const cid = btn.dataset.cid; 1181 const textarea = el.querySelector(`.admin-critique-edit[data-cid="${cid}"]`); 1182 const newText = textarea.value.trim(); 1183 if (!newText) return; 1184 const critiqueData = state.pendingCritiques.find(c => c.id === cid); 1185 await updateCritique(cid, { text: newText, status: 'approved' }); 1186 showToast('Critique approuvée !'); 1187 if (critiqueData) { 1188 const label = findSessionLabel(critiqueData.sessionId, critiqueData.bookIndex); 1189 logActivity('critique', `Critique approuvée : ${label}`, `#${critiqueData.sessionType === 'pastSessions' ? 'pastbook' : 'book'}/${critiqueData.sessionId}/${critiqueData.bookIndex}`); 1190 } 1191 }); 1192 }); 1193 el.querySelectorAll('.btn-reject-critique').forEach(btn => { 1194 btn.addEventListener('click', () => rejectCritique(btn.dataset.cid)); 1195 }); 1196} 1197 1198function findSessionLabel(sessionId, bookIndex) { 1199 const p = state.pastSessions.find(s => s.id === sessionId); 1200 if (!p) return 'Session inconnue'; 1201 const book = (p.books || [])[bookIndex]; 1202 return `${p.theme || '?'} â ${book ? book.title : `Livre #${bookIndex + 1}`}`; 1203} 1204 1205// ââ Render : hero ââââââââââââââââââââââââââââââââââââââââ 1206function renderHero() { 1207 const s = state.sessions[0]; 1208 if (!s) { 1209 document.getElementById('hero-next-label').textContent = ''; 1210 document.getElementById('hero-theme').textContent = ''; 1211 document.getElementById('hero-desc').textContent = 'Le prochain thème sera annoncé bientôt.'; 1212 document.getElementById('hero-meta').innerHTML = ''; 1213 return; 1214 } 1215 document.getElementById('hero-next-label').textContent = heroLabel(s); 1216 document.getElementById('hero-theme').textContent = heroThemeText(s); 1217 document.getElementById('hero-desc').textContent = getSessionSubtitle(s); 1218 document.getElementById('hero-meta').innerHTML = 1219 `<span class="badge badge-green">ð ${escapeHtml(s.date)}</span> 1220 <span class="badge badge-terra">ð ${escapeHtml(s.heure)}</span> 1221 <span class="badge badge-dark">ð ${escapeHtml(s.lieu || CONFIG.club.lieu)}</span>`; 1222} 1223 1224// ââ Render : sessions à venir ââââââââââââââââââââââââââââ 1225function renderAllSessions() { 1226 const first = state.sessions[0]; 1227 // Première session : affichage principal 1228 if (!first) { 1229 document.getElementById('session-theme-title').textContent = 'Prochain thème'; 1230 document.getElementById('session-desc-text').textContent = ''; 1231 document.getElementById('session-date').textContent = ''; 1232 document.getElementById('session-time').textContent = ''; 1233 document.getElementById('session-location').textContent = ''; 1234 document.getElementById('books-grid').innerHTML = `<p style="color:rgba(255,255,255,0.35);font-style:italic;
1234font-family:'Playfair Display',serif;">Aucune session prévue pour le moment.</p>`; 1235 document.getElementById('upcoming-sessions').innerHTML = ''; 1236 return; 1237 } 1238 const firstType = getSessionType(first); 1239 // En-tête : adapté au type 1240 const titlePrefix = firstType === 'theme' ? 'Thème : ' : (firstType === 'debat' ? '' : ''); 1241 document.getElementById('session-theme-title').innerHTML = `${sessionTypeBadge(firstType)} ${titlePrefix}${escapeHtml(getSessionTitle(first))}`; 1242 document.getElementById('session-desc-text').textContent = getSessionSubtitle(first); 1243 document.getElementById('session-date').textContent = `ð ${first.date}`; 1244 document.getElementById('session-time').textContent = `ð ${first.heure}`; 1245 document.getElementById('session-location').textContent = `ð ${first.lieu || CONFIG.club.lieu}`; 1246 1247 const books = first.books || []; 1248 const booksGrid = document.getElementById('books-grid'); 1249 const revealContainer = document.getElementById('books-reveal-all'); 1250 if (revealContainer) revealContainer.remove(); 1251 1252 // Contenu : type-specific ou thème par défaut 1253 const customContent = renderFirstSessionBooks(first); 1254 if (customContent !== null) { 1255 booksGrid.innerHTML = customContent; 1256 // Events clic sur les book-card et lc-book-main 1257 booksGrid.querySelectorAll('.book-card.clickable, .lc-book-main.clickable').forEach(card => { 1258 card.addEventListener('click', () => { 1259 const sid = card.dataset.sid; 1260 const bi = +card.dataset.bi; 1261 const s = state.sessions.find(s => s.id === sid); 1262 if (s) openBookModal(s.books[bi], sid, card.dataset.stype || 'sessions', bi); 1263 }); 1264 }); 1265 } else { 1266 // Type thème : affichage original avec spoilers 1267 booksGrid.innerHTML = books.length === 0 1268 ? `<p style="color:rgba(255,255,255,0.35);font-style:italic;font-family:'Playfair Display',serif;">Aucun livre annoncé pour le moment.</p>` 1269 : books.map((b, i) => ` 1270 <div class="book-card clickable spoiler" data-sid="${first.id}" data-stype="sessions" data-bi="${i}"> 1271 <div class="book-card-spoiler-overlay"> 1272 ${b.note ? `<div class="spoiler-teaser">"${escapeHtml(b.note)}"</div>` : `<div class="spoiler-teaser">Un livre à découvrirâ¦</div>`} 1273 <div class="spoiler-hint">Cliquer pour révéler</div> 1274 </div> 1275 <div class="book-card-content"> 1276 <div class="book-cover"> 1277 ${b.coverUrl ? `<img src="${b.coverUrl}" alt="">` : 'ð'} 1278 </div> 1279 <div class="book-info"> 1280 <div class="book-title">${escapeHtml(b.title)}</div> 1281 <div class="book-author">${escapeHtml(b.author)}</div> 1282 ${b.presenter ? `<div class="book-presenter">Présenté par ${escapeHtml(b.presenter)}</div>` : ''} 1283 </div> 1284 </div> 1285 </div>`).join(''); 1286 if (books.length > 0) { 1287 const revealBtn = document.createElement('div'); 1288 revealBtn.id = 'books-reveal-all'; 1289 revealBtn.style.textAlign = 'center'; 1290 revealBtn.style.marginTop = '0.75rem'; 1291 revealBtn.innerHTML = `<button class="btn-reveal-all">Tout révéler</button>`; 1292 booksGrid.after(revealBtn); 1293 revealBtn.querySelector('.btn-reveal-all').addEventListener('click', () => { 1294 booksGrid.querySelectorAll('.book-card.spoiler').forEach(c => c.classList.remove('spoiler')); 1295 revealBtn.style.display = 'none'; 1296 }); 1297 } 1298 booksGrid.querySelectorAll('.book-card.clickable').forEach(card => { 1299 card.addEventListener('click', () => { 1300 if (card.classList.contains('spoiler')) { card.classList.remove('spoiler'); return; } 1301 const s = state.sessions.find(s => s.id === card.dataset.sid); 1302 if (s) openBookModal(s.books[+card.dataset.bi], card.dataset.sid, card.dataset.stype, +card.dataset.bi); 1303 }); 1304 }); 1305 } 1306 1307 // CTA : seulement pour les sessions thème 1308 const cta = document.getElementById('session-cta'); 1309 if (firstType === 'theme') { 1310 cta.innerHTML = `<a href="#" id="btn-open-propose-modal">ð Proposer un livre pour cette session</a>`;
1311 document.getElementById('btn-open-propose-modal').addEventListener('click', (e) => { 1312 e.preventDefault(); 1313 openProposeModal(); 1314 }); 1315 } else { 1316 cta.innerHTML = ''; 1317 } 1318 1319 // Sessions suivantes 1320 const rest = state.sessions.slice(1); 1321 const upEl = document.getElementById('upcoming-sessions'); 1322 upEl.innerHTML = rest.length === 0 ? '' : ` 1323 <div class="upcoming-separator"> 1324 <p class="section-label">Sessions suivantes</p> 1325 <div class="upcoming-grid"> 1326 ${rest.map(s => { 1327 const customCard = renderUpcomingCardContent(s); 1328 if (customCard !== null) { 1329 return `<div class="upcoming-card"> 1330 <div class="upcoming-date">ð ${escapeHtml(s.date)} · ð ${escapeHtml(s.heure)} · ð ${escapeHtml(s.lieu || CONFIG.club.lieu)}</div> 1331 ${customCard} 1332 </div>`; 1333 } 1334 // Type thème : affichage original 1335 return `<div class="upcoming-card"> 1336 <div class="upcoming-date">ð ${escapeHtml(s.date)} · ð ${escapeHtml(s.heure)} · ð ${escapeHtml(s.lieu || CONFIG.club.lieu)}</div> 1337 <div class="upcoming-theme">${escapeHtml(s.theme)}</div> 1338 ${s.themeDescription ? `<div class="upcoming-desc">${escapeHtml(s.themeDescription)}</div>` : ''} 1339 ${(s.books || []).length > 0 ? ` 1340 <ul class="upcoming-books"> 1341 ${s.books.map((b, i) => `<li class="clickable spoiler-li" data-sid="${s.id}" data-bi="${i}"> 1342 <span class="upcoming-spoiler-tag">Cliquer pour révéler</span> 1343 <span class="upcoming-book-content"> 1344 <span class="upcoming-book-cover">${b.coverUrl ? `<img src="${b.coverUrl}" alt="">` : 'ð'}</span> 1345 <span class="upcoming-book-info"><strong>${escapeHtml(b.title)}</strong><span>${escapeHtml(b.author)}${b.presenter ? ` · ${escapeHtml(b.presenter)}` : ''}</span></span> 1346 </span> 1347 </li>`).join('')} 1348 </ul> 1349 <button class="btn-reveal-all" data-sid="${s.id}">Tout révéler</button>` : ''} 1350 </div>`; 1351 }).join('')} 1352 </div> 1353 </div>`; 1354 upEl.querySelectorAll('.upcoming-books li.clickable').forEach(li => { 1355 li.addEventListener('click', () => { 1356 if (li.classList.contains('spoiler-li')) { 1357 li.classList.remove('spoiler-li'); 1358 return; 1359 } 1360 const s = state.sessions.find(s => s.id === li.dataset.sid); 1361 if (s) openBookModal(s.books[+li.dataset.bi], li.dataset.sid, 'sessions', +li.dataset.bi); 1362 }); 1363 }); 1364 // Bouton "Tout révéler" dans les sessions suivantes 1365 upEl.querySelectorAll('.btn-reveal-all').forEach(btn => { 1366 btn.addEventListener('click', () => { 1367 const card = btn.closest('.upcoming-card'); 1368 card.querySelectorAll('.spoiler-li').forEach(li => li.classList.remove('spoiler-li')); 1369 btn.style.display = 'none'; 1370 }); 1371 }); 1372} 1373 1374// ââ Render : sessions passées ââââââââââââââââââââââââââââ 1375 1376 1377function renderPastSessions() { 1378 const featured = document.getElementById('last-session-featured'); 1379 const grid = document.getElementById('past-grid'); 1380 const separatorContainer = document.getElementById('history-separator-container'); 1381 const olderWrapper = document.getElementById('older-sessions-wrapper'); 1382 1383 if (state.pastSessions.length === 0) { 1384 featured.innerHTML = ''; 1385 separatorContainer.innerHTML = ''; 1386 olderWrapper.style.display = 'none'; 1387 grid.innerHTML = `<p class="empty-sessions">Les sessions passées apparaîtront ici après chaque rencontre.</p>`; 1388 return; 1389 } 1390 1391 // ââ Dernière session : hero avec tuiles livres ââ 1392 const last = state.pastSessions[0]; 1393 const lastType = getSessionType(last); 1394 const lastBooks = last.books || []; 1395 const lastLabel = lastType === 'theme' ? 'Les découvertes de la dernière session' 1396 : lastType === 'lecture-commune' ? 'Dernière lecture commune' 1397 : lastType === 'rencontre-auteur' ? 'Dernière rencontre auteur' 1398 : lastType === 'debat' ? 'Dernier débat' : 'Dernière session'; 1399 1400 const customFeatured = renderFeaturedPastContent(last); 1401 featured.innerHTML = ` 1402 <div class="last-session-hero" data-pid="${last.id}"> 1403 <div class="last-session-header"> 1404 <div class="last-session-label">${lastLabel}</div> 1405 ${sessionTypeBadge(lastType)} 1406 <div class="last-session-theme">${escapeHtml(getSessionTitle(last))}</div> 1407 <div class="last-session-meta">${escapeHtml(last.date)}</div> 1408 ${lastType === 'theme' && last.themeDescription ? `<div class="last-session-desc">${escapeHtml(last.themeDescription)}</div>` : ''} 1409 </div> 1410 ${customFeatured !== null ? customFeatured : (lastBooks.length > 0 ? ` 1411 <div class="last-session-books"> 1412 ${lastBooks.map((b, i) => ` 1413 <div class="book-card clickable" data-pid="${last.id}" data-bi="${i}"> 1414 <div class="book-card-content"> 1415 <div class="book-cover"> 1416 ${b.coverUrl ? `<img src="${b.coverUrl}" alt="">` : 'ð'} 1417 </div> 1418 <div class="book-info"> 1419 <div class="book-title">${escapeHtml(b.title)}</div> 1420 <div class="book-author">${escapeHtml(b.author)}</div> 1421 ${b.presenter ? `<div class="book-presenter">Présenté par ${escapeHtml(b.presenter)}</div>` : ''} 1422 </div> 1423 </div> 1424 </div>`).join('')} 1425 </div>` : `<p style="color:var(--text-light);font-style:italic;">Aucun livre pour cette session.</p>`)} 1426 ${last.recap ? `<div class="last-session-recap">${last.recap}</div>` : ''} 1427 </div>`; 1428 1429 // Events sur la section featured (book-card + lc-book-main) 1430 featured.querySelectorAll('.book-card.clickable, .lc-book-main.clickable').forEach(card => { 1431 card.addEventListener('click', () => { 1432 const p = state.pastSessions.find(s => s.id === card.dataset.pid);
1433 if (p) openBookModal(p.books[+card.dataset.bi], card.dataset.pid, 'pastSessions', +card.dataset.bi); 1434 }); 1435 }); 1436 1437 // ââ Anciennes sessions : grille compacte ââ 1438 const older = state.pastSessions.slice(1); 1439 if (older.length === 0) { 1440 separatorContainer.innerHTML = ''; 1441 olderWrapper.style.display = 'none'; 1442 } else { 1443 separatorContainer.innerHTML = `<div class="history-separator"><span>Sessions précédentes</span></div>`; 1444 olderWrapper.style.display = ''; 1445 grid.innerHTML = older.map(p => { 1446 const customCard = renderPastCardContent(p); 1447 if (customCard !== null) { 1448 return `<div class="past-card" data-pid="${p.id}">${customCard}</div>`; 1449 } 1450 // Type thème : affichage original 1451 return `<div class="past-card" data-pid="${p.id}"> 1452 <div class="past-date">${escapeHtml(p.date)}</div> 1453 <div class="past-theme">${escapeHtml(p.theme)}</div> 1454 ${p.themeDescription ? `<div class="past-theme-desc">${escapeHtml(p.themeDescription)}</div>` : ''} 1455 <ul class="past-books"> 1456 ${(p.books || []).map((b, i) => 1457 `<li class="clickable" data-pid="${p.id}" data-bi="${i}"><span><strong>${escapeHtml(b.title)}</strong> â ${escapeHtml(b.author)}${b.presenter ? ` <span class="book-presenter-inline">(${escapeHtml(b.presenter)})</span>` : ''}</span></li>` 1458 ).join('')} 1459 </ul> 1460 ${p.recap ? `<div class="past-recap">${p.recap}</div>` : ''} 1461 </div>`; 1462 }).join(''); 1463 } 1464 1465 // ââ Events : clic livre â modale ââ 1466 const container = document.getElementById('passees'); 1467 container.querySelectorAll('.past-books li.clickable, .lc-book-main.clickable').forEach(el => { 1468 el.addEventListener('click', () => { 1469 const pid = el.dataset.pid; 1470 const bi = +el.dataset.bi; 1471 const p = state.pastSessions.find(s => s.id === pid); 1472 if (p) openBookModal(p.books[bi], pid, 'pastSessions', bi); 1473 }); 1474 }); 1475 // Events clic sur book-card dans les past cards (debat, rencontre-auteur) 1476 container.querySelectorAll('.past-card .book-card.clickable, .debat-arena-past .book-card.clickable').forEach(card => { 1477 card.addEventListener('click', () => { 1478 const pid = card.dataset.pid; 1479 const bi = +card.dataset.bi; 1480 const p = state.pastSessions.find(s => s.id === pid); 1481 if (p) openBookModal(p.books[bi], pid, 'pastSessions', bi); 1482 }); 1483 }); 1484} 1485 1486// ââ Render : section suggestion ââââââââââââââââââââââââââ 1487function getAnonProposalCount() { 1488 try { 1489 const d = JSON.parse(localStorage.getItem('anonProposals') || '{}');
1490 const month = new Date().toISOString().slice(0, 7); 1491 return d[month] || 0; 1492 } catch { return 0; } 1493} 1494function incrementAnonProposalCount() { 1495 try { 1496 const d = JSON.parse(localStorage.getItem('anonProposals') || '{}'); 1497 const month = new Date().toISOString().slice(0, 7); 1498 d[month] = (d[month] || 0) + 1; 1499 localStorage.setItem('anonProposals', JSON.stringify(d)); 1500 } catch {} 1501} 1502 1503function openProposeModal() { 1504 const modal = document.getElementById('propose-modal'); 1505 const body = document.getElementById('propose-modal-body'); 1506 modal.classList.remove('hidden'); 1507 renderSuggestForm(body); 1508} 1509 1510function renderSuggestSection() { 1511 renderSuggestForm(document.getElementById('suggest-content')); 1512} 1513 1514function renderSuggestForm(el) { 1515 const themeSessions = state.sessions.filter(s => getSessionType(s) === 'theme'); 1516 const sessionsOptions = themeSessions.map((s, i) => 1517 `<option value="${s.id}">${i === 0 ? '⸠' : ''}${escapeHtml(s.theme)} (${escapeHtml(s.date)})</option>` 1518 ).join(''); 1519 1520 state.selectedBook = null; 1521 el.innerHTML = ` 1522 <div class="proposal-form"> 1523 <div class="form-group"> 1524 <label>Ton prénom / nom / pseudo *</label> 1525 <input type="text" class="proposer-name" placeholder="Ex : Marie Dupont" value="${userState.user ? escapeHtml(getDisplayName()) : ''}"> 1526 </div> 1527 ${themeSessions.length > 0 ? ` 1528 <div class="form-group"> 1529 <label>Pour quelle session ?</label> 1530 <select class="proposal-session">${sessionsOptions}</select> 1531 </div>` : ''} 1532 <div class="form-group book-search-wrapper"> 1533 <label>Rechercher un livre *</label> 1534 <input type="text" class="book-search-input" placeholder="Tape un titre ou un auteurâ¦" autocomplete="off"> 1535 <div class="book-search-dropdown search-dropdown"></div> 1536 </div> 1537 <div class="book-preview propose-preview"> 1538 <img class="preview-cover" src="" alt=""> 1539 <div class="book-preview-info"> 1540 <div class="preview-title"></div> 1541 <div class="preview-author"></div> 1542 <button class="btn-change-book">â Changer de livre</button> 1543 </div> 1544 </div> 1545 <div class="form-group"> 1546 <label>Pourquoi ce livre ? (optionnel) <span style="font-weight:400;font-size:0.78rem;color:var(--text-light);">â ce texte sera affiché en teaser anti-spoil sur la carte du livre</span></label> 1547 <textarea class="proposal-note" placeholder="Donne envie sans trop en dire⦠ce texte apparaîtra sur la carte du livre avant qu'on ne le révèle."></textarea> 1548 </div> 1549 <button class="btn-submit btn-submit-proposal">â Envoyer ma proposition</button> 1550 </div> 1551 <div class="form-success proposal-success"> 1552 <div class="success-icon">ð¬</div> 1553 <h3>Merci !</h3> 1554 <p>Ta proposition a été transmise aux organisateurs.</p> 1555 <button class="btn-reset btn-reset-proposal">Faire une autre suggestion</button> 1556 </div>`; 1557 1558 const $ = (sel) => el.querySelector(sel); 1559 const input = $('.book-search-input'); 1560 const dropdown = $('.search-dropdown'); 1561 const preview = $('.propose-preview'); 1562 1563 input.addEventListener('input', () => { 1564 clearTimeout(searchTimer); 1565 const q = input.value.trim(); 1566 if (q.length < 2) { dropdown.classList.remove('open'); return; } 1567 dropdown.innerHTML = `<div class="search-loading">Recherche en coursâ¦</div>`; 1568 dropdown.classList.add('open'); 1569 searchTimer = setTimeout(async () => { 1570 const results = await searchBooks(q); 1571 state.searchResults = results; 1572 renderSearchResults(results, dropdown, q, (book) => { 1573 state.selectedBook = book; 1574 dropdown.classList.remove('open'); 1575 input.closest('.form-group').style.display = 'none'; 1576 $('.preview-cover').src = book.coverUrl || ''; 1577 $('.preview-cover').style.display = book.coverUrl ? '' : 'none'; 1578 $('.preview-title').textContent = book.title; 1579 $('.preview-author').textContent = book.author; 1580 preview.classList.add('show'); 1581 }); 1582 }, 450); 1583 }); 1584 1585 document.addEventListener('click', e => { 1586 if (!e.target.closest('.book-search-wrapper')) dropdown.classList.remove('open'); 1587 }); 1588 1589 $('.btn-change-book').addEventListener('click', () => { 1590 state.selectedBook = null; 1591 input.value = ''; 1592 input.closest('.form-group').style.display = ''; 1593 preview.classList.remove('show'); 1594 input.focus(); 1595 }); 1596 1597 $('.btn-submit-proposal').addEventListener('click', async () => { 1598 if (!state.selectedBook) { showToast('Sélectionne d\'abord un livre dans la liste.'); return; } 1599 const proposerName = $('.proposer-name').value.trim(); 1600 if (!proposerName) { showToast('Indique ton prénom/nom.'); return; } 1601 const note = $('.proposal-note').value.trim(); 1602 const sessionSelect = $('.proposal-session'); 1603 const targetSessionId = sessionSelect ? sessionSelect.value : null; 1604 try { 1605 await submitProposal({ ...state.selectedBook, note, proposerName, targetSessionId }); 1606 $('.proposal-form').style.display = 'none'; 1607 $('.proposal-success').classList.add('show'); 1608 } catch(e) { showToast('Erreur lors de l\'envoi. Réessaie.'); console.error(e); } 1609 }); 1610 1611 $('.btn-reset-proposal').addEventListener('click', () => renderSuggestForm(el)); 1612} 1613 1614// ââ Render : admin â propositions ââââââââââââââââââââââââ 1615function renderAdminProposals() { 1616 const count = state.proposals.length; 1617 const makeSessionOptions = (targetId) => state.sessions.map((s, i) => { 1618 const typeInfo = SESSION_TYPES[getSessionType(s)] || SESSION_TYPES.theme; 1619 const label = getSessionType(s) !== 'theme' ? `${typeInfo.icon} ${escapeHtml(getSessionTitle(s))}` : escapeHtml(s.theme); 1620 return `<option value="${s.id}" ${s.id === targetId ? 'selected' : ''}>${i === 0 ? '⸠' : ''}${label} (${escapeHtml(s.date)})</option>`; 1621 }).join(''); 1622 document.getElementById('proposals-count').textContent = count > 0 ? `(${count})` : ''; 1623 document.getElementById('proposals-list').innerHTML = count === 0 1624 ? `<p class="empty-proposals">Aucune proposition en attente.</p>` 1625 : state.proposals.map(p => ` 1626 <div class="proposal-card"> 1627 <div class="proposal-card-header"> 1628 ${p.coverUrl 1629 ? `<img class="proposal-cover" src="${p.coverUrl}" alt="">` 1630 : `<div class="proposal-cover-ph">ð</div>`} 1631 <div> 1632 <div class="proposal-title">${escapeHtml(p.title)}</div> 1633 <div class="proposal-author">${escapeHtml(p.author || 'â')}</div> 1634 <div class="proposal-proposer">Proposé par ${escapeHtml(p.proposedBy?.name || 'Anonyme')}${p.targetSessionId ? ` · pour <strong>${escapeHtml(state.sessions.find(s => s.id === p.targetSessionId)?.theme || 'session inconnue')}</strong>` : ''}</div> 1635 </div> 1636 </div> 1637 ${p.note ? `<div class="proposal-note">"${escapeHtml(p.note)}"</div>` : ''} 1638 ${state.sessions.length > 0 1639 ? `<select class="proposal-session-select" data-proposal-id="${p.id}">${makeSessionOptions(p.targetSessionId)}</select>` 1640 : `<p style="font-size:0.78rem;color:var(--text-light);font-style:italic;margin-bottom:0.5rem;">Créez d'abord une session pour pouvoir approuver.</p>`} 1641 <div class="proposal-actions"> 1642 <button class="btn-approve" data-id="${p.id}" ${state.sessions.length === 0 ? 'disabled style="opacity:0.5;cursor:not-allowed;"' : ''}>â Ajouter</button> 1643 <button class="btn-reject" data-id="${p.id}">â Refuser</button> 1644 </div> 1645 </div>`).join(''); 1646 1647 document.querySelectorAll('.btn-approve').forEach(btn => { 1648 btn.addEventListener('click', async () => { 1649 const proposal = state.proposals.find(p => p.id === btn.dataset.id); 1650 const select = document.querySelector(`.proposal-session-select[data-proposal-id="${btn.dataset.id}"]`); 1651 if (proposal && select) await approveProposal(proposal, select.value); 1652 }); 1653 }); 1654 document.querySelectorAll('.btn-reject').forEach(btn => { 1655 btn.addEventListener('click', () => rejectProposal(btn.dataset.id)); 1656 }); 1657} 1658 1659// ââ Helper : champs admin spécifiques au type âââââââââââ 1660function adminTypeFields(s) { 1661 const type = getSessionType(s); 1662 switch (type) { 1663 case 'lecture-commune': 1664 return ` 1665 <div class="sform-group"> 1666 <label>Guide de discussion</label> 1667 <textarea class="edit-discussion-guide" rows="3" placeholder="Questions, axes de réflexionâ¦">${escapeHtml(s.discussionGuide || '')}</textarea> 1668 </div>`; 1669 case 'rencontre-auteur': 1670 return ` 1671 <div class="sform-group"> 1672 <label>Nom de l'auteur</label> 1673 <input type="text" class="edit-author-name" value="${escapeHtml(s.authorName || '')}" placeholder="Ex : Victor Hugo"> 1674 </div> 1675 <div class="sform-group"> 1676 <label>Biographie</label> 1677 <textarea class="edit-author-bio" rows="3" placeholder="Courte biographie de l'auteurâ¦">${escapeHtml(s.authorBio || '')}</textarea> 1678 </div> 1679 <div class="sform-group"> 1680 <label>Photo (URL)</label> 1681 <input type="text" class="edit-author-photo" value="${escapeHtml(s.authorPhoto || '')}" placeholder="https://..."> 1682 </div>`; 1683 case 'debat': 1684 return ` 1685 <div class="sform-group"> 1686 <label>Question du débat</label> 1687 <input type="text" class="edit-debate-question" value="${escapeHtml(s.debateQuestion || '')}" placeholder="Ex : Faut-il séparer l'Åuvre de l'artiste ?"> 1688 </div> 1689 <div class="sform-group"> 1690 <label>Description du débat</label> 1691 <textarea class="edit-debate-desc" rows="2" placeholder="Contexte, enjeuxâ¦">${escapeHtml(s.debateDescription || '')}</textarea> 1692 </div>`; 1693 default: return ''; 1694 } 1695} 1696 1697function adminBooksLabel(s) { 1698 const type = getSessionType(s); 1699 const books = s.books || []; 1700 if (type === 'lecture-commune') return `Livre (${books.length}/1)`; 1701 if (type === 'debat') return `Livres â Pour / Contre (${books.length}/2)`; 1702 if (type === 'rencontre-auteur') return `Bibliographie (${books.length})`; 1703 return `Livres (${books.length})`; 1704} 1705 1706function adminBookItemHTML(b, i, sid, type) { 1707 const label = type === 'debat' ? (i === 0 ? 'ð¢ Pour' : 'ð´ Contre') : ''; 1708 return ` 1709 <div class="admin-book-item"> 1710 ${b.coverUrl ? `<img src="${b.coverUrl}" style="width:28px;height:38px;object-fit:cover;border-radius:3px;">` : 'ð'} 1711 <div class="book-info"> 1712 ${label ? `<span class="admin-book-label">${label}</span>` : ''} 1713 <strong>${escapeHtml(b.title)}</strong>
1714 <span>${escapeHtml(b.author)}</span> 1715 <input type="text" class="admin-book-presenter" data-sid="${sid}" data-bi="${i}" value="${escapeHtml(b.presenter || '')}" placeholder="Présenté parâ¦"> 1716 ${type === 'theme' ? `<input type="text" class="admin-book-note" data-sid="${sid}" data-bi="${i}" value="${escapeHtml(b.note || '')}" placeholder="Texte anti-spoil (donne l'eau à la boucheâ¦)">` : ''} 1717 </div> 1718 <button class="btn-remove-book" data-sid="${sid}" data-i="${i}" title="Retirer">ð</button> 1719 </div>`; 1720} 1721 1722// ââ Render : admin â sessions ââââââââââââââââââââââââââââ 1723function renderAdminSessions() { 1724 const el = document.getElementById('session-editor'); 1725 const sessions = state.sessions; 1726 const typeOptions = Object.entries(SESSION_TYPES).map(([k, v]) => `<option value="${k}">${v.icon} ${v.label}</option>`).join(''); 1727 1728 el.innerHTML = ` 1729 <div class="admin-add-session-row"> 1730 <div class="admin-type-select-wrapper"> 1731 <label class="admin-type-select-label">Type de session</label> 1732 <select class="admin-type-select" id="new-session-type">${typeOptions}</select> 1733 </div> 1734 <button class="btn-add-session" id="btn-add-session">+ Créer</button> 1735 </div> 1736 ${sessions.length === 0 1737 ? `<p style="font-size:0.85rem;color:var(--text-light);font-style:italic;text-align:center;padding:1.5rem;">Aucune session. Créez-en une !</p>` 1738 : sessions.map((s, idx) => { 1739 const type = getSessionType(s); 1740 const typeInfo = SESSION_TYPES[type] || SESSION_TYPES.theme; 1741 const books = s.books || []; 1742 const expanded = state.expandedSessions.has(s.id); 1743 return ` 1744 <div class="admin-session-card ${expanded ? '' : 'collapsed'}" data-sid="${s.id}"> 1745 <div class="admin-session-card-header" data-sid="${s.id}"> 1746 <h3><span class="collapse-chevron">â¼</span> ${type !== 'theme' ? `<span class="admin-type-tag type-${type}">${typeInfo.icon}</span> ` : ''}${escapeHtml(getSessionTitle(s) || 'Sans titre')} <span class="header-date">${escapeHtml(s.date || '')}</span></h3> 1747 ${idx === 0 ? '<span class="admin-session-badge">Prochaine</span>' : ''} 1748 <div class="header-move-btns"> 1749 ${idx > 0 ? `<button class="btn-move-header" data-sid="${s.id}" data-dir="up" title="Monter">â²</button>` : ''} 1750 ${idx < sessions.length - 1 ? `<button class="btn-move-header" data-sid="${s.id}" data-dir="down" title="Descendre">â¼</button>` : ''} 1751 </div> 1752 </div> 1753 <div class="session-editor"> 1754 <div class="admin-type-display"><span class="session-type-badge type-${type}">${typeInfo.icon} ${typeInfo.label}</span></div> 1755 ${type === 'theme' ? ` 1756 <div class="sform-group"> 1757 <label>Thème</label> 1758 <input type="text" class="edit-theme" value="${escapeHtml(s.theme || '')}" placeholder="Ex : Féminisme"> 1759 </div> 1760 <div class="sform-group"> 1761 <label>Description</label> 1762 <textarea class="edit-desc" rows="2">${escapeHtml(s.themeDescription || '')}</textarea> 1763 </div>` : ''} 1764 ${adminTypeFields(s)} 1765 <div class="sform-row"> 1766 <div class="sform-group"> 1767 <label>Date (affichage)</label> 1768 <input type="text" class="edit-date" value="${escapeHtml(s.date || '')}" placeholder="Ex : Samedi 26 avril"> 1769 </div> 1770 <div class="sform-group"> 1771 <label>Date (calendrier)</label> 1772 <input type="date" class="edit-date-iso" value="${escapeHtml(s.dateISO || '')}"> 1773 </div> 1774 <div class="sform-group"> 1775 <label>Heure</label> 1776 <input type="text" class="edit-heure" value="${escapeHtml(s.heure || '')}" placeholder="Ex : 15h00"> 1777 </div> 1778 </div> 1779 <div class="sform-group"> 1780 <label>Lieu</label> 1781 <input type="text" class="edit-lieu" value="${escapeHtml(s.lieu || CONFIG.club.lieu)}" placeholder="Ex : Lab-A, 11 Av. Louis Pasteur"> 1782 </div> 1783 <button class="btn-save btn-save-session" data-sid="${s.id}">ð¾ Enregistrer</button> 1784 <hr class="admin-sep"> 1785 <p class="admin-sublabel">${adminBooksLabel(s)}</p> 1786 <div> 1787 ${books.length === 0 1788 ? `<p style="font-size:0.8rem;color:var(--text-light);font-style:italic;">Validez des propositions pour ajouter des livres.</p>` 1789 : books.map((b, i) => adminBookItemHTML(b, i, s.id, type)).join('')} 1790 </div> 1791 <hr class="admin-sep"> 1792 <div class="admin-session-actions"> 1793 <button class="btn-archive" data-sid="${s.id}">ð¦ Archiver</button> 1794 <button class="btn-delete-session" data-sid="${s.id}">ð Supprimer</button> 1795 </div> 1796 </div> 1797 </div>`; 1798 }).join('')}`; 1799 1800 // Nouvelle session avec type 1801 document.getElementById('btn-add-session').addEventListener('click', () => { 1802 const typeSelect = document.getElementById('new-session-type'); 1803 addSession(typeSelect.value); 1804 }); 1805 1806 // Toggle collapse 1807 el.querySelectorAll('.admin-session-card-header').forEach(header => { 1808 header.addEventListener('click', (e) => { 1809 if (e.target.closest('.header-move-btns')) return; 1810 const sid = header.dataset.sid; 1811 const card = header.closest('.admin-session-card'); 1812 card.classList.toggle('collapsed'); 1813 if (!card.classList.contains('collapsed')) state.expandedSessions.add(sid); 1814 else state.expandedSessions.delete(sid); 1815 }); 1816 }); 1817 1818 // Monter/descendre 1819 el.querySelectorAll('.btn-move-header').forEach(btn => { 1820 btn.addEventListener('click', async (e) => { 1821 e.stopPropagation(); 1822 const idx = sessions.findIndex(s => s.id === btn.dataset.sid); 1823 const targetIdx = btn.dataset.dir === 'up' ? idx - 1 : idx + 1; 1824 if (targetIdx >= 0 && targetIdx < sessions.length) { 1825 await swapSessionOrder(sessions[idx].id, sessions[targetIdx].id); 1826 } 1827 }); 1828 }); 1829 1830 // Sauvegarder chaque session (type-aware) 1831 el.querySelectorAll('.btn-save-session').forEach(btn => { 1832 btn.addEventListener('click', async () => { 1833 const card = btn.closest('.admin-session-card'); 1834 const sid = btn.dataset.sid; 1835 const s = state.sessions.find(s => s.id === sid); 1836 const type = getSessionType(s); 1837 const updatedBooks = (s?.books || []).map((b, i) => { 1838 const presInput = card.querySelector(`.admin-book-presenter[data-sid="${sid}"][data-bi="${i}"]`); 1839 const noteInput = card.querySelector(`.admin-book-note[data-sid="${sid}"][data-bi="${i}"]`); 1840 return { ...b, presenter: presInput ? presInput.value.trim() : (b.presenter || ''), note: n
1840oteInput ? noteInput.value.trim() : (b.note || '') }; 1841 }); 1842 const data = { 1843 date: card.querySelector('.edit-date').value.trim(), 1844 dateISO: card.querySelector('.edit-date-iso').value.trim(), 1845 heure: card.querySelector('.edit-heure').value.trim(), 1846 lieu: card.querySelector('.edit-lieu').value.trim(), 1847 books: updatedBooks 1848 }; 1849 // Champs spécifiques au type 1850 if (type === 'theme') { 1851 data.theme = card.querySelector('.edit-theme').value.trim(); 1852 data.themeDescription = card.querySelector('.edit-desc').value.trim(); 1853 } else if (type === 'lecture-commune') { 1854 data.discussionGuide = card.querySelector('.edit-discussion-guide').value.trim(); 1855 // Le thème est le titre du livre 1856 data.theme = updatedBooks[0]?.title || 'Lecture commune'; 1857 } else if (type === 'rencontre-auteur') { 1858 data.authorName = card.querySelector('.edit-author-name').value.trim(); 1859 data.authorBio = card.querySelector('.edit-author-bio').value.trim(); 1860 data.authorPhoto = card.querySelector('.edit-author-photo').value.trim(); 1861 data.theme = data.authorName || 'Rencontre auteur'; 1862 } else if (type === 'debat') { 1863 data.debateQuestion = card.querySelector('.edit-debate-question').value.trim(); 1864 data.debateDescription = card.querySelector('.edit-debate-desc').value.trim(); 1865 data.theme = data.debateQuestion || 'Débat'; 1866 } 1867 await saveSession(sid, data); 1868 showToast('Session mise à jour !'); 1869 }); 1870 }); 1871 1872 // Retirer un livre 1873 el.querySelectorAll('.btn-remove-book').forEach(btn => { 1874 btn.addEventListener('click', async () => { 1875 if (confirm('Retirer ce livre ?')) { 1876 await removeBookFromSession(btn.dataset.sid, +btn.dataset.i); 1877 showToast('Livre retiré.'); 1878 } 1879 }); 1880 }); 1881 1882 // Archiver 1883 el.querySelectorAll('.btn-archive').forEach(btn => { 1884 btn.addEventListener('click', () => archiveSession(btn.dataset.sid)); 1885 }); 1886 1887 // Supprimer 1888 el.querySelectorAll('.btn-delete-session').forEach(btn => { 1889 btn.addEventListener('click', () => deleteSession(btn.dataset.sid)); 1890 }); 1891} 1892 1893// ââ Render : admin sessions passées ââââââââââââââââââââââ 1894function renderAdminPastSessions() { 1895 const el = document.getElementById('past-session-editor'); 1896 const sessions = state.pastSessions; 1897 1898 el.innerHTML = ` 1899 ${sessions.length === 0 1900 ? `<p style="font-size:0.85rem;color:var(--text-light);font-style:italic;text-align:center;padding:1.5rem;">Aucune session archivée.</p>` 1901 : sessions.map((s, idx) => { 1902 const type = getSessionType(s); 1903 const typeInfo = SESSION_TYPES[type] || SESSION_TYPES.theme; 1904 const books = s.books || []; 1905 const expanded = state.expandedPastSessions.has(s.id); 1906 return ` 1907 <div class="admin-session-card ${expanded ? '' : 'collapsed'}" data-pid="${s.id}">
1908 <div class="admin-past-card-header" data-pid="${s.id}"> 1909 <h3><span class="collapse-chevron">â¼</span> ${type !== 'theme' ? `<span class="admin-type-tag type-${type}">${typeInfo.icon}</span> ` : ''}${escapeHtml(getSessionTitle(s) || 'Sans titre')} <span class="header-date">${escapeHtml(s.date || '')}</span></h3> 1910 <div class="header-move-btns"> 1911 ${idx > 0 ? `<button class="btn-move-past-header" data-pid="${s.id}" data-dir="up" title="Monter">â²</button>` : ''} 1912 ${idx < sessions.length - 1 ? `<button class="btn-move-past-header" data-pid="${s.id}" data-dir="down" title="Descendre">â¼</button>` : ''} 1913 </div> 1914 </div> 1915 <div class="session-editor"> 1916 <div class="admin-type-display"><span class="session-type-badge type-${type}">${typeInfo.icon} ${typeInfo.label}</span></div> 1917 ${type === 'theme' ? ` 1918 <div class="sform-group"> 1919 <label>Thème</label> 1920 <input type="text" class="edit-past-theme" value="${escapeHtml(s.theme || '')}" placeholder="Ex : Féminisme"> 1921 </div> 1922 <div class="sform-group"> 1923 <label>Description</label> 1924 <textarea class="edit-past-desc" rows="2">${escapeHtml(s.themeDescription || '')}</textarea> 1925 </div>` : ''} 1926 ${type === 'lecture-commune' ? ` 1927 <div class="sform-group"> 1928 <label>Guide de discussion</label> 1929 <textarea class="edit-past-discussion-guide" rows="3" placeholder="Questions, axes de réflexionâ¦">${escapeHtml(s.discussionGuide || '')}</textarea> 1930 </div>` : ''} 1931 ${type === 'rencontre-auteur' ? ` 1932 <div class="sform-group"> 1933 <label>Nom de l'auteur</label> 1934 <input type="text" class="edit-past-author-name" value="${escapeHtml(s.authorName || '')}" placeholder="Ex : Victor Hugo"> 1935 </div> 1936 <div class="sform-group"> 1937 <label>Biographie</label> 1938 <textarea class="edit-past-author-bio" rows="3" placeholder="Courte biographieâ¦">${escapeHtml(s.authorBio || '')}</textarea> 1939 </div> 1940 <div class="sform-group"> 1941 <label>Photo (URL)</label> 1942 <input type="text" class="edit-past-author-photo" value="${escapeHtml(s.authorPhoto || '')}" placeholder="https://..."> 1943 </div>` : ''} 1944 ${type === 'debat' ? ` 1945 <div class="sform-group"> 1946 <label>Question du débat</label> 1947 <input type="text" class="edit-past-debate-question" value="${escapeHtml(s.debateQuestion || '')}" placeholder="Ex : Faut-il séparer l'Åuvre de l'artiste ?"> 1948 </div> 1949 <div class="sform-group"> 1950 <label>Description du débat</label> 1951 <textarea class="edit-past-debate-desc" rows="2" placeholder="Contexte, enjeuxâ¦">${escapeHtml(s.debateDescription || '')}</textarea> 1952 </div>` : ''} 1953 <div class="sform-row"> 1954 <div class="sform-group"> 1955 <label>Date (affichage)</label> 1956 <input type="text" class="edit-past-date" value="${escapeHtml(s.date || '')}" placeholder="Ex : Samedi 26 avril"> 1957 </div> 1958 <div class="sform-group"> 1959 <label>Date (calendrier)</label> 1960 <input type="date" class="edit-past-date-iso" value="${escapeHtml(s.dateISO || '')}"> 1961 </div> 1962 <div class="sform-group"> 1963 <label>Heure</label> 1964 <input type="text" class="edit-past-heure" value="${escapeHtml(s.heure || '')}" placeholder="Ex : 15h00"> 1965 </div> 1966 </div> 1967 <div class="sform-group"> 1968 <label>Compte-rendu</label> 1969 <textarea class="edit-past-recap" rows="3" placeholder="Compte-rendu de la séanceâ¦">${escapeHtml(s.recap || '')}</textarea> 1970 </div> 1971 <button class="btn-save btn-save-past-session" data-pid="${s.id}">ð¾ Enregistrer</button> 1972 <hr class="admin-sep"> 1973 <p class="admin-sublabel">${adminBooksLabel(s)}</p> 1974 <div> 1975 ${books.map((b, i) => ` 1976 <div class="admin-book-item"> 1977 ${b.coverUrl ? `<img src="${b.coverUrl}" style="width:28px;height:38px;object-fit:cover;border-radius:3px;">` : 'ð'} 1978 <div class="book-info"> 1979 ${type === 'debat' ? `<span class="admin-book-label">${i === 0 ? 'ð¢ Pour' : 'ð´ Contre'}</span>` : ''} 1980 <strong>${escapeHtml(b.title)}</strong>
1981 <span>${escapeHtml(b.author)}</span> 1982 <input type="text" class="admin-book-presenter" data-pid="${s.id}" data-bi="${i}" value="${escapeHtml(b.presenter || '')}" placeholder="Présenté parâ¦"> 1983 </div> 1984 <button class="btn-remove-past-book-admin" data-pid="${s.id}" data-i="${i}" title="Retirer">ð</button> 1985 </div>`).join('')} 1986 <div class="admin-past-add-book" style="margin-top:0.5rem;"> 1987 <div class="book-search-wrapper" style="position:relative;"> 1988 <input type="text" class="admin-past-book-search" data-pid="${s.id}" placeholder="Ajouter un livreâ¦" autocomplete="off" 1989 style="width:100%;padding:0.45rem 0.7rem;border:1px solid var(--border);border-radius:6px;font-family:'DM Sans',sans-serif;font-size:0.82rem;background:var(--bg);color:var(--text);outline:none;"> 1990 <div class="book-search-dropdown" id="admin-past-dd-${s.id}"></div> 1991 </div> 1992 </div> 1993 </div> 1994 <hr class="admin-sep"> 1995 <div class="admin-session-actions"> 1996 <button class="btn-delete-past-admin" data-pid="${s.id}">ð Supprimer</button> 1997 </div> 1998 </div> 1999 </div>`; 2000 }).join('')}`; 2001 2002 // Toggle collapse 2003 el.querySelectorAll('.admin-past-card-header').forEach(header => { 2004 header.addEventListener('click', (e) => { 2005 if (e.target.closest('.header-move-btns')) return; 2006 const pid = header.dataset.pid; 2007 const card = header.closest('.admin-session-card'); 2008 card.classList.toggle('collapsed'); 2009 if (!card.classList.contains('collapsed')) state.expandedPastSessions.add(pid); 2010 else state.expandedPastSessions.delete(pid); 2011 }); 2012 }); 2013 2014 // Monter/descendre 2015 el.querySelectorAll('.btn-move-past-header').forEach(btn => { 2016 btn.addEventListener('click', async (e) => { 2017 e.stopPropagation(); 2018 const idx = sessions.findIndex(s => s.id === btn.dataset.pid); 2019 const targetIdx = btn.dataset.dir === 'up' ? idx - 1 : idx + 1; 2020 if (targetIdx >= 0 && targetIdx < sessions.length) { 2021 await swapPastSessionOrder(sessions[idx].id, sessions[targetIdx].id); 2022 } 2023 }); 2024 }); 2025 2026 // Sauvegarder 2027 el.querySelectorAll('.btn-save-past-session').forEach(btn => { 2028 btn.addEventListener('click', async () => { 2029 const card = btn.closest('.admin-session-card'); 2030 const pid = btn.dataset.pid; 2031 const s = state.pastSessions.find(s => s.id === pid); 2032 const type = getSessionType(s); 2033 const updatedBooks = (s?.books || []).map((b, i) => { 2034 const presInput = card.querySelector(`.admin-book-presenter[data-pid="${pid}"][data-bi="${i}"]`); 2035 return { ...b, presenter: presInput ? presInput.value.trim() : (b.presenter || '') }; 2036 }); 2037 const data = { 2038 date: card.querySelector('.edit-past-date').value.trim(), 2039 dateISO: card.querySelector('.edit-past-date-iso').value.trim(), 2040 heure: card.querySelector('.edit-past-heure').value.trim(), 2041 recap: card.querySelector('.edit-past-recap').value.trim(), 2042 books: updatedBooks 2043 }; 2044 if (type === 'theme') { 2045 data.theme = card.querySelector('.edit-past-theme').value.trim(); 2046 data.themeDescription = card.querySelector('.edit-past-desc').value.trim(); 2047 } else if (type === 'lecture-commune') { 2048 data.discussionGuide = card.querySelector('.edit-past-discussion-guide').value.trim(); 2049 data.theme = updatedBooks[0]?.title || 'Lecture commune'; 2050 } else if (type === 'rencontre-auteur') { 2051 data.authorName = card.querySelector('.edit-past-author-name').value.trim(); 2052 data.authorBio = card.querySelector('.edit-past-author-bio').value.trim(); 2053 data.authorPhoto = card.querySelector('.edit-past-author-photo').value.trim(); 2054 data.theme = data.authorName || 'Rencontre auteur'; 2055 } else if (type === 'debat') { 2056 data.debateQuestion = card.querySelector('.edit-past-debate-question').value.trim(); 2057 data.debateDescription = card.querySelector('.edit-past-debate-desc').value.trim(); 2058 data.theme = data.debateQuestion || 'Débat'; 2059 } 2060 await savePastSession(pid, data); 2061 showToast('Session mise à jour !'); 2062 }); 2063 }); 2064 2065 // Retirer un livre 2066 el.querySelectorAll('.btn-remove-past-book-admin').forEach(btn => { 2067 btn.addEventListener('click', async () => { 2068 if (confirm('Retirer ce livre ?')) { 2069 await removeBookFromPastSession(btn.dataset.pid, +btn.dataset.i); 2070 showToast('Livre retiré.'); 2071 } 2072 }); 2073 }); 2074 2075 // Recherche livre pour ajouter 2076 let pastAdminSearchTimer = null; 2077 el.querySelectorAll('.admin-past-book-search').forEach(input => { 2078 const pid = input.dataset.pid; 2079 const dropdown = document.getElementById(`admin-past-dd-${pid}`); 2080 input.addEventListener('input', () => { 2081 clearTimeout(pastAdminSearchTimer); 2082 const q = input.value.trim(); 2083 if (q.length < 2) { dropdown.classList.remove('open'); return; } 2084 dropdown.innerHTML = '<div class="search-loading">Rechercheâ¦</div>'; 2085 dropdown.classList.add('open'); 2086 pastAdminSearchTimer = setTimeout(async () => { 2087 const results = await searchBooks(q); 2088 renderSearchResults(results, dropdown, q, async (book) => { 2089 await addBookToPastSession(pid, { title: book.title, author: book.author, description: book.description || '', coverUrl: book.coverUrl || '', presenter: '' }); 2090 dropdown.classList.remove('open'); 2091 input.value = ''; 2092 }); 2093 }, 450); 2094 }); 2095 }); 2096 2097 // Supprimer 2098 el.querySelectorAll('.btn-delete-past-admin').forEach(btn => { 2099 btn.addEventListener('click', () => deletePastSession(btn.dataset.pid)); 2100 }); 2101} 2102 2103// ââ Render : conseils lecture âââââââââââââââââââââââââââââ 2104function tipCardHTML(t) { 2105 const date = t.createdAt?.toDate ? t.createdAt.toDate().toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' }) : ''; 2106 return ` 2107 <div class="tip-card" data-tip-id="${t.id}"> 2108 ${t.coverUrl 2109 ? `<img class="tip-card-cover" src="${t.coverUrl}" alt="">` 2110 : `<div class="tip-card-cover-ph">ð</div>`} 2111 <div class="tip-card-body"> 2112 <div class="tip-card-title">${escapeHtml(t.title)}</div> 2113 <div class="tip-card-author">${escapeHtml(t.author || '')}</div> 2114 ${t.note ? `<div class="tip-card-note">"${escapeHtml(t.note)}"</div>` : ''} 2115 <div class="tip-card-by">
2115${t.recommendedBy?.isAdmin ? 'ð ' : ''}Recommandé par ${escapeHtml(t.recommendedBy?.name || 'Anonyme')}${date ? ` · ${date}` : ''}</div> 2116 ${userState.isAdmin ? `<button class="btn-delete-theme" style="margin-top:0.3rem;" data-tip-id="${t.id}" title="Supprimer">ð</button>` : ''} 2117 </div> 2118 </div>`; 2119} 2120 2121// Stockage des handlers pour nettoyage 2122let _tipsArrowLHandler = null; 2123let _tipsArrowRHandler = null; 2124let _tipsMouseEnter = null; 2125let _tipsMouseLeave = null; 2126 2127function renderTips() { 2128 // Nettoyer l'ancien carousel en le clonant (supprime tous les event listeners d'un coup) 2129 const oldCarousel = document.getElementById('tips-carousel'); 2130 const carousel = oldCarousel.cloneNode(false); 2131 oldCarousel.parentNode.replaceChild(carousel, oldCarousel); 2132 2133 // Nettoyer les anciens handlers des flèches 2134 const arrowL = document.getElementById('tips-arrow-left'); 2135 const arrowR = document.getElementById('tips-arrow-right'); 2136 if (_tipsArrowLHandler) { arrowL.removeEventListener('click', _tipsArrowLHandler); } 2137 if (_tipsArrowRHandler) { arrowR.removeEventListener('click', _tipsArrowRHandler); } 2138 2139 // Nettoyer l'auto-scroll 2140 if (window._tipsAutoScroll) { clearInterval(window._tipsAutoScroll); window._tipsAutoScroll = null; } 2141 2142 const approved = state.tips.filter(t => t.status === 'approved'); 2143 const needsLoop = approved.length > 3; 2144 2145 if (approved.length === 0) { 2146 carousel.innerHTML = '<p class="tips-empty">Aucun conseil pour le moment.</p>'; 2147 } else { 2148 const cards = approved.map(t => tipCardHTML(t)).join(''); 2149 carousel.innerHTML = needsLoop ? cards + cards + cards : cards; 2150 } 2151 2152 arrowL.style.display = needsLoop ? '' : 'none'; 2153 arrowR.style.display = needsLoop ? '' : 'none'; 2154 const scrollAmt = 240; 2155 2156 // Positionner au milieu du triplement 2157 if (needsLoop) { 2158 carousel.style.scrollBehavior = 'auto'; 2159 carousel.scrollLeft = carousel.scrollWidth / 3; 2160 carousel.style.scrollBehavior = 'smooth'; 2161 } 2162 2163 // Reset silencieux quand on atteint les bords 2164 const checkLoop = () => { 2165 if (!needsLoop) return; 2166 const third = carousel.scrollWidth / 3; 2167 if (carousel.scrollLeft >= third * 2) { 2168 carousel.style.scrollBehavior = 'auto'; 2169 carousel.scrollLeft -= third; 2170 carousel.style.scrollBehavior = 'smooth'; 2171 } else if (carousel.scrollLeft <= 0) { 2172 carousel.style.scrollBehavior = 'auto'; 2173 carousel.scrollLeft += third; 2174 carousel.style.scrollBehavior = 'smooth'; 2175 } 2176 }; 2177 carousel.addEventListener('scrollend', checkLoop); 2178 let scrollTimeout; 2179 carousel.addEventListener('scroll', () => { 2180 clearTimeout(scrollTimeout); 2181 scrollTimeout = setTimeout(checkLoop, 150); 2182 }); 2183 2184 // Flèches (handlers nettoyables) 2185 _tipsArrowLHandler = () => carousel.scrollBy({ left: -scrollAmt }); 2186 _tipsArrowRHandler = () => carousel.scrollBy({ left: scrollAmt }); 2187 arrowL.addEventListener('click', _tipsArrowLHandler); 2188 arrowR.addEventListener('click', _tipsArrowRHandler); 2189 2190 // Défilement automatique 2191 if (needsLoop) { 2192 window._tipsAutoScroll = setInterval(() => carousel.scrollBy({ left: scrollAmt }), 4000); 2193 _tipsMouseEnter = () => clearInterval(window._tipsAutoScroll); 2194 _tipsMouseLeave = () => { 2195 window._tipsAutoScroll = setInterval(() => carousel.scrollBy({ left: scrollAmt }), 4000); 2196 }; 2197 carousel.addEventListener('mouseenter', _tipsMouseEnter); 2198 carousel.addEventListener('mouseleave', _tipsMouseLeave); 2199 } 2200 2201 // Clic sur carte â modale livre 2202 carousel.querySelectorAll('.tip-card').forEach(card => { 2203 card.addEventListener('click', (e) => { 2204 if (e.target.closest('.btn-delete-theme')) return; 2205 const t = state.tips.find(t => t.id === card.dataset.tipId); 2206 if (t) { 2207 openBookModal({ title: t.title, author: t.author, description: t.description || '', coverUrl: t.coverUrl, presenter: '' }, null, null, null); 2208 setHash(`tip/${t.id}`); 2209 } 2210 }); 2211 }); 2212 2213 // Admin : supprimer 2214 carousel.querySelectorAll('.btn-delete-theme[data-tip-id]').forEach(btn => { 2215 btn.addEventListener('click', (e) => { 2216 e.stopPropagation(); 2217 deleteTip(btn.dataset.tipId); 2218 }); 2219 }); 2220 2221 // Badge admin pending
2222 const pending = state.tips.filter(t => t.status === 'pending'); 2223 const badge = document.getElementById('tips-pending-count'); 2224 if (badge) badge.textContent = pending.length > 0 ? `(${pending.length})` : ''; 2225 2226 // Formulaire d'ajout 2227 renderTipForm(); 2228} 2229 2230function renderTipForm() { 2231 const container = document.getElementById('tip-form-container'); 2232 if (!userState.user) { 2233 container.innerHTML = '<p style="color:var(--text-light);font-size:0.82rem;font-style:italic;margin-top:0.75rem;">Connecte-toi pour recommander un livre.</p>'; 2234 return; 2235 } 2236 container.innerHTML = ` 2237 <div class="tip-form" id="tip-form"> 2238 <input type="text" id="tip-recommender-name" placeholder="Ton prénom / nom" value="${userState.user ? escapeHtml(getDisplayName()) : ''}" style="flex:0 0 180px;"> 2239 <div class="book-search-wrapper" style="flex:1;min-width:200px;"> 2240 <input type="text" id="tip-book-search" placeholder="Rechercher un livre à recommanderâ¦" autocomplete="off"> 2241 <div class="book-search-dropdown" id="tip-search-dd"></div> 2242 </div> 2243 <div id="tip-selected" style="width:100%;display:none;"></div> 2244 <textarea id="tip-note" placeholder="Pourquoi recommandes-tu ce livre ? (optionnel)" style="display:none;"></textarea> 2245 <button class="btn-submit-tip" id="btn-submit-tip" style="display:none;">Recommander</button> 2246 </div>`; 2247 2248 const input = document.getElementById('tip-book-search'); 2249 const dropdown = document.getElementById('tip-search-dd'); 2250 let tipTimer = null; 2251 2252 input.addEventListener('input', () => { 2253 clearTimeout(tipTimer); 2254 const q = input.value.trim(); 2255 if (q.length < 2) { dropdown.classList.remove('open'); return; } 2256 dropdown.innerHTML = '<div class="search-loading">Rechercheâ¦</div>'; 2257 dropdown.classList.add('open'); 2258 tipTimer = setTimeout(async () => { 2259 const results = await searchBooks(q); 2260 renderSearchResults(results, dropdown, q, (book) => { 2261 state.tipSelectedBook = book; 2262 dropdown.classList.remove('open'); 2263 input.closest('.book-search-wrapper').style.display = 'none'; 2264 const sel = document.getElementById('tip-selected'); 2265 sel.style.display = ''; 2266 sel.innerHTML = ` 2267 <div class="tip-selected-book"> 2268 ${book.coverUrl ? `<img src="${book.coverUrl}" alt="">` : ''} 2269 <strong>${escapeHtml(book.title)}</strong> â ${escapeHtml(book.author || '')} 2270 <button class="tip-remove" id="tip-remove-book">â</button> 2271 </div>`; 2272 document.getElementById('tip-note').style.display = ''; 2273 document.getElementById('btn-submit-tip').style.display = ''; 2274 document.getElementById('tip-remove-book').addEventListener('click', () => { 2275 state.tipSelectedBook = null; 2276 input.closest('.book-search-wrapper').style.display = ''; 2277 input.value = ''; 2278 sel.style.display = 'none'; 2279 document.getElementById('tip-note').style.display = 'none'; 2280 document.getElementById('btn-submit-tip').style.display = 'none'; 2281 }); 2282 }); 2283 }, 450); 2284 }); 2285 2286 document.getElementById('btn-submit-tip').addEventListener('click', async () => { 2287 if (!state.tipSelectedBook) return; 2288 const recommenderName = document.getElementById('tip-recommender-name').value.trim(); 2289 if (!recommenderName) { showToast('Indique ton prénom/nom.'); return; } 2290 const note = document.getElementById('tip-note').value.trim(); 2291 await addTip({ 2292 title: state.tipSelectedBook.title, 2293 author: state.tipSelectedBook.author || '', 2294 description: state.tipSelectedBook.description || '', 2295 coverUrl: state.tipSelectedBook.coverUrl || '',
2296 googleBooksId: state.tipSelectedBook.googleBooksId || '', 2297 note, 2298 recommenderName 2299 }); 2300 state.tipSelectedBook = null; 2301 renderTipForm(); 2302 }); 2303} 2304 2305// ââ Render : calendrier ââââââââââââââââââââââââââââââââââ 2306let calendarDate = new Date(); 2307 2308function renderCalendar() { 2309 const year = calendarDate.getFullYear(); 2310 const month = calendarDate.getMonth(); 2311 const monthNames = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; 2312 const dayNames = ['Lun','Mar','Mer','Jeu','Ven','Sam','Dim']; 2313 2314 document.getElementById('cal-month').textContent = `${monthNames[month]} ${year}`; 2315 2316 // Collecter les événements du mois (sessions à venir + passées) 2317 const events = {}; 2318 [...state.sessions, ...state.pastSessions].forEach(s => { 2319 if (!s.dateISO) return; 2320 const d = new Date(s.dateISO + 'T00:00:00'); 2321 if (d.getFullYear() === year && d.getMonth() === month) { 2322 const day = d.getDate(); 2323 const isPast = state.pastSessions.some(p => p.id === s.id); 2324 events[day] = { session: s, isPast }; 2325 } 2326 }); 2327 2328 // Grille 2329 const firstDay = new Date(year, month, 1).getDay(); 2330 const offset = firstDay === 0 ? 6 : firstDay - 1; // Lundi = 0 2331 const daysInMonth = new Date(year, month + 1, 0).getDate(); 2332 const today = new Date(); 2333 const isCurrentMonth = today.getFullYear() === year && today.getMonth() === month; 2334 2335 let html = dayNames.map(d => `<div class="cal-day-name">${d}</div>`).join(''); 2336 for (let i = 0; i < offset; i++) html += '<div class="cal-day empty"></div>'; 2337 for (let d = 1; d <= daysInMonth; d++) { 2338 const isToday = isCurrentMonth && today.getDate() === d; 2339 const ev = events[d]; 2340 let cls = 'cal-day'; 2341 if (isToday) cls += ' today'; 2342 if (ev) cls += ev.isPast ? ' past-event' : ' upcoming-event'; 2343 const tooltip = ev ? escapeHtml(ev.session.theme || '') : ''; 2344 html += `<div class="${cls}" ${tooltip ? `title="${tooltip}"` : ''}>${d}</div>`; 2345 } 2346 2347 document.getElementById('cal-grid').innerHTML = html; 2348 2349 // Légende 2350 const legend = document.getElementById('cal-legend'); 2351 const evList = Object.entries(events).sort(([a],[b]) => a - b); 2352 legend.innerHTML = evList.length === 0 2353 ? '<p class="cal-no-events">Aucune session ce mois-ci.</p>' 2354 : evList.map(([day, ev]) => { 2355 const s = ev.session; 2356 const dot = ev.isPast ? 'past-dot' : 'upcoming-dot'; 2357 return `<div class="cal-event"><span class="cal-dot ${dot}"></span><strong>${day} ${monthNames[month].slice(0,3)}.</strong> ${escapeHtml(s.theme || '')} ${s.heure ? `· ${escapeHtml(s.heure)}` : ''}</div>`; 2358 }).join(''); 2359} 2360 2361function initCalendar() { 2362 const modal = document.getElementById('calendar-modal'); 2363 document.getElementById('open-calendar').addEventListener('click', () => { 2364 calendarDate = new Date(); 2365 renderCalendar(); 2366 modal.classList.remove('hidden'); 2367 }); 2368 document.getElementById('cal-modal-close').addEventListener('click', () => modal.classList.add('hidden')); 2369 modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.add('hidden'); }); 2370 document.getElementById('cal-prev').addEventListener('click', () => { 2371 calendarDate.setMonth(calendarDate.getMonth() - 1); 2372 renderCalendar(); 2373 }); 2374 document.getElementById('cal-next').addEventListener('click', () => { 2375 calendarDate.setMonth(calendarDate.getMonth() + 1); 2376 renderCalendar(); 2377 }); 2378} 2379 2380// ââ Render : sondage âââââââââââââââââââââââââââââââââââââ 2381function renderPoll() { 2382 const el = document.getElementById('poll-container'); 2383 const approvedThemes = state.themes.filter(t => t.status !== 'pending'); 2384 const uid = userState.user?.uid; 2385 const myThemeCount = uid ? state.themes.filter(t => t.proposedBy === uid).length : 0; 2386 const totalThemes = state.themes.length; 2387 const canSuggest = uid && totalThemes < state.limits.maxThemes; 2388 2389 if (approvedThemes.length === 0) { 2390 el.innerHTML = `<p style="color:var(--text-light);font-style:italic;">Aucun thème en lice pour le moment.</p> 2391 ${!uid ? '<p class="poll-login-hint">Connecte-toi pour voter et proposer des thèmes.</p>' : ''} 2392 ${canSuggest ? `<div class="poll-suggest"><input type="text" id="suggest-theme-input" placeholder="Proposer un thèmeâ¦"><button id="btn-suggest-theme">Proposer</button></div>` : ''}`; 2393 document.getElementById('btn-suggest-theme')?.addEventListener('click', () => { 2394 const name = document.getElementById('suggest-theme-input').value.trim(); 2395 if (name) { suggestTheme(name); document.getElementById('suggest-theme-input').value = ''; } 2396 }); 2397 return; 2398 } 2399 2400 // Trier par nombre de votes décroissant 2401 const sorted = [...approvedThemes].sort((a, b) => getVoteCount(b) - getVoteCount(a)); 2402 const maxVotes = getVoteCount(sorted[0]) || 1; 2403 const rankSymbols = ['ð¥', 'ð¥', 'ð¥']; 2404 2405 // Calcul des rangs par paliers de votes (ex-aequo = même rang) 2406 let currentRank = 0; 2407 let lastCount = -1; 2408 const ranks = sorted.map(t => { 2409 const c = getVoteCount(t); 2410 if (c !== lastCount) { currentRank++; lastCount = c; } 2411 return currentRank; 2412 }); 2413 2414 // Séparer top 3 (rangs 1-3) et le reste 2415 const topItems = []; 2416 const otherItems = []; 2417 sorted.forEach((t, idx) => { (ranks[idx] <= 3 ? topItems : otherItems).push({ theme: t, rank: ranks[idx], idx }); }); 2418 2419 function renderItem(t, rank, isCompact) { 2420 const voters = getVotersMap(t); 2421 const names = Object.values(voters); 2422 const count = names.length; 2423 const hasVoted = uid && !!voters[uid]; 2424 const pct = maxVotes > 0 ? (count / maxVotes) * 100 : 0; 2425 const rankClass = rank <= 3 && count > 0 ? `rank-${rank}` : ''; 2426 const rankLabel = rank <= 3 && count > 0 ? rankSymbols[rank - 1] : `${rank}
2426`; 2427 const tooltip = names.length > 0 ? names.join(', ') : 'Aucun vote'; 2428 const compact = isCompact ? ' compact' : ''; 2429 return ` 2430 <div class="poll-item ${rankClass}${compact}" title="${escapeHtml(tooltip)}"> 2431 ${!isCompact ? `<div class="poll-bar-bg" style="width:${pct}%"></div>` : ''} 2432 <span class="poll-rank">${rankLabel}</span> 2433 <div class="poll-item-body"> 2434 <div class="poll-name">${escapeHtml(t.name)}</div> 2435 ${!isCompact && names.length > 0 ? `<div class="poll-voters-hint">${escapeHtml(names.slice(0, 3).join(', '))}${names.length > 3 ? ` +${names.length - 3}` : ''}</div>` : ''} 2436 </div> 2437 <div class="poll-right"> 2438 <span class="poll-votes-count">${count}</span> 2439 ${uid 2440 ? (hasVoted 2441 ? `<button class="btn-vote voted" data-tid="${t.id}" title="Annuler le vote">Voté</button>` 2442 : `<button class="btn-vote" data-tid="${t.id}">Voter</button>`) 2443 : ''} 2444 </div> 2445 </div>`; 2446 } 2447 2448 el.innerHTML = ` 2449 <div class="poll-top"> 2450 ${topItems.map(({ theme, rank }) => renderItem(theme, rank, false)).join('')} 2451 </div> 2452 ${otherItems.length > 0 ? `<div class="poll-others">${otherItems.map(({ theme, rank }) => renderItem(theme, rank, true)).join('')}</div>` : ''} 2453 ${!uid ? '<p class="poll-login-hint">Connecte-toi pour voter et proposer des thèmes.</p>' : ''} 2454 ${canSuggest ? ` 2455 <div class="poll-suggest"> 2456 <input type="text" id="suggest-theme-input" placeholder="Proposer un thèmeâ¦"> 2457 <button id="btn-suggest-theme">Proposer</button> 2458 ${myThemeCount >= state.limits.freeThemes ? '<p style="font-size:0.75rem;color:var(--text-light);margin-top:0.3rem;">Tes prochaines propositions seront soumises à validation.</p>' : ''} 2459 </div>` : (uid && totalThemes >= state.limits.maxThemes ? `<p style="font-size:0.8rem;color:var(--text-light);margin-top:0.5rem;">Le nombre maximum de thèmes (${state.limits.maxThemes}) est atteint.</p>` : '')}`; 2460 2461 el.querySelectorAll('.btn-vote:not(.voted)').forEach(btn => { 2462 btn.addEventListener('click', () => voteTheme(btn.dataset.tid)); 2463 }); 2464 el.querySelectorAll('.btn-vote.voted').forEach(btn => { 2465 btn.addEventListener('click', () => unvoteTheme(btn.dataset.tid)); 2466 }); 2467 document.getElementById('btn-suggest-theme')?.addEventListener('click', () => { 2468 const name = document.getElementById('suggest-theme-input').value.trim(); 2469 if (name) { suggestTheme(name); document.getElementById('suggest-theme-input').value = ''; } 2470 }); 2471} 2472 2473// ââ Render : admin â thèmes ââââââââââââââââââââââââââââââ 2474function renderAdminThemes() { 2475 const el = document.getElementById('admin-themes'); 2476 const pendingThemes = state.themes.filter(t => t.status === 'pending'); 2477 const approvedThemes = state.themes.filter(t => t.status !== 'pending'); 2478 el.innerHTML = ` 2479 ${pendingThemes.length > 0 ? ` 2480 <p class="admin-sublabel">En attente de validation (${pendingThemes.length})</p> 2481 ${pendingThemes.map(t => ` 2482 <div class="admin-theme-item"> 2483 <span class="theme-name">${escapeHtml(t.name)}</span> 2484 <button class="btn-approve-theme" data-tid="${t.id}" title="Approuver" style="background:var(--green);color:#fff;border:none;padding:0.2rem 0.6rem;border-radius:999px;font-size:0.75rem;cursor:pointer;">â</button> 2485 <button class="btn-delete-theme" data-tid="${t.id}" title="Refuser">ð</button> 2486 </div>`).join('')}` : ''} 2487 <p class="admin-sublabel"${pendingThemes.length > 0 ? ' style="margin-top:1rem;"' : ''}>Thèmes du sondage (${approvedThemes.length}) â max ${state.limits.maxThemes} au total</p> 2488 ${approvedThemes.length === 0 2489 ? '<p style="font-size:0.82rem;color:var(--text-light);font-style:italic;">Aucun thème.</p>' 2490 : approvedThemes.map(t => ` 2491 <div class="admin-theme-item"> 2492 <span class="theme-name">${escapeHtml(t.name)}</span> 2493 <span class="theme-votes">${getVoteCount(t)} votes</span> 2494 <button class="btn-delete-theme" data-tid="${t.id}" title="Supprimer">ð</button> 2495 </div>`).join('')} 2496 <div class="admin-add-theme"> 2497 <input type="text" id="new-theme-name" placeholder="Nouveau thèmeâ¦"> 2498 <button id="btn-add-theme">+</button> 2499 </div> 2500 <button class="btn-reset-votes" id="btn-reset-votes">Remettre tous les votes à zéro</button>`; 2501 2502 el.querySelectorAll('.btn-delete-theme').forEach(btn => { 2503 btn.addEventListener('click', () => deleteTheme(btn.dataset.tid)); 2504 }); 2505 el.querySelectorAll('.btn-approve-theme').forEach(btn => { 2506 btn.addEventListener('click', async () => { 2507 await setDoc(doc(db, 'themes', btn.dataset.tid), { status: 'approved' }, { merge: true }); 2508 showToast('Thème approuvé !'); 2509 }); 2510 }); 2511 document.getElementById('btn-add-theme').addEventListener('click', () => { 2512 const name = document.getElementById('new-theme-name').value.trim(); 2513 if (name) { addTheme(name); document.getElementById('new-theme-name').value = ''; } 2514 }); 2515 document.getElementById('btn-reset-votes').addEventListener('click', resetAllVotes); 2516} 2517 2518// ââ Render : admin â conseils ââââââââââââââââââââââââââââ 2519function renderAdminTips() { 2520 const el = document.getElementById('admin-tips');
2521 const pending = state.tips.filter(t => t.status === 'pending'); 2522 const approved = state.tips.filter(t => t.status === 'approved'); 2523 el.innerHTML = ` 2524 <p class="admin-sublabel">En attente (${pending.length})</p> 2525 ${pending.length === 0 2526 ? '<p style="font-size:0.82rem;color:var(--text-light);font-style:italic;margin-bottom:1rem;">Aucun conseil en attente.</p>' 2527 : pending.map(t => ` 2528 <div class="proposal-card" style="margin-bottom:0.75rem;"> 2529 <div class="proposal-card-header"> 2530 ${t.coverUrl ? `<img class="proposal-cover" src="${t.coverUrl}" alt="">` : `<div class="proposal-cover-ph">ð</div>`} 2531 <div> 2532 <div class="proposal-title">${escapeHtml(t.title)}</div> 2533 <div class="proposal-author">${escapeHtml(t.author || 'â')}</div> 2534 <div class="proposal-proposer">Recommandé par ${escapeHtml(t.recommendedBy?.name || 'Anonyme')}</div> 2535 </div> 2536 </div> 2537 ${t.note ? `<div class="proposal-note">"${escapeHtml(t.note)}"</div>` : ''} 2538 <div class="proposal-actions"> 2539 <button class="btn-approve" data-tip-id="${t.id}">â Approuver</button> 2540 <button class="btn-edit-tip" data-tip-id="${t.id}">âï¸ Modifier</button> 2541 <button class="btn-reject" data-tip-id="${t.id}">â Refuser</button> 2542 </div> 2543 <div class="admin-tip-edit-form hidden" id="tip-edit-${t.id}"> 2544 <label class="past-edit-label">Titre</label> 2545 <input type="text" class="past-edit-input tip-edit-title" value="${escapeHtml(t.title || '')}"> 2546 <label class="past-edit-label">Auteur</label> 2547 <input type="text" class="past-edit-input tip-edit-author" value="${escapeHtml(t.author || '')}"> 2548 <label class="past-edit-label">Recommandé par</label> 2549 <input type="text" class="past-edit-input tip-edit-recommender" value="${escapeHtml(t.recommendedBy?.name || '')}"> 2550 <label class="past-edit-label">Note</label> 2551 <textarea class="past-edit-input tip-edit-note" rows="2">${escapeHtml(t.note || '')}</textarea> 2552 <button class="btn-save-past-edit btn-save-tip-edit" data-tip-id="${t.id}">Enregistrer</button> 2553 </div> 2554 </div>`).join('')} 2555 <p class="admin-sublabel" style="margin-top:1rem;">Publiés (${approved.length})</p> 2556 ${approved.length === 0 2557 ? '<p style="font-size:0.82rem;color:var(--text-light);font-style:italic;">Aucun conseil publié.</p>' 2558 : approved.map(t => ` 2559 <div class="admin-tip-item" data-tip-id="${t.id}"> 2560 <div class="admin-tip-header"> 2561 <span class="theme-name">${escapeHtml(t.title)} <span style="color:var(--text-light);font-weight:400;">â ${escapeHtml(t.recommendedBy?.name || 'Anonyme')}</span></span> 2562 <span> 2563 <button class="btn-edit-tip" data-tip-id="${t.id}" title="Modifier">âï¸</button> 2564 <button class="btn-delete-theme" data-tip-id="${t.id}" title="Supprimer">ð</button> 2565 </span> 2566 </div> 2567 <div class="admin-tip-edit-form hidden" id="tip-edit-${t.id}"> 2568 <label class="past-edit-label">Titre</label> 2569 <input type="text" class="past-edit-input tip-edit-title" value="${escapeHtml(t.title || '')}"> 2570 <label class="past-edit-label">Auteur</label> 2571 <input type="text" class="past-edit-input tip-edit-author" value="${escapeHtml(t.author || '')}"> 2572 <label class="past-edit-label">Recommandé par</label> 2573 <input type="text" class="past-edit-input tip-edit-recommender" value="${escapeHtml(t.recommendedBy?.name || '')}"> 2574 <label class="past-edit-label">Note</label> 2575 <textarea class="past-edit-input tip-edit-note" rows="2">${escapeHtml(t.note || '')}</textarea> 2576 <button class="btn-save-past-edit btn-save-tip-edit" data-tip-id="${t.id}">Enregistrer</button> 2577 </div> 2578 </div>`).join('')}`; 2579 2580 el.querySelectorAll('.btn-approve[data-tip-id]').forEach(btn => { 2581 btn.addEventListener('click', () => approveTip(btn.dataset.tipId)); 2582 }); 2583 el.querySelectorAll('.btn-reject[data-tip-id]').forEach(btn => { 2584 btn.addEventListener('click', () => rejectTip(btn.dataset.tipId)); 2585 }); 2586 el.querySelectorAll('.btn-delete-theme[data-tip-id]').forEach(btn => { 2587 btn.addEventListener('click', () => deleteTip(btn.dataset.tipId)); 2588 }); 2589 el.querySelectorAll('.btn-edit-tip').forEach(btn => { 2590 btn.addEventListener('click', () => { 2591 const form = document.getElementById(`tip-edit-${btn.dataset.tipId}`); 2592 form.classList.toggle('hidden'); 2593 }); 2594 }); 2595 el.querySelectorAll('.btn-save-tip-edit').forEach(btn => { 2596 btn.addEventListener('click', async () => { 2597 const id = btn.dataset.tipId; 2598 const form = document.getElementById(`tip-edit-${id}`); 2599 const title = form.querySelector('.tip-edit-title').value.trim(); 2600 const author = form.querySelector('.tip-edit-author').value.trim(); 2601 const recommenderName = form.querySelector('.tip-edit-recommender').value.trim(); 2602 const note = form.querySelector('.tip-edit-note').value.trim(); 2603 if (!title) { showToast('Le titre est obligatoire.'); return; }
2604 const tip = state.tips.find(t => t.id === id); 2605 await setDoc(doc(db, 'tips', id), { 2606 title, author, note, 2607 recommendedBy: { ...tip?.recommendedBy, name: recommenderName } 2608 }, { merge: true }); 2609 showToast('Conseil modifié !'); 2610 }); 2611 }); 2612} 2613 2614// ââ Render : admin â limites âââââââââââââââââââââââââââââ 2615function renderAdminLimits() { 2616 const el = document.getElementById('admin-limits'); 2617 const L = state.limits; 2618 el.innerHTML = ` 2619 <p class="admin-sublabel">Limites globales</p> 2620 <div class="limits-form"> 2621 <label class="past-edit-label">Propositions de livres max au total (toutes personnes confondues)</label> 2622 <input type="number" class="past-edit-input" id="limit-max-proposals-total" value="${L.maxProposalsTotal}" min="1" max="10000"> 2623 <label class="past-edit-label">Propositions sans compte (localStorage, par mois) â 0 = connexion obligatoire</label> 2624 <input type="number" class="past-edit-input" id="limit-anon-proposals" value="${L.anonProposals}" min="0" max="50"> 2625 </div> 2626 <p class="admin-sublabel" style="margin-top:1rem;">Limites par utilisateur connecté (par mois)</p> 2627 <div class="limits-form"> 2628 <label class="past-edit-label">Propositions de livres</label> 2629 <input type="number" class="past-edit-input" id="limit-monthly-proposals" value="${L.monthlyProposals}" min="1" max="100"> 2630 <label class="past-edit-label">Conseils de lecture</label> 2631 <input type="number" class="past-edit-input" id="limit-monthly-tips" value="${L.monthlyTips}" min="1" max="100"> 2632 </div> 2633 <p class="admin-sublabel" style="margin-top:1rem;">Thèmes du sondage</p> 2634 <div class="limits-form"> 2635 <label class="past-edit-label">Nombre max de thèmes</label> 2636 <input type="number" class="past-edit-input" id="limit-max-themes" value="${L.maxThemes}" min="1" max="100"> 2637 <label class="past-edit-label">Thèmes gratuits avant validation admin</label> 2638 <input type="number" class="past-edit-input" id="limit-free-themes" value="${L.freeThemes}" min="0" max="50"> 2639 <button class="btn-save-past-edit" id="btn-save-limits" style="margin-top:0.75rem;">Enregistrer les limites</button> 2640 </div> 2641 <p class="admin-sublabel" style="margin-top:1.5rem;">Membres officiels (${state.members.length})</p> 2642 <p style="font-size:0.75rem;color:var(--text-light);margin-bottom:0.5rem;">Les membres peuvent publier des critiques sans validation admin.</p> 2643 <div class="members-list"> 2644 ${state.members.length === 0 2645 ? '<p style="font-size:0.82rem;color:var(--text-light);font-style:italic;">Aucun membre officiel.</p>' 2646 : state.members.map(email => ` 2647 <div class="member-item"> 2648 <span class="member-email">${escapeHtml(email)}</span> 2649 <button class="btn-remove-member" data-email="${escapeHtml(email)}" title="Retirer">â</button> 2650 </div>`).join('')} 2651 </div> 2652 <div class="member-add" style="display:flex;gap:0.35rem;margin-top:0.5rem;"> 2653 <input type="email" id="new-member-email" class="past-edit-input" placeholder="[email protected]" style="flex:1;"> 2654 <button class="btn-save-past-edit" id="btn-add-member">Ajouter</button> 2655 </div>`; 2656 document.getElementById('btn-save-limits').addEventListener('click', () => { 2657 saveLimits({ 2658 maxProposalsTotal: +document.getElementById('limit-max-proposals-total').value || 100, 2659 anonProposals: +document.getElementById('limit-anon-proposals').value, 2660 monthlyProposals: +document.getElementById('limit-monthly-proposals').value || 15, 2661 monthlyTips: +document.getElementById('limit-monthly-tips').value || 15, 2662 maxThemes: +document.getElementById('limit-max-themes').value || 20, 2663 freeThemes: +document.getElementById('limit-free-themes').value || 2 2664 }); 2665 }); 2666 document.getElementById('btn-add-member').addEventListener('click', () => { 2667 const email = document.getElementById('new-member-email').value.trim(); 2668 if (email) { addMember(email); document.getElementById('new-member-email').value = ''; } 2669 }); 2670 el.querySelectorAll('.btn-remove-member').forEach(btn => { 2671 btn.addEventListener('click', () => { 2672 if (confirm(`Retirer ${btn.dataset.email} des membres ?`)) removeMember(btn.dataset.email); 2673 }); 2674 }); 2675} 2676 2677// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2678// INITIALISATION 2679// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2680 2681// ââ Init modale livre ââââââââââââââââââââââââââââââââââââ 2682document.getElementById('book-modal-close').addEventListener('click', closeBookModal); 2683document.getElementById('book-modal').addEventListener('click', e => { 2684 if (e.target === document.getElementById('book-modal')) closeBookModal(); 2685}); 2686 2687// ââ Modale proposition ââââââââââââââââââââââââââââââââââ 2688document.getElementById('propose-modal-close').addEventListener('click', () => { 2689 document.getElementById('propose-modal').classList.add('hidden'); 2690}); 2691document.getElementById('propose-modal').addEventListener('click', e => { 2692 if (e.target === document.getElementById('propose-modal')) { 2693 document.getElementById('propose-modal').classList.add('hidden'); 2694 } 2695}); 2696 2697// ââ Admin panel ââââââââââââââââââââââââââââââââââââââââââ 2698document.getElementById('admin-fab').addEventListener('click', () => { 2699 document.getElementById('admin-overlay').classList.remove('hidden'); 2700 renderAdminSessions(); 2701}); 2702document.getElementById('btn-close-admin').addEventListener('click', () => { 2703 document.getElementById('admin-overlay').classList.add('hidden'); 2704}); 2705document.getElementById('admin-overlay').addEventListener('click', e => { 2706 if (e.target === document.getElementById('admin-overlay')) 2707 document.getElementById('admin-overlay').classList.add('hidden'); 2708}); 2709document.querySelectorAll('.admin-tab-btn').forEach(btn => { 2710 btn.addEventListener('click', () => { 2711 document.querySelectorAll('.admin-tab-btn').forEach(b => b.classList.remove('active')); 2712 document.querySelectorAll('.admin-tab-content').forEach(c => c.classList.add('hidden')); 2713 btn.classList.add('active'); 2714 document.getElementById(`tab-${btn.dataset.tab}`).classList.remove('hidden'); 2715 if (btn.dataset.tab === 'session') renderAdminSessions(); 2716 if (btn.dataset.tab === 'past') renderAdminPastSessions(); 2717 if (btn.dataset.tab === 'critiques') renderAdminCritiques(); 2718 if (btn.dataset.tab === 'themes') renderAdminThemes(); 2719 if (btn.dataset.tab === 'tips') renderAdminTips(); 2720 if (btn.dataset.tab === 'limits') renderAdminLimits(); 2721 }); 2722}); 2723 2724// ââ Auth state listener ââââââââââââââââââââââââââââââââââ 2725initAuthStateListener(user => { 2726 // Refresh member status 2727 userState.isMember = !!(user && (state.members.includes(user.email) || userState.isAdmin)); 2728 renderHero(); 2729 renderAllSessions(); 2730 renderPastSessions(); 2731 renderSuggestSection(); 2732 renderTipForm(); 2733 renderTips(); 2734 renderPoll(); 2735 const fab = document.getElementById('admin-fab'); 2736 if (userState.isAdmin) { 2737 fab.classList.remove('hidden'); 2738 if (unsubProposals) unsubProposals(); 2739 unsubProposals = listenProposals(); 2740 if (unsubPendingCritiques) unsubPendingCritiques(); 2741 unsubPendingCritiques = listenPendingCritiques(); 2742 } else { 2743 fab.classList.add('hidden'); 2744 if (unsubProposals) { unsubProposals(); unsubProposals = null; } 2745 if (unsubPendingCritiques) { unsubPendingCritiques(); unsubPendingCritiques = null; } 2746 document.getElementById('admin-overlay').classList.add('hidden'); 2747 } 2748}); 2749 2750// ââ Nav brand click ââââââââââââââââââââââââââââââââââââââ 2751document.querySelector('.nav-brand').addEventListener('click', e => { 2752 e.preventDefault(); 2753 window.scrollTo({ top: 0, behavior: 'smooth' }); 2754 renderHero(); 2755 renderAllSessions(); 2756 renderPastSessions(); 2757 renderSuggestSection(); 2758 renderTips(); 2759 renderPoll(); 2760}); 2761 2762// ââ Profile update callback ââââââââââââââââââââââââââââââ 2763window._onProfileUpdate = () => { 2764 renderNavUser(); 2765}; 2766 2767// ââ Init shared UI âââââââââââââââââââââââââââââââââââââââ 2768initHamburger(); 2769initAvatarDropdownClose(); 2770initAuthUI(); 2771initNotificationBell(); 2772initBugReport(); 2773 2774// ââ Routes (deep linking) ââââââââââââââââââââââââââââââââ 2775registerRoute('book', async ([sessionId, bookIndex]) => { 2776 await waitForData('sessions'); 2777 const s = state.sessions.find(s => s.id === sessionId); 2778 if (s && s.books[+bookIndex]) { 2779 openBookModal(s.books[+bookIndex], sessionId, 'sessions', +bookIndex); 2780 return; 2781 } 2782 // Fallback : chercher dans les sessions passées 2783 await waitForData('pastSessions'); 2784 const p = state.pastSessions.find(p => p.id === sessionId); 2785 if (p && p.books[+bookIndex]) { 2786 openBookModal(p.books[+bookIndex], sessionId, 'pastSessions', +bookIndex); 2787 } 2788}); 2789 2790registerRoute('pastbook', async ([sessionId, bookIndex]) => { 2791 await waitForData('pastSessions'); 2792 const p = state.pastSessions.find(p => p.id === sessionId); 2793 if (p && p.books[+bookIndex]) { 2794 openBookModal(p.books[+bookIndex], sessionId, 'pastSessions', +bookIndex); 2795 } 2796}); 2797
2798registerRoute('tip', async ([tipId]) => { 2799 await waitForData('tips'); 2800 const t = state.tips.find(t => t.id === tipId); 2801 if (t) openBookModal({ title: t.title, author: t.author, description: t.description || '', coverUrl: t.coverUrl, presenter: '' }, null, null, null); 2802}); 2803 2804registerRoute('login', async () => { 2805 const { showLoginModal } = await import('./auth.js'); 2806 showLoginModal(); 2807}); 2808 2809// ââ Démarrage ââââââââââââââââââââââââââââââââââââââââââââ 2810loadLimits(); 2811listenSessions(); 2812listenPastSessions(); 2813listenThemes(); 2814listenTips(); 2815listenMembers(); 2816listenActivity(); 2817initCalendar(); 2818initScrollReveal(); 2819processCurrentHash();
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.