Book Club Bingo

Book Club Bingo https://cdn.tailwindcss.com @import url(‘https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap’); :root { –brand-primary: #4f46e5; –brand-secondary: #e0e7ff; –brand-text: #312e81; –page-bg: #f8fafc; –header-text: #1e293b; } body { font-family: ‘Inter’, sans-serif; -webkit-tap-highlight-color: transparent; background-color: var(–page-bg); color: var(–brand-text); transition: background-color 0.8s ease, color 0.5s ease; overflow-x: hidden; min-height: 100vh; } #effect-canvas { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 0; opacity: 0.5; } #main-title { color: var(–header-text) !important; transition: color 0.5s ease; } /* Tile Styling */ .tile-active { background-color: var(–brand-primary) !important; color: white !important; transform: scale(0.96); box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.2); } .free-space { background-color: #fef3c7 !important; color: #92400e !important; font-weight: 800; } .tile-active.free-space { background-color: #f59e0b !important; color: white !important; } @keyframes celebrate { 0% { transform: scale(1); } 50% { transform: scale(1.05); } 100% { transform: scale(1); } } .bingo-winner { animation: celebrate 0.5s ease infinite; border: 4px solid #10b981 !important; } .loading-overlay { background: white; z-index: 100; } #book-cover { transition: all 0.5s ease; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(0, 0, 0, 0.2); } /* Dynamic Visual Classes */ .dynamic-bg { background-color: var(–brand-secondary) !important; transition: all 0.5s ease; } .dynamic-text { color: var(–brand-text) !important; transition: all 0.5s ease; } .dynamic-border { border-color: var(–brand-primary) !important; transition: all 0.5s ease; } .dynamic-btn { background-color: var(–brand-primary) !important; transition: all 0.5s ease; } .dynamic-card { background-color: var(–brand-secondary) !important; border: 1px solid var(–brand-primary); transition: all 0.5s ease; } .scanlines::after { content: ” “; display: block; position: absolute; top: 0; left: 0; bottom: 0; right: 0; background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.05) 50%); z-index: 2; background-size: 100% 4px; pointer-events: none; opacity: 0.3; }

Syncing Club Data…

Book Club Bingo

Set Theme…

Set Book Title…

Offline Mode

Club Charter

  • 01 Theme-picker rotates alphabetically each month.
  • 02 Everyone suggests 1 book based on the theme.
  • 03 Vote via anonymous poll; top 2 books are locked in.
  • 04 The top 2 picks cover the next two months.
  • 05 DNF is okay! Just tell us why it didn’t click.
  • 06 Share a line or quote with the group chat.
  • 07 Meet at month-end (Coffee/Park/Digital).
Link copied!
import { initializeApp } from “https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js”; import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged } from “https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js”; import { getFirestore, doc, setDoc, onSnapshot } from “https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js”; (async function() { /** * TO PERSIST DATA FOR THE GROUP ON GITHUB: * Create a free project at console.firebase.google.com and paste your config here. */ const MY_CUSTOM_FIREBASE_CONFIG = “”; const STORAGE_KEY_BOARD = ‘book_club_bingo_board_v11’; const STORAGE_KEY_MARKED = ‘book_club_bingo_marked_v11’; const STORAGE_KEY_TITLE = ‘book_club_bingo_title_v11’; const STORAGE_KEY_THEME = ‘book_club_bingo_theme_v11’; const THEME_COLORS = { horror: { primary: ‘#7f1d1d’, secondary: ‘#111827’, text: ‘#fecaca’, header: ‘#ffffff’, bg: ‘#030712’, effect: ‘stars’ }, fantasy: { primary: ‘#4338ca’, secondary: ‘#1e1b4b’, text: ‘#e0e7ff’, header: ‘#ffffff’, bg: ‘#0c0a21’, effect: ‘stars’ }, myth: { primary: ‘#a855f7’, secondary: ‘#3b0764’, text: ‘#f5f3ff’, header: ‘#ffffff’, bg: ‘#1e1b4b’, effect: ‘stars’ }, scifi: { primary: ‘#0891b2’, secondary: ‘#083344’, text: ‘#ecfeff’, header: ‘#ffffff’, bg: ‘#020617’, effect: ‘scan’ }, cyber: { primary: ‘#22d3ee’, secondary: ‘#083344’, text: ‘#ecfeff’, header: ‘#ffffff’, bg: ‘#020617’, effect: ‘scan’ }, hist: { primary: ‘#78350f’, secondary: ‘#fef3c7’, text: ‘#451a03’, header: ‘#1e293b’, bg: ‘#fffcf5’, effect: ‘grain’ }, classic: { primary: ‘#15803d’, secondary: ‘#f0fdf4’, text: ‘#14532d’, header: ‘#1e293b’, bg: ‘#f6fff8’, effect: ‘grain’ }, acad: { primary: ‘#451a03’, secondary: ‘#d97706’, text: ‘#fef3c7’, header: ‘#fef3c7’, bg: ‘#1c1917’, effect: ‘grain’ }, romance: { primary: ‘#db2777’, secondary: ‘#fce7f3’, text: ‘#831843’, header: ‘#1e293b’, bg: ‘#fff5f7’, effect: ‘bubbles’ }, nature: { primary: ‘#166534’, secondary: ‘#dcfce7’, text: ‘#064e3b’, header: ‘#1e293b’, bg: ‘#f0fdf4’, effect: ‘leaf’ }, travel: { primary: ‘#0369a1’, secondary: ‘#e0f2fe’, text: ‘#0c4a6e’, header: ‘#1e293b’, bg: ‘#f0f9ff’, effect: ‘leaf’ }, social: { primary: ‘#4f46e5’, secondary: ‘#f1f5f9’, text: ‘#334155’, header: ‘#1e293b’, bg: ‘#ffffff’, effect: ‘bubbles’ }, contemp: { primary: ‘#0f172a’, secondary: ‘#f1f5f9’, text: ‘#334155’, header: ‘#1e293b’, bg: ‘#ffffff’, effect: ‘bubbles’ }, cozy: { primary: ‘#ea580c’, secondary: ‘#ffedd5’, text: ‘#7c2d12’, header: ‘#1e293b’, bg: ‘#fffaf5’, effect: ‘bubbles’ }, default: { primary: ‘#4f46e5’, secondary: ‘#e0e7ff’, text: ‘#312e81’, header: ‘#1e293b’, bg: ‘#f8fafc’, effect: ‘none’ } }; const masterChallenges = [ “Read a chapter outdoors”, “Character makes a choice you hate”, “Try a new coffee roast”, “Finish a book early”, “Share a line or quote with the group”, “Prediction came true”, “Plot twist you didn’t see”, “Read 50 pages in one sitting”, “Mention a vegan snack”, “Look up a word you didn’t know”, “Share a ‘casting’ idea”, “Finish the book!”, “FREE SPACE”, “Recommend a book”, “Read in an NWA park”, “Character has a pet”, “Bookmark is not a bookmark”, “Author’s debut novel”, “Read a new genre”, “Book has a map”, “Emotional ending”, “Favorite quote shared”, “Listen to an audiobook”, “Book with a blue cover”, “Theme-related snack” ]; let activeBoard = []; let marked = new Array(25).fill(false); marked[12] = true; let currentUser = null; let db = null; let isCloudActive = false; let currentEffect = ‘none’; const gridElement = document.getElementById(‘bingo-grid’); const alertElement = document.getElementById(‘bingo-alert’); const loader = document.getElementById(‘loading-spinner’); const titleElement = document.getElementById(‘book-title’); const themeElement = document.getElementById(‘current-theme’); const syncDot = document.getElementById(‘sync-dot’); const syncText = document.getElementById(‘sync-text’); const coverImg = document.getElementById(‘book-cover’); const coverContainer = document.getElementById(‘cover-container’); const canvas = document.getElementById(‘effect-canvas’); const ctx = canvas.getContext(‘2d’); function hideLoader() { loader.style.opacity = ‘0’; setTimeout(() => loader.style.display = ‘none’, 500); } const failsafe = setTimeout(hideLoader, 3000); // — THEME ENGINE — function updateUITheme(themeText) { if (!themeText) return; const lower = themeText.toLowerCase(); let palette = THEME_COLORS.default; // Enhanced matching logic const keys = Object.keys(THEME_COLORS).filter(k => k !== ‘default’); for (const key of keys) { if (lower.includes(key)) { palette = THEME_COLORS[key]; break; } } const root = document.documentElement; root.style.setProperty(‘–brand-primary’, palette.primary); root.style.setProperty(‘–brand-secondary’, palette.secondary); root.style.setProperty(‘–brand-text’, palette.text); root.style.setProperty(‘–page-bg’, palette.bg); root.style.setProperty(‘–header-text’, palette.header); currentEffect = palette.effect; if (currentEffect === ‘scan’) document.body.classList.add(‘scanlines’); else document.body.classList.remove(‘scanlines’); initParticles(); } // — CANVAS ANIMATIONS — let particles = []; function resizeCanvas() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; } window.addEventListener(‘resize’, resizeCanvas); resizeCanvas(); function initParticles() { particles = []; const count = currentEffect === ‘none’ ? 0 : 50; for(let i=0; i { ctx.globalAlpha = p.opacity; ctx.fillStyle = ‘#fff’; ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI*2); ctx.fill(); p.y -= p.speed; if (p.y { ctx.globalAlpha = 0.12; ctx.fillStyle = primary; ctx.beginPath(); ctx.arc(p.x, p.y, p.size * 6, 0, Math.PI*2); ctx.fill(); p.y -= p.speed * 2; if (p.y { ctx.globalAlpha = 0.2; ctx.fillStyle = primary; ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.angle); ctx.beginPath(); ctx.ellipse(0, 0, 8, 4, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); p.y += p.speed; p.x += Math.sin(p.y/40); p.angle += 0.01; if (p.y > canvas.height) p.y = -10; }); } else if (currentEffect === ‘grain’) { ctx.fillStyle = ‘rgba(0,0,0,0.03)’; for(let i=0; i { const btn = document.createElement(‘button’); btn.className = `aspect-square p-0.5 sm:p-1.5 rounded-xl text-[8px] sm:text-[11px] font-semibold transition-all duration-200 flex items-center justify-center text-center leading-tight bg-white/90 text-slate-600 border border-slate-200 hover:bg-white relative shadow-sm overflow-hidden`; if (marked[i]) btn.classList.add(’tile-active’); if (i === 12) { btn.classList.add(‘free-space’); btn.innerText = “FREE SPACE”; } else btn.innerText = text; btn.onclick = () => toggleTile(i); gridElement.appendChild(btn); }); const hasBingo = [ [0, 1, 2, 3, 4], [5, 6, 7, 8, 9], [10, 11, 12, 13, 14], [15, 16, 17, 18, 19], [20, 21, 22, 23, 24], [0, 5, 10, 15, 20], [1, 6, 11, 16, 21], [2, 7, 12, 17, 22], [3, 8, 13, 18, 23], [4, 9, 14, 19, 24], [0, 6, 12, 18, 24], [4, 8, 12, 16, 20] ].some(line => line.every(idx => marked[idx])); if (alertElement) alertElement.classList.toggle(‘hidden’, !hasBingo); } function toggleTile(idx) { if (idx === 12) return; marked[idx] = !marked[idx]; localStorage.setItem(STORAGE_KEY_MARKED, JSON.stringify(marked)); renderBoard(); } window.confirmReset = () => { if (confirm(“Reset your individual progress?”)) { activeBoard = shuffle([…masterChallenges]).slice(0, 24); activeBoard.splice(12, 0, “FREE SPACE”); // Ensure space 12 is correct marked = new Array(25).fill(false); marked[12] = true; localStorage.setItem(STORAGE_KEY_BOARD, JSON.stringify(activeBoard)); localStorage.setItem(STORAGE_KEY_MARKED, JSON.stringify(marked)); renderBoard(); } }; async function saveSharedData() { const title = titleElement.innerText.trim(); const theme = themeElement.innerText.trim(); localStorage.setItem(STORAGE_KEY_TITLE, title); localStorage.setItem(STORAGE_KEY_THEME, theme); const coverUrl = await fetchBookCover(title); updateCoverUI(coverUrl); if (isCloudActive && currentUser) { try { const appId = typeof __app_id !== ‘undefined’ ? __app_id : ‘book-club-default’; const sharedDoc = doc(db, ‘artifacts’, appId, ‘public’, ‘data’, ‘book_club_meta’); await setDoc(sharedDoc, { title, theme, coverUrl, updatedAt: Date.now() }, { merge: true }); } catch (e) {} } } themeElement.addEventListener(‘input’, (e) => updateUITheme(e.target.innerText)); titleElement.addEventListener(‘blur’, saveSharedData); themeElement.addEventListener(‘blur’, saveSharedData); const setupCloud = async () => { let config = null; try { if (MY_CUSTOM_FIREBASE_CONFIG) config = JSON.parse(MY_CUSTOM_FIREBASE_CONFIG); else if (typeof __firebase_config !== ‘undefined’) config = JSON.parse(__firebase_config); } catch (e) {} if (!config) { hideLoader(); return; } try { const fbApp = initializeApp(config); const auth = getAuth(fbApp); db = getFirestore(fbApp); const appId = typeof __app_id !== ‘undefined’ ? __app_id : ‘book-club-default’; onAuthStateChanged(auth, (user) => { currentUser = user; if (user) { isCloudActive = true; syncDot.classList.replace(‘bg-slate-300’, ‘bg-emerald-500’); syncText.innerText = “Group Sync Active”; syncText.classList.replace(‘text-slate-400’, ‘text-emerald-600’); const sharedDoc = doc(db, ‘artifacts’, appId, ‘public’, ‘data’, ‘book_club_meta’); onSnapshot(sharedDoc, (snap) => { if (snap.exists()) { const data = snap.data(); if (document.activeElement !== titleElement) titleElement.innerText = data.title; if (document.activeElement !== themeElement) { themeElement.innerText = data.theme; updateUITheme(data.theme); } updateCoverUI(data.coverUrl); } clearTimeout(failsafe); hideLoader(); }, () => hideLoader()); } }); if (typeof __initial_auth_token !== ‘undefined’ && __initial_auth_token) await signInWithCustomToken(auth, __initial_auth_token); else await signInAnonymously(auth); } catch (e) { hideLoader(); } }; const savedBoard = localStorage.getItem(STORAGE_KEY_BOARD); const savedMarked = localStorage.getItem(STORAGE_KEY_MARKED); const savedTitle = localStorage.getItem(STORAGE_KEY_TITLE); const savedTheme = localStorage.getItem(STORAGE_KEY_THEME); if (savedBoard) activeBoard = JSON.parse(savedBoard); else { const shuffled = shuffle([…masterChallenges]); activeBoard = []; let ci = 0; for(let i=0; i updateCoverUI(url)); } if (savedTheme) { themeElement.innerText = savedTheme; updateUITheme(savedTheme); } renderBoard(); setupCloud(); window.copyLink = () => { const url = window.location.href; const temp = document.createElement(‘input’); document.body.appendChild(temp); temp.value = url; temp.select(); document.execCommand(‘copy’); document.body.removeChild(temp); const t = document.getElementById(‘toast’); t.style.opacity = ‘1’; setTimeout(() => t.style.opacity = ‘0’, 2000); }; })();