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

Upload 9 files

Browse files
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>} The student ID (userId)
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 if user already exists in this room (Cross-device sync)
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
- // User exists, return existing ID
111
- const userDoc = querySnapshot.docs[0];
112
- // Update last active
113
- await updateDoc(userDoc.ref, { last_active: serverTimestamp() });
114
- return userDoc.id;
115
  }
116
 
117
- // 3. Create new user if not exists
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
- // Always go back to instructor view since admin is only accessible from there
91
- window.location.hash = 'instructor';
 
 
 
 
 
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 studentId = await joinRoom(roomCode, nickname);
86
 
87
  // Save Session
88
- localStorage.setItem('vibecoding_user_id', studentId);
89
  localStorage.setItem('vibecoding_room_code', roomCode);
90
- localStorage.setItem('vibecoding_nickname', 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
- <!-- AI Tutor / Error Area -->
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 mt-2">
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 (Actually newProgressMap is complete source of truth from firestore listener)
330
- const updatedProgress = newProgressMap;
331
 
332
- // 1. Update Monster (Visuals)
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
- // Check level up (totalCompleted) to update scale/animation if needed
346
- const oldTotal = parseInt(fixedContainer.querySelector('.bg-gray-900\\/90')?.textContent?.replace('Lv.', '') || '1') - 1;
347
- if (oldTotal !== newState.totalCompleted) {
348
- monsterContainer.innerHTML = renderMonsterSection(updatedProgress, classSize, userProfile);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  }
350
  } else {
351
- // Full Monster Render
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