Spaces:
Running
Running
Upload 9 files
Browse files- src/services/classroom.js +84 -14
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
finalNickname = `${nickname}#${suffix}`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
}
|
| 116 |
|
| 117 |
-
//
|
| 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 |
-
|
| 83 |
-
|
| 84 |
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
-
//
|
| 88 |
-
|
| 89 |
-
localStorage.setItem('vibecoding_room_code', roomCode);
|
| 90 |
-
localStorage.setItem('vibecoding_nickname', finalNickname);
|
| 91 |
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
alert(
|
| 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 |
+
}
|