Lashtw commited on
Commit
ae79a68
·
verified ·
1 Parent(s): 78849f8

Upload 9 files

Browse files
Files changed (2) hide show
  1. src/services/classroom.js +84 -14
  2. src/views/LandingView.js +100 -11
src/services/classroom.js CHANGED
@@ -87,7 +87,52 @@ export async function verifyInstructorPassword(inputPassword) {
87
  * @param {string} nickname
88
  * @returns {Promise<{userId: string, nickname: string}>} Object containing userId and final nickname
89
  */
90
- export async function joinRoom(roomCode, nickname) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  // 1. Verify Room Exists
92
  const roomRef = doc(db, ROOMS_COLLECTION, roomCode);
93
  const roomSnap = await getDoc(roomRef);
@@ -96,25 +141,50 @@ export async function joinRoom(roomCode, nickname) {
96
  throw new Error("教室代碼不存在");
97
  }
98
 
99
- // 2. Check for nickname collision
100
  const usersRef = collection(db, USERS_COLLECTION);
101
- const q = query(
102
- usersRef,
103
- where("current_room", "==", roomCode),
104
- where("nickname", "==", nickname)
105
- );
106
-
107
- const querySnapshot = await getDocs(q);
108
  let finalNickname = nickname;
109
 
110
- if (!querySnapshot.empty) {
111
- // Nickname exists! Add a random suffix to differentiate
112
- // Use a short random string to avoid "Little Ming (2)" collision if many
113
- const suffix = Math.floor(1000 + Math.random() * 9000).toString(); // 4-digit suffix
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  finalNickname = `${nickname}#${suffix}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  }
116
 
117
- // 3. Create new user
118
  const newUserRef = await addDoc(usersRef, {
119
  nickname: finalNickname,
120
  current_room: roomCode,
 
87
  * @param {string} nickname
88
  * @returns {Promise<{userId: string, nickname: string}>} Object containing userId and final nickname
89
  */
90
+ /**
91
+ * Checks for nickname conflicts in a room
92
+ * @param {string} roomCode
93
+ * @param {string} nickname
94
+ * @returns {Promise<Array<{id: string, nickname: string, last_active: any}>>} List of conflicting users
95
+ */
96
+ export async function checkNicknameConflict(roomCode, nickname) {
97
+ const usersRef = collection(db, USERS_COLLECTION);
98
+
99
+ // We want to find users whose nickname STARTS with the input nickname
100
+ // Firestore doesn't have a simple "startsWith" for this specific case combined with other filters easily without an index.
101
+ // However, we can query exact match OR match with suffix pattern if we fetch all users in room.
102
+ // Given class size is small (<100), fetching all room users is efficient enough.
103
+
104
+ // Actually, let's just query for the exact nickname AND nicknames that likely start with it?
105
+ // Firestore querying for "nickname" >= "Name" and "nickname" <= "Name\uf8ff" works.
106
+
107
+ const q = query(
108
+ usersRef,
109
+ where("current_room", "==", roomCode)
110
+ );
111
+
112
+ const snapshot = await getDocs(q);
113
+ const conflicts = [];
114
+
115
+ snapshot.forEach(doc => {
116
+ const data = doc.data();
117
+ const name = data.nickname;
118
+
119
+ // Match exact "Name" or "Name#1234"
120
+ if (name === nickname || (name.startsWith(nickname + "#") && /#\d{4}$/.test(name))) {
121
+ conflicts.push({ id: doc.id, ...data });
122
+ }
123
+ });
124
+
125
+ return conflicts;
126
+ }
127
+
128
+ /**
129
+ * Creates a new user or joins as an existing specific user
130
+ * @param {string} roomCode
131
+ * @param {string} nickname
132
+ * @param {boolean} forceNew - If true, force create new user with suffix even if exact match exists
133
+ * @returns {Promise<{userId: string, nickname: string}>}
134
+ */
135
+ export async function joinRoom(roomCode, nickname, forceNew = false) {
136
  // 1. Verify Room Exists
137
  const roomRef = doc(db, ROOMS_COLLECTION, roomCode);
138
  const roomSnap = await getDoc(roomRef);
 
141
  throw new Error("教室代碼不存在");
142
  }
143
 
 
144
  const usersRef = collection(db, USERS_COLLECTION);
 
 
 
 
 
 
 
145
  let finalNickname = nickname;
146
 
147
+ // Check if direct re-login (e.g. user typed "Name#1234")
148
+ const isspecificAuth = /#\d{4}$/.test(nickname);
149
+
150
+ if (isspecificAuth && !forceNew) {
151
+ // Try to find this specific user
152
+ const q = query(
153
+ usersRef,
154
+ where("current_room", "==", roomCode),
155
+ where("nickname", "==", nickname)
156
+ );
157
+ const snapshot = await getDocs(q);
158
+
159
+ if (!snapshot.empty) {
160
+ const userDoc = snapshot.docs[0];
161
+ await updateDoc(userDoc.ref, { last_active: serverTimestamp() });
162
+ return { userId: userDoc.id, nickname: nickname };
163
+ }
164
+ }
165
+
166
+ // Logic for generic name or forced new
167
+ if (forceNew) {
168
+ // Generate suffix
169
+ const suffix = Math.floor(1000 + Math.random() * 9000).toString();
170
  finalNickname = `${nickname}#${suffix}`;
171
+ } else {
172
+ // Check if exact match exists
173
+ const q = query(
174
+ usersRef,
175
+ where("current_room", "==", roomCode),
176
+ where("nickname", "==", nickname)
177
+ );
178
+ const snapshot = await getDocs(q);
179
+
180
+ if (!snapshot.empty) {
181
+ // Collision on generic name -> Auto suffix
182
+ const suffix = Math.floor(1000 + Math.random() * 9000).toString();
183
+ finalNickname = `${nickname}#${suffix}`;
184
+ }
185
  }
186
 
187
+ // Create new user
188
  const newUserRef = await addDoc(usersRef, {
189
  nickname: finalNickname,
190
  current_room: roomCode,
src/views/LandingView.js CHANGED
@@ -1,6 +1,7 @@
1
- import { createRoom, joinRoom } from "../services/classroom.js";
2
  import { generateMonsterSVG, MONSTER_DEFS } from "../utils/monsterUtils.js";
3
 
 
4
  export function renderLandingView() {
5
  // Select Decor Monsters
6
  // Left: Genesis Dragon (L3_AAA), Right: Gundam (L3_BAA) - or fallbacks
@@ -55,6 +56,9 @@ export function renderLandingView() {
55
  </div>
56
  </div>
57
 
 
 
 
58
  <style>
59
  @keyframes float {
60
  0%, 100% { transform: translateY(0); }
@@ -69,6 +73,23 @@ export function setupLandingEvents(navigateTo) {
69
  const joinBtn = document.getElementById('join-btn');
70
  const instructorBtn = document.getElementById('instructor-mode-btn');
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  joinBtn.addEventListener('click', async () => {
73
  const roomCode = document.getElementById('room-code-input').value.trim();
74
  const nickname = document.getElementById('nickname-input').value.trim();
@@ -78,20 +99,27 @@ export function setupLandingEvents(navigateTo) {
78
  return;
79
  }
80
 
 
 
 
81
  try {
82
- joinBtn.textContent = '加入中...';
83
- joinBtn.disabled = true;
84
 
85
- const { userId, nickname: finalNickname } = await joinRoom(roomCode, nickname);
 
 
 
 
 
 
86
 
87
- // Save Session
88
- localStorage.setItem('vibecoding_user_id', userId);
89
- localStorage.setItem('vibecoding_room_code', roomCode);
90
- localStorage.setItem('vibecoding_nickname', finalNickname);
91
 
92
- navigateTo('student');
93
- } catch (error) {
94
- alert('加入失敗: ' + error.message);
95
  joinBtn.textContent = '進入教室';
96
  joinBtn.disabled = false;
97
  }
@@ -103,3 +131,64 @@ export function setupLandingEvents(navigateTo) {
103
  navigateTo('instructor');
104
  });
105
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createRoom, joinRoom, checkNicknameConflict } from "../services/classroom.js";
2
  import { generateMonsterSVG, MONSTER_DEFS } from "../utils/monsterUtils.js";
3
 
4
+ // ... (renderLandingView remains same) ...
5
  export function renderLandingView() {
6
  // Select Decor Monsters
7
  // Left: Genesis Dragon (L3_AAA), Right: Gundam (L3_BAA) - or fallbacks
 
56
  </div>
57
  </div>
58
 
59
+ <!-- Conflict Modal Container -->
60
+ <div id="conflict-modal-container"></div>
61
+
62
  <style>
63
  @keyframes float {
64
  0%, 100% { transform: translateY(0); }
 
73
  const joinBtn = document.getElementById('join-btn');
74
  const instructorBtn = document.getElementById('instructor-mode-btn');
75
 
76
+ const handleJoin = async (roomCode, nickname, forceNew = false) => {
77
+ try {
78
+ const { userId, nickname: finalNickname } = await joinRoom(roomCode, nickname, forceNew);
79
+
80
+ // Save Session
81
+ localStorage.setItem('vibecoding_user_id', userId);
82
+ localStorage.setItem('vibecoding_room_code', roomCode);
83
+ localStorage.setItem('vibecoding_nickname', finalNickname);
84
+
85
+ navigateTo('student');
86
+ return true;
87
+ } catch (error) {
88
+ alert('加入失敗: ' + error.message);
89
+ return false;
90
+ }
91
+ };
92
+
93
  joinBtn.addEventListener('click', async () => {
94
  const roomCode = document.getElementById('room-code-input').value.trim();
95
  const nickname = document.getElementById('nickname-input').value.trim();
 
99
  return;
100
  }
101
 
102
+ joinBtn.textContent = '檢查中...';
103
+ joinBtn.disabled = true;
104
+
105
  try {
106
+ // Check conflicts first
107
+ const conflicts = await checkNicknameConflict(roomCode, nickname);
108
 
109
+ if (conflicts.length > 0) {
110
+ // Show Conflict Modal
111
+ showConflictModal(conflicts, nickname, roomCode, navigateTo, handleJoin);
112
+ joinBtn.textContent = '進入教室';
113
+ joinBtn.disabled = false;
114
+ return;
115
+ }
116
 
117
+ // No conflict -> direct join
118
+ await handleJoin(roomCode, nickname);
 
 
119
 
120
+ } catch (e) {
121
+ console.error(e);
122
+ alert("檢查失敗: " + e.message);
123
  joinBtn.textContent = '進入教室';
124
  joinBtn.disabled = false;
125
  }
 
131
  navigateTo('instructor');
132
  });
133
  }
134
+
135
+ function showConflictModal(conflicts, originalNickname, roomCode, navigateTo, handleJoin) {
136
+ const container = document.getElementById('conflict-modal-container');
137
+
138
+ const userListHTML = conflicts.map(u => `
139
+ <button onclick="window.selectUser('${u.nickname}')"
140
+ class="w-full text-left bg-gray-700 hover:bg-gray-600 p-4 rounded-xl flex justify-between items-center group transition-all border border-gray-600 hover:border-cyan-500">
141
+ <span class="font-bold text-white group-hover:text-cyan-300 transition-colors">${u.nickname}</span>
142
+ <span class="text-xs text-gray-400 bg-gray-800 px-2 py-1 rounded">舊學員</span>
143
+ </button>
144
+ `).join('');
145
+
146
+ container.innerHTML = `
147
+ <div class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
148
+ <div class="bg-gray-800 w-full max-w-md rounded-2xl border border-gray-700 shadow-2xl overflow-hidden animate-[fadeIn_0.3s_ease-out]">
149
+ <div class="p-6 border-b border-gray-700 bg-gray-900/50">
150
+ <h3 class="text-xl font-bold text-white mb-1">發現相同暱稱</h3>
151
+ <p class="text-gray-400 text-sm">教室裡已經有叫「${originalNickname}」的同學了,請問您是?</p>
152
+ </div>
153
+
154
+ <div class="p-6 space-y-3 max-h-[60vh] overflow-y-auto">
155
+ ${userListHTML}
156
+
157
+ <div class="relative flex py-2 items-center">
158
+ <div class="flex-grow border-t border-gray-700"></div>
159
+ <span class="flex-shrink-0 mx-4 text-gray-500 text-xs">或者</span>
160
+ <div class="flex-grow border-t border-gray-700"></div>
161
+ </div>
162
+
163
+ <button onclick="window.createNewUser()"
164
+ class="w-full bg-gradient-to-r from-green-600 to-teal-600 hover:from-green-500 hover:to-teal-500 text-white font-bold p-4 rounded-xl shadow-lg shadow-green-900/40 transform transition hover:scale-[1.02] flex items-center justify-center space-x-2">
165
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
166
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z" clip-rule="evenodd" />
167
+ </svg>
168
+ <span>我是新同學,建立新分身</span>
169
+ </button>
170
+ </div>
171
+
172
+ <div class="p-4 bg-gray-900/50 border-t border-gray-700 flex justify-end">
173
+ <button onclick="document.getElementById('conflict-modal-container').innerHTML=''"
174
+ class="text-gray-400 hover:text-white text-sm px-4 py-2">
175
+ 取消
176
+ </button>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ `;
181
+
182
+ // Bind temporary window functions for the modal buttons
183
+ window.selectUser = async (targetNickname) => {
184
+ // Log in as existing
185
+ document.getElementById('conflict-modal-container').innerHTML = ''; // Close modal
186
+ await handleJoin(roomCode, targetNickname, false);
187
+ };
188
+
189
+ window.createNewUser = async () => {
190
+ // Create new
191
+ document.getElementById('conflict-modal-container').innerHTML = ''; // Close modal
192
+ await handleJoin(roomCode, originalNickname, true); // Force new
193
+ };
194
+ }