Skip to content

Commit 3ef192b

Browse files
alfredangclaude
andcommitted
feat: add Firebase Realtime Database for cross-device sync
- Create Firebase project (wordcloud-live) with Realtime Database - Replace localStorage-only sync with Firebase as primary transport - Keep localStorage + BroadcastChannel as same-browser fallback - Add database security rules with submission validation - Participants on different devices can now see live word cloud updates Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7f1f57a commit 3ef192b

7 files changed

Lines changed: 175 additions & 23 deletions

File tree

.firebaserc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"projects": {
3+
"default": "wordcloud-live"
4+
}
5+
}

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@
33
.DS_Store
44
.playwright-mcp/
55
node_modules/
6+
.firebase/
7+
firebase-debug.log
8+
ui-debug.log

app.js

Lines changed: 108 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,25 @@ let participantSubmissions = []; // Track this participant's submissions
6464
let lastSubmitTime = 0;
6565

6666
/* =============================================================
67-
STORAGE-BASED REAL-TIME SYNC
68-
Uses localStorage + storage events for cross-tab communication.
69-
Replace with WebSocket/Firebase for production multi-device use.
67+
REAL-TIME SYNC LAYER
68+
Uses Firebase Realtime Database for cross-device sync.
69+
Falls back to localStorage + BroadcastChannel if Firebase
70+
is not configured.
7071
============================================================= */
7172
const STORAGE_KEY_PREFIX = 'wc_';
73+
let firebaseListener = null; // Track active Firebase listener
7274

7375
function storageKey(roomId, key) {
7476
return `${STORAGE_KEY_PREFIX}${roomId}_${key}`;
7577
}
7678

79+
// --- Firebase sync ---
80+
81+
function getRoomRef(roomId) {
82+
if (!firebaseDB) return null;
83+
return firebaseDB.ref('rooms/' + roomId);
84+
}
85+
7786
function saveRoomState() {
7887
if (!state.roomId) return;
7988
const payload = {
@@ -84,19 +93,78 @@ function saveRoomState() {
8493
participants: [...state.participants],
8594
updatedAt: Date.now()
8695
};
96+
97+
// Always save to localStorage (local fallback)
8798
localStorage.setItem(storageKey(state.roomId, 'state'), JSON.stringify(payload));
99+
100+
// Save to Firebase if available
101+
const ref = getRoomRef(state.roomId);
102+
if (ref) {
103+
ref.set(payload).catch(e => console.warn('[WordCloud] Firebase write error:', e.message));
104+
}
88105
}
89106

90107
function loadRoomState(roomId) {
108+
// Try localStorage first (synchronous, for init)
91109
const raw = localStorage.getItem(storageKey(roomId, 'state'));
92110
if (!raw) return null;
93111
try { return JSON.parse(raw); }
94112
catch { return null; }
95113
}
96114

97-
// Listen for cross-tab updates
115+
async function loadRoomStateFromFirebase(roomId) {
116+
const ref = getRoomRef(roomId);
117+
if (!ref) return null;
118+
try {
119+
const snapshot = await ref.once('value');
120+
return snapshot.val();
121+
} catch (e) {
122+
console.warn('[WordCloud] Firebase read error:', e.message);
123+
return null;
124+
}
125+
}
126+
127+
function subscribeToRoom(roomId) {
128+
// Unsubscribe from previous room
129+
if (firebaseListener) {
130+
firebaseListener.off();
131+
firebaseListener = null;
132+
}
133+
134+
const ref = getRoomRef(roomId);
135+
if (!ref) return;
136+
137+
firebaseListener = ref;
138+
ref.on('value', (snapshot) => {
139+
const data = snapshot.val();
140+
if (!data) return;
141+
142+
// Update local state from Firebase
143+
state.question = data.question || state.question;
144+
state.submissions = data.submissions || [];
145+
state.locked = data.locked || false;
146+
state.allowDuplicates = data.allowDuplicates || false;
147+
state.participants = new Set(data.participants || []);
148+
149+
// Also update localStorage cache
150+
localStorage.setItem(storageKey(roomId, 'state'), JSON.stringify(data));
151+
152+
// Re-render
153+
const facilView = document.getElementById('facilitator-view');
154+
const partView = document.getElementById('participant-view');
155+
if (facilView && !facilView.classList.contains('hidden')) {
156+
renderCurrentView();
157+
}
158+
if (partView && !partView.classList.contains('hidden')) {
159+
renderParticipant();
160+
}
161+
});
162+
}
163+
164+
// --- localStorage / BroadcastChannel fallback ---
165+
98166
window.addEventListener('storage', (e) => {
99-
if (!state.roomId) return;
167+
if (!state.roomId || firebaseDB) return; // Skip if Firebase is active
100168
const key = storageKey(state.roomId, 'state');
101169
if (e.key === key && e.newValue) {
102170
try {
@@ -111,11 +179,11 @@ window.addEventListener('storage', (e) => {
111179
}
112180
});
113181

114-
// BroadcastChannel for same-origin real-time
115182
let bc;
116183
try {
117184
bc = new BroadcastChannel('wordcloud_sync');
118185
bc.onmessage = (e) => {
186+
if (firebaseDB) return; // Skip if Firebase is active
119187
if (e.data.roomId !== state.roomId) return;
120188
if (e.data.type === 'state_update') {
121189
state.question = e.data.state.question;
@@ -130,7 +198,8 @@ try {
130198

131199
function broadcastState() {
132200
saveRoomState();
133-
if (bc) {
201+
// BroadcastChannel for same-browser fallback
202+
if (bc && !firebaseDB) {
134203
bc.postMessage({
135204
type: 'state_update',
136205
roomId: state.roomId,
@@ -185,11 +254,12 @@ function init() {
185254
}
186255
}
187256

188-
function initFacilitator() {
257+
async function initFacilitator() {
189258
// Check for existing room or create new
190259
const savedRoom = sessionStorage.getItem('wc_host_room');
191260
if (savedRoom) {
192-
const existing = loadRoomState(savedRoom);
261+
// Try Firebase first, then localStorage
262+
const existing = (firebaseDB ? await loadRoomStateFromFirebase(savedRoom) : null) || loadRoomState(savedRoom);
193263
if (existing) {
194264
state.roomId = savedRoom;
195265
state.question = existing.question;
@@ -204,10 +274,15 @@ function initFacilitator() {
204274
createNewRoom();
205275
}
206276

277+
// Subscribe to Firebase real-time updates
278+
subscribeToRoom(state.roomId);
279+
207280
document.getElementById('facilitator-view').classList.remove('hidden');
208281
renderFacilitator();
209-
// Poll for updates (fallback for storage event edge cases)
210-
setInterval(pollUpdates, 800);
282+
// Poll for updates (fallback when Firebase is not available)
283+
if (!firebaseDB) {
284+
setInterval(pollUpdates, 800);
285+
}
211286
}
212287

213288
function createNewRoom() {
@@ -217,23 +292,31 @@ function createNewRoom() {
217292
state.participants = new Set();
218293
sessionStorage.setItem('wc_host_room', state.roomId);
219294
saveRoomState();
295+
// Subscribe to the new room in Firebase
296+
subscribeToRoom(state.roomId);
220297
}
221298

222-
function initParticipant(roomId) {
299+
async function initParticipant(roomId) {
223300
state.roomId = roomId;
224-
const existing = loadRoomState(roomId);
301+
302+
// Try Firebase first, then localStorage
303+
const existing = (firebaseDB ? await loadRoomStateFromFirebase(roomId) : null) || loadRoomState(roomId);
225304
if (existing) {
226305
state.question = existing.question;
227306
state.locked = existing.locked;
228307
state.allowDuplicates = existing.allowDuplicates;
229308
state.submissions = existing.submissions || [];
309+
state.participants = new Set(existing.participants || []);
230310
}
231311

232312
// Register participant
233313
const pid = getParticipantId();
234314
state.participants.add(pid);
235315
broadcastState();
236316

317+
// Subscribe to Firebase real-time updates
318+
subscribeToRoom(state.roomId);
319+
237320
document.getElementById('participant-view').classList.remove('hidden');
238321
renderParticipant();
239322

@@ -244,16 +327,18 @@ function initParticipant(roomId) {
244327
});
245328
pInput.focus();
246329

247-
// Poll for question/lock changes
248-
setInterval(() => {
249-
const data = loadRoomState(roomId);
250-
if (data) {
251-
state.question = data.question;
252-
state.locked = data.locked;
253-
state.allowDuplicates = data.allowDuplicates;
254-
renderParticipant();
255-
}
256-
}, 1500);
330+
// Poll for question/lock changes (fallback when Firebase is not available)
331+
if (!firebaseDB) {
332+
setInterval(() => {
333+
const data = loadRoomState(roomId);
334+
if (data) {
335+
state.question = data.question;
336+
state.locked = data.locked;
337+
state.allowDuplicates = data.allowDuplicates;
338+
renderParticipant();
339+
}
340+
}, 1500);
341+
}
257342
}
258343

259344
/* =============================================================

database.rules.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"rules": {
3+
"rooms": {
4+
"$roomId": {
5+
".read": true,
6+
".write": true,
7+
"submissions": {
8+
"$submissionId": {
9+
".validate": "newData.hasChildren(['word', 'normalizedWord', 'timestamp', 'roomId'])"
10+
}
11+
}
12+
}
13+
}
14+
}
15+
}

firebase-config.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/* =============================================================
2+
FIREBASE CONFIGURATION
3+
Auto-generated from Firebase CLI for project: wordcloud-live
4+
============================================================= */
5+
const FIREBASE_CONFIG = {
6+
apiKey: "AIzaSyARkc7ebEtCW9jYqUdtbZevDg3lL9F0YVE",
7+
authDomain: "wordcloud-live.firebaseapp.com",
8+
databaseURL: "https://wordcloud-live-default-rtdb.firebaseio.com",
9+
projectId: "wordcloud-live",
10+
storageBucket: "wordcloud-live.firebasestorage.app",
11+
messagingSenderId: "223206575168",
12+
appId: "1:223206575168:web:755550cfd674905aa463a5"
13+
};
14+
15+
/* =============================================================
16+
FIREBASE INITIALIZATION
17+
============================================================= */
18+
let firebaseApp = null;
19+
let firebaseDB = null;
20+
21+
function isFirebaseConfigured() {
22+
return FIREBASE_CONFIG.apiKey && !FIREBASE_CONFIG.apiKey.startsWith('YOUR_');
23+
}
24+
25+
if (isFirebaseConfigured()) {
26+
try {
27+
firebaseApp = firebase.initializeApp(FIREBASE_CONFIG);
28+
firebaseDB = firebase.database();
29+
console.log('[WordCloud] Firebase connected — cross-device sync enabled');
30+
} catch (e) {
31+
console.warn('[WordCloud] Firebase init failed, falling back to localStorage:', e.message);
32+
}
33+
} else {
34+
console.log('[WordCloud] Firebase not configured — using localStorage sync (same-browser only)');
35+
}

firebase.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"database": {
3+
"rules": "database.rules.json"
4+
}
5+
}

index.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ <h3>Edit Question</h3>
100100

101101
<!-- QR Code library (MIT license) -->
102102
<script src="https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js"></script>
103+
<!-- Firebase SDK (Realtime Database for cross-device sync) -->
104+
<script src="https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js"></script>
105+
<script src="https://www.gstatic.com/firebasejs/10.14.1/firebase-database-compat.js"></script>
106+
<script src="firebase-config.js"></script>
103107
<script src="app.js"></script>
104108
</body>
105109
</html>

0 commit comments

Comments
 (0)