Spaces:
Running
Running
Upload 9 files
Browse files- src/services/auth.js +15 -1
- src/services/classroom.js +10 -18
- src/views/AdminView.js +7 -2
- src/views/InstructorView.js +0 -0
- src/views/LandingView.js +3 -3
- src/views/StudentView.js +29 -54
src/services/auth.js
CHANGED
|
@@ -3,7 +3,8 @@ import {
|
|
| 3 |
signInWithEmailAndPassword,
|
| 4 |
createUserWithEmailAndPassword,
|
| 5 |
signOut,
|
| 6 |
-
sendPasswordResetEmail
|
|
|
|
| 7 |
} from "https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js";
|
| 8 |
import {
|
| 9 |
doc,
|
|
@@ -20,6 +21,19 @@ import {
|
|
| 20 |
const INSTRUCTORS_COLLECTION = "instructors";
|
| 21 |
const SUPER_ADMIN_EMAIL = "t92206@gmail.com";
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
/**
|
| 24 |
* Sign in with Email/Password
|
| 25 |
*/
|
|
|
|
| 3 |
signInWithEmailAndPassword,
|
| 4 |
createUserWithEmailAndPassword,
|
| 5 |
signOut,
|
| 6 |
+
sendPasswordResetEmail,
|
| 7 |
+
getRedirectResult
|
| 8 |
} from "https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js";
|
| 9 |
import {
|
| 10 |
doc,
|
|
|
|
| 21 |
const INSTRUCTORS_COLLECTION = "instructors";
|
| 22 |
const SUPER_ADMIN_EMAIL = "t92206@gmail.com";
|
| 23 |
|
| 24 |
+
/**
|
| 25 |
+
* Handle Redirect Result (for OAuth flows like Google Sign In)
|
| 26 |
+
*/
|
| 27 |
+
export async function handleRedirectResult() {
|
| 28 |
+
try {
|
| 29 |
+
const result = await getRedirectResult(auth);
|
| 30 |
+
return result ? result.user : null;
|
| 31 |
+
} catch (error) {
|
| 32 |
+
console.error("Redirect Result Error:", error);
|
| 33 |
+
throw error;
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
/**
|
| 38 |
* Sign in with Email/Password
|
| 39 |
*/
|
src/services/classroom.js
CHANGED
|
@@ -85,7 +85,7 @@ export async function verifyInstructorPassword(inputPassword) {
|
|
| 85 |
* Joins a room with Dual-Role Auth / Session Persistence logic
|
| 86 |
* @param {string} roomCode
|
| 87 |
* @param {string} nickname
|
| 88 |
-
* @returns {Promise<string>}
|
| 89 |
*/
|
| 90 |
export async function joinRoom(roomCode, nickname) {
|
| 91 |
// 1. Verify Room Exists
|
|
@@ -96,7 +96,7 @@ export async function joinRoom(roomCode, nickname) {
|
|
| 96 |
throw new Error("教室代碼不存在");
|
| 97 |
}
|
| 98 |
|
| 99 |
-
// 2. Check
|
| 100 |
const usersRef = collection(db, USERS_COLLECTION);
|
| 101 |
const q = query(
|
| 102 |
usersRef,
|
|
@@ -105,25 +105,25 @@ export async function joinRoom(roomCode, nickname) {
|
|
| 105 |
);
|
| 106 |
|
| 107 |
const querySnapshot = await getDocs(q);
|
|
|
|
| 108 |
|
| 109 |
if (!querySnapshot.empty) {
|
| 110 |
-
//
|
| 111 |
-
|
| 112 |
-
//
|
| 113 |
-
|
| 114 |
-
return userDoc.id;
|
| 115 |
}
|
| 116 |
|
| 117 |
-
// 3. Create new user
|
| 118 |
const newUserRef = await addDoc(usersRef, {
|
| 119 |
-
nickname,
|
| 120 |
current_room: roomCode,
|
| 121 |
role: 'student',
|
| 122 |
joinedAt: serverTimestamp(),
|
| 123 |
last_active: serverTimestamp()
|
| 124 |
});
|
| 125 |
|
| 126 |
-
return newUserRef.id;
|
| 127 |
}
|
| 128 |
|
| 129 |
/**
|
|
@@ -342,7 +342,6 @@ export async function getPeerPrompts(roomCode, challengeId) {
|
|
| 342 |
* @param {string} challengeId
|
| 343 |
*/
|
| 344 |
export async function resetProgress(userId, roomCode, challengeId) {
|
| 345 |
-
console.log(`[resetProgress] Attempting reset for User: ${userId}, Challenge: ${challengeId}, Room: ${roomCode}`);
|
| 346 |
const progressRef = collection(db, PROGRESS_COLLECTION);
|
| 347 |
const q = query(
|
| 348 |
progressRef,
|
|
@@ -351,19 +350,12 @@ export async function resetProgress(userId, roomCode, challengeId) {
|
|
| 351 |
);
|
| 352 |
const snapshot = await getDocs(q);
|
| 353 |
|
| 354 |
-
console.log(`[resetProgress] Found ${snapshot.size} documents.`);
|
| 355 |
-
|
| 356 |
if (!snapshot.empty) {
|
| 357 |
// Reset status to 'started' so they can submit again
|
| 358 |
await updateDoc(snapshot.docs[0].ref, {
|
| 359 |
status: 'started',
|
| 360 |
timestamp: serverTimestamp()
|
| 361 |
});
|
| 362 |
-
console.log(`[resetProgress] Document updated successfully.`);
|
| 363 |
-
return true;
|
| 364 |
-
} else {
|
| 365 |
-
console.warn(`[resetProgress] No progress document found to reset!`);
|
| 366 |
-
return false;
|
| 367 |
}
|
| 368 |
}
|
| 369 |
|
|
|
|
| 85 |
* Joins a room with Dual-Role Auth / Session Persistence logic
|
| 86 |
* @param {string} roomCode
|
| 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
|
|
|
|
| 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,
|
|
|
|
| 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,
|
| 121 |
role: 'student',
|
| 122 |
joinedAt: serverTimestamp(),
|
| 123 |
last_active: serverTimestamp()
|
| 124 |
});
|
| 125 |
|
| 126 |
+
return { userId: newUserRef.id, nickname: finalNickname };
|
| 127 |
}
|
| 128 |
|
| 129 |
/**
|
|
|
|
| 342 |
* @param {string} challengeId
|
| 343 |
*/
|
| 344 |
export async function resetProgress(userId, roomCode, challengeId) {
|
|
|
|
| 345 |
const progressRef = collection(db, PROGRESS_COLLECTION);
|
| 346 |
const q = query(
|
| 347 |
progressRef,
|
|
|
|
| 350 |
);
|
| 351 |
const snapshot = await getDocs(q);
|
| 352 |
|
|
|
|
|
|
|
| 353 |
if (!snapshot.empty) {
|
| 354 |
// Reset status to 'started' so they can submit again
|
| 355 |
await updateDoc(snapshot.docs[0].ref, {
|
| 356 |
status: 'started',
|
| 357 |
timestamp: serverTimestamp()
|
| 358 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
}
|
| 360 |
}
|
| 361 |
|
src/views/AdminView.js
CHANGED
|
@@ -87,8 +87,13 @@ export function setupAdminEvents() {
|
|
| 87 |
loadChallenges();
|
| 88 |
|
| 89 |
document.getElementById('back-instructor-btn').addEventListener('click', () => {
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
});
|
| 93 |
|
| 94 |
document.getElementById('add-challenge-btn').addEventListener('click', () => {
|
|
|
|
| 87 |
loadChallenges();
|
| 88 |
|
| 89 |
document.getElementById('back-instructor-btn').addEventListener('click', () => {
|
| 90 |
+
const referer = localStorage.getItem('vibecoding_admin_referer');
|
| 91 |
+
if (referer === 'instructor') {
|
| 92 |
+
window.location.hash = 'instructor';
|
| 93 |
+
} else {
|
| 94 |
+
window.location.hash = ''; // Main landing
|
| 95 |
+
}
|
| 96 |
+
localStorage.removeItem('vibecoding_admin_referer');
|
| 97 |
});
|
| 98 |
|
| 99 |
document.getElementById('add-challenge-btn').addEventListener('click', () => {
|
src/views/InstructorView.js
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/views/LandingView.js
CHANGED
|
@@ -82,12 +82,12 @@ export function setupLandingEvents(navigateTo) {
|
|
| 82 |
joinBtn.textContent = '加入中...';
|
| 83 |
joinBtn.disabled = true;
|
| 84 |
|
| 85 |
-
const
|
| 86 |
|
| 87 |
// Save Session
|
| 88 |
-
localStorage.setItem('vibecoding_user_id',
|
| 89 |
localStorage.setItem('vibecoding_room_code', roomCode);
|
| 90 |
-
localStorage.setItem('vibecoding_nickname',
|
| 91 |
|
| 92 |
navigateTo('student');
|
| 93 |
} catch (error) {
|
|
|
|
| 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) {
|
src/views/StudentView.js
CHANGED
|
@@ -1,12 +1,9 @@
|
|
| 1 |
import { submitPrompt, getChallenges, startChallenge, getUserProgress, getPeerPrompts, resetProgress, toggleLike, subscribeToNotifications, markNotificationRead, getClassSize, updateUserMonster, getUser, subscribeToUserProgress } from "../services/classroom.js";
|
| 2 |
-
import { db } from "../services/firebase.js";
|
| 3 |
import { generateMonsterSVG, getNextMonster, MONSTER_STAGES, MONSTER_DEFS } from "../utils/monsterUtils.js";
|
| 4 |
-
import { doc, getDoc } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js";
|
| 5 |
|
| 6 |
|
| 7 |
// Cache challenges locally
|
| 8 |
let cachedChallenges = [];
|
| 9 |
-
let roomAiSettings = { active: false, key: null };
|
| 10 |
|
| 11 |
function renderTaskCard(c, userProgress) {
|
| 12 |
const p = userProgress[c.id] || {};
|
|
@@ -82,30 +79,10 @@ function renderTaskCard(c, userProgress) {
|
|
| 82 |
class="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:border-cyan-500 focus:outline-none transition-colors text-sm"
|
| 83 |
placeholder="貼上您的修復提示詞..."></textarea>
|
| 84 |
|
| 85 |
-
<
|
| 86 |
-
<div class="flex justify-between items-start">
|
| 87 |
-
<div id="error-${c.id}" class="text-red-500 text-xs hidden mt-1">提示詞太短囉...</div>
|
| 88 |
-
|
| 89 |
-
${roomAiSettings.active ? `
|
| 90 |
-
<button onclick="window.callAiTutor('${c.id}')" id="btn-tutor-${c.id}"
|
| 91 |
-
class="text-xs bg-gray-800 hover:bg-gray-700 text-cyan-400 border border-gray-600 px-3 py-1 rounded-full flex items-center space-x-1 transition-colors self-start mt-1">
|
| 92 |
-
<span>🐥 呼叫 AI 橡皮鴨</span>
|
| 93 |
-
</button>
|
| 94 |
-
` : ''}
|
| 95 |
-
</div>
|
| 96 |
-
|
| 97 |
-
<!-- AI Response Bubble -->
|
| 98 |
-
<div id="ai-response-${c.id}" class="hidden mt-2 mb-2 relative group">
|
| 99 |
-
<div class="absolute -top-1 left-4 w-2 h-2 bg-gray-700 transform rotate-45"></div>
|
| 100 |
-
<div class="bg-gray-800 border border-cyan-500/30 rounded-lg p-3 text-cyan-300 text-sm font-mono shadow-lg flex items-start space-x-2">
|
| 101 |
-
<span class="text-lg">🤖</span>
|
| 102 |
-
<div class="flex-1 typing-effect" id="ai-text-${c.id}">...</div>
|
| 103 |
-
<button onclick="document.getElementById('ai-response-${c.id}').classList.add('hidden')" class="text-gray-500 hover:text-white">×</button>
|
| 104 |
-
</div>
|
| 105 |
-
</div>
|
| 106 |
|
| 107 |
<button onclick="window.submitLevel('${c.id}')"
|
| 108 |
-
class="w-full bg-gradient-to-r from-cyan-600 to-blue-600 hover:from-cyan-500 hover:to-blue-500 text-white px-6 py-2 rounded-lg font-bold transition-transform active:scale-95 shadow-lg shadow-cyan-900/50
|
| 109 |
提交解答
|
| 110 |
</button>
|
| 111 |
</div>
|
|
@@ -326,14 +303,19 @@ export async function renderStudentView() {
|
|
| 326 |
// Setup Real-time Subscription
|
| 327 |
if (window.currentProgressUnsub) window.currentProgressUnsub();
|
| 328 |
window.currentProgressUnsub = subscribeToUserProgress(userId, (newProgressMap) => {
|
| 329 |
-
// Merge updates
|
| 330 |
-
const updatedProgress = newProgressMap;
|
| 331 |
|
| 332 |
-
//
|
| 333 |
const newState = calculateMonsterState(updatedProgress, classSize, userProfile);
|
| 334 |
const fixedContainer = document.getElementById('monster-container-fixed');
|
| 335 |
const currentMonsterId = fixedContainer?.getAttribute('data-monster-id');
|
| 336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
if (fixedContainer && String(currentMonsterId) === String(newState.monster.id)) {
|
| 338 |
// Monster ID is same (no evolution/devolution).
|
| 339 |
// Just update stats tooltip
|
|
@@ -342,36 +324,29 @@ export async function renderStudentView() {
|
|
| 342 |
statsContainer.innerHTML = renderMonsterStats(newState);
|
| 343 |
}
|
| 344 |
|
| 345 |
-
//
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
}
|
| 350 |
} else {
|
| 351 |
-
//
|
| 352 |
monsterContainer.innerHTML = renderMonsterSection(updatedProgress, classSize, userProfile);
|
| 353 |
}
|
| 354 |
-
|
| 355 |
-
// 2. Update Task List (Accordions) - Realtime "Reject" support
|
| 356 |
-
const levelGroups = {
|
| 357 |
-
beginner: cachedChallenges.filter(c => c.level === 'beginner'),
|
| 358 |
-
intermediate: cachedChallenges.filter(c => c.level === 'intermediate'),
|
| 359 |
-
advanced: cachedChallenges.filter(c => c.level === 'advanced')
|
| 360 |
-
};
|
| 361 |
-
const levelNames = {
|
| 362 |
-
beginner: "初級 (Beginner)",
|
| 363 |
-
intermediate: "中級 (Intermediate)",
|
| 364 |
-
advanced: "高級 (Advanced)"
|
| 365 |
-
};
|
| 366 |
-
|
| 367 |
-
['beginner', 'intermediate', 'advanced'].forEach(level => {
|
| 368 |
-
const detailEl = document.getElementById(`details-group-${level}`);
|
| 369 |
-
if (detailEl) {
|
| 370 |
-
// renderLevelGroup checks existing DOM for 'open' state, so it persists
|
| 371 |
-
const newHTML = renderLevelGroup(level, levelGroups[level], updatedProgress, levelNames);
|
| 372 |
-
detailEl.outerHTML = newHTML;
|
| 373 |
-
}
|
| 374 |
-
});
|
| 375 |
});
|
| 376 |
|
| 377 |
// Accordion Layout
|
|
|
|
| 1 |
import { submitPrompt, getChallenges, startChallenge, getUserProgress, getPeerPrompts, resetProgress, toggleLike, subscribeToNotifications, markNotificationRead, getClassSize, updateUserMonster, getUser, subscribeToUserProgress } from "../services/classroom.js";
|
|
|
|
| 2 |
import { generateMonsterSVG, getNextMonster, MONSTER_STAGES, MONSTER_DEFS } from "../utils/monsterUtils.js";
|
|
|
|
| 3 |
|
| 4 |
|
| 5 |
// Cache challenges locally
|
| 6 |
let cachedChallenges = [];
|
|
|
|
| 7 |
|
| 8 |
function renderTaskCard(c, userProgress) {
|
| 9 |
const p = userProgress[c.id] || {};
|
|
|
|
| 79 |
class="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:border-cyan-500 focus:outline-none transition-colors text-sm"
|
| 80 |
placeholder="貼上您的修復提示詞..."></textarea>
|
| 81 |
|
| 82 |
+
<div id="error-${c.id}" class="text-red-500 text-xs hidden">提示詞太短囉,請多寫一點細節!</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
<button onclick="window.submitLevel('${c.id}')"
|
| 85 |
+
class="w-full bg-gradient-to-r from-cyan-600 to-blue-600 hover:from-cyan-500 hover:to-blue-500 text-white px-6 py-2 rounded-lg font-bold transition-transform active:scale-95 shadow-lg shadow-cyan-900/50">
|
| 86 |
提交解答
|
| 87 |
</button>
|
| 88 |
</div>
|
|
|
|
| 303 |
// Setup Real-time Subscription
|
| 304 |
if (window.currentProgressUnsub) window.currentProgressUnsub();
|
| 305 |
window.currentProgressUnsub = subscribeToUserProgress(userId, (newProgressMap) => {
|
| 306 |
+
// Merge updates
|
| 307 |
+
const updatedProgress = { ...userProgress, ...newProgressMap };
|
| 308 |
|
| 309 |
+
// Smart Update: Check if visual refresh is needed
|
| 310 |
const newState = calculateMonsterState(updatedProgress, classSize, userProfile);
|
| 311 |
const fixedContainer = document.getElementById('monster-container-fixed');
|
| 312 |
const currentMonsterId = fixedContainer?.getAttribute('data-monster-id');
|
| 313 |
|
| 314 |
+
// Tolerance for scale check usually not needed if we want scale to update visuals immediately?
|
| 315 |
+
// Actually, scale change usually means totalCompleted changed.
|
| 316 |
+
// If we want smooth growth, replacing DOM resets animation which looks slight jumpy but acceptable.
|
| 317 |
+
// But the user complained about "position reset" (walk cycle reset).
|
| 318 |
+
|
| 319 |
if (fixedContainer && String(currentMonsterId) === String(newState.monster.id)) {
|
| 320 |
// Monster ID is same (no evolution/devolution).
|
| 321 |
// Just update stats tooltip
|
|
|
|
| 324 |
statsContainer.innerHTML = renderMonsterStats(newState);
|
| 325 |
}
|
| 326 |
|
| 327 |
+
// What if level up (scale change)?
|
| 328 |
+
// If we don't replace DOM, scale won't update in style attribute.
|
| 329 |
+
// We should update the style manually.
|
| 330 |
+
const artContainer = fixedContainer.querySelector('.pixel-art-container');
|
| 331 |
+
if (artContainer && newState.currentScale) {
|
| 332 |
+
// Update animation with new scale
|
| 333 |
+
// Note: Modifying 'transform' directly might conflict with keyframes unless keyframes use relative or we update style variable.
|
| 334 |
+
// Keyframes use: scale(${currentScale}). This is hardcoded in specific keyframes string in <style>.
|
| 335 |
+
// We can't easily update keyframes dynamic values without replacing style block.
|
| 336 |
+
|
| 337 |
+
// If totalCompleted changed (level up), user *might* accept a reset because they levelled up.
|
| 338 |
+
// But simply giving a heart shouldn't reset.
|
| 339 |
+
|
| 340 |
+
const oldTotal = parseInt(fixedContainer.querySelector('.bg-gray-900\\/90')?.textContent?.replace('Lv.', '') || '1') - 1;
|
| 341 |
+
if (oldTotal !== newState.totalCompleted) {
|
| 342 |
+
// Level changed -> Scale changed -> Re-render full (reset animation is fine for Level Up)
|
| 343 |
+
monsterContainer.innerHTML = renderMonsterSection(updatedProgress, classSize, userProfile);
|
| 344 |
+
}
|
| 345 |
}
|
| 346 |
} else {
|
| 347 |
+
// Monster changed or clean slate -> Full Render
|
| 348 |
monsterContainer.innerHTML = renderMonsterSection(updatedProgress, classSize, userProfile);
|
| 349 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
});
|
| 351 |
|
| 352 |
// Accordion Layout
|