Book Club Bingo
Set Theme…
Set Book Title…
Offline Mode
BINGO!
Screenshot your board and share it!
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);
};
})();