diff --git "a/src/views/InstructorView.js" "b/src/views/InstructorView.js" --- "a/src/views/InstructorView.js" +++ "b/src/views/InstructorView.js" @@ -696,811 +696,809 @@ export function setupInstructorEvents() { alert('退回失敗: ' + e.message); } } - } - - - - let roomUnsubscribe = null; - let currentInstructor = null; - - // UI References - const authModal = document.getElementById('auth-modal'); - // New Auth Elements - const loginEmailInput = document.getElementById('login-email'); - const loginPasswordInput = document.getElementById('login-password'); - const loginBtn = document.getElementById('login-btn'); - const registerBtn = document.getElementById('register-btn'); - const authErrorMsg = document.getElementById('auth-error'); - - // Remove old authBtn reference if present - // const authBtn = document.getElementById('auth-btn'); - - const navAdminBtn = document.getElementById('nav-admin-btn'); - const navInstBtn = document.getElementById('nav-instructors-btn'); - const createBtn = document.getElementById('create-room-btn'); - - // Other UI - const roomInfo = document.getElementById('room-info'); - const createContainer = document.getElementById('create-room-container'); - const dashboardContent = document.getElementById('dashboard-content'); - const displayRoomCode = document.getElementById('display-room-code'); - const groupPhotoBtn = document.getElementById('group-photo-btn'); - const snapshotBtn = document.getElementById('snapshot-btn'); - let isSnapshotting = false; - - // AI Settings UI - const setupAiBtn = document.getElementById('setup-ai-btn'); - const aiSettingsModal = document.getElementById('ai-settings-modal'); - const geminiKeyInput = document.getElementById('gemini-api-key'); - const toggleStudentAi = document.getElementById('toggle-student-ai'); - const saveAiSettingsBtn = document.getElementById('save-ai-settings'); - - // Permission Check Helper - const checkPermissions = (instructor) => { - if (!instructor) return; - - currentInstructor = instructor; - - // 1. Create Room Permission - if (instructor.permissions?.includes('create_room')) { - createBtn.classList.remove('hidden', 'opacity-50', 'cursor-not-allowed'); - createBtn.disabled = false; - } else { - createBtn.classList.add('opacity-50', 'cursor-not-allowed'); - createBtn.disabled = true; - createBtn.title = "無此權限"; - } - - // 2. Add Question Permission (Admin Button) - if (instructor.permissions?.includes('add_question')) { - navAdminBtn.classList.remove('hidden'); - } else { - navAdminBtn.classList.add('hidden'); - } - - // 3. Manage Instructors Permission - if (instructor.permissions?.includes('manage_instructors')) { - navInstBtn.classList.remove('hidden'); - } else { - navInstBtn.classList.add('hidden'); - } - }; - // Auto-Check Auth on Load (Restores session from Firebase) - (async () => { - try { - const { onAuthStateChanged } = await import("https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js"); - const { auth } = await import("../services/firebase.js"); - - onAuthStateChanged(auth, async (user) => { - if (user) { - // User is known to Firebase, check if they are an instructor - const instructorData = await checkInstructorPermission(user); - if (instructorData) { - authModal.classList.add('hidden'); - checkPermissions(instructorData); - localStorage.setItem('vibecoding_instructor_name', instructorData.name); - - // Auto-reconnect room if exists - const savedRoom = localStorage.getItem('vibecoding_room_code'); - if (savedRoom) { - // If we already have a room code, we could auto-setup dashboard state here if needed - // For now, at least user is logged in - const displayRoomCode = document.getElementById('display-room-code'); - if (displayRoomCode) displayRoomCode.textContent = savedRoom; - } - } - } - }); - } catch (e) { - console.error("Auto-Auth Check Failed:", e); - } - })(); - - // --- Global Dashboard Buttons (Always Active) --- - const logoutBtn = document.getElementById('logout-btn'); - if (logoutBtn) { - logoutBtn.addEventListener('click', async () => { - if (confirm("確定要登出嗎?")) { - try { - await signOutUser(); - // Optional: clear local storage if strictly needed, but auth state change handles UI - // localStorage.removeItem('vibecoding_instructor_name'); - window.location.reload(); - } catch (e) { - console.error("Logout failed:", e); - alert("登出失敗"); - } - } - }); } - - const btnOpenGallery = document.getElementById('btn-open-gallery'); - if (btnOpenGallery) { - btnOpenGallery.addEventListener('click', () => { - window.open('monster_preview.html', '_blank'); - }); +} let roomUnsubscribe = null; +let currentInstructor = null; + +// UI References +const authModal = document.getElementById('auth-modal'); +// New Auth Elements +const loginEmailInput = document.getElementById('login-email'); +const loginPasswordInput = document.getElementById('login-password'); +const loginBtn = document.getElementById('login-btn'); +const registerBtn = document.getElementById('register-btn'); +const authErrorMsg = document.getElementById('auth-error'); + +// Remove old authBtn reference if present +// const authBtn = document.getElementById('auth-btn'); + +const navAdminBtn = document.getElementById('nav-admin-btn'); +const navInstBtn = document.getElementById('nav-instructors-btn'); +const createBtn = document.getElementById('create-room-btn'); + +// Other UI +const roomInfo = document.getElementById('room-info'); +const createContainer = document.getElementById('create-room-container'); +const dashboardContent = document.getElementById('dashboard-content'); +const displayRoomCode = document.getElementById('display-room-code'); +const groupPhotoBtn = document.getElementById('group-photo-btn'); +const snapshotBtn = document.getElementById('snapshot-btn'); +let isSnapshotting = false; + +// AI Settings UI +const setupAiBtn = document.getElementById('setup-ai-btn'); +const aiSettingsModal = document.getElementById('ai-settings-modal'); +const geminiKeyInput = document.getElementById('gemini-api-key'); +const toggleStudentAi = document.getElementById('toggle-student-ai'); +const saveAiSettingsBtn = document.getElementById('save-ai-settings'); + +// Permission Check Helper +const checkPermissions = (instructor) => { + if (!instructor) return; + + currentInstructor = instructor; + + // 1. Create Room Permission + if (instructor.permissions?.includes('create_room')) { + createBtn.classList.remove('hidden', 'opacity-50', 'cursor-not-allowed'); + createBtn.disabled = false; + } else { + createBtn.classList.add('opacity-50', 'cursor-not-allowed'); + createBtn.disabled = true; + createBtn.title = "無此權限"; } - if (navInstBtn) { - navInstBtn.addEventListener('click', async () => { - const currentUser = auth.currentUser; - const instData = await checkInstructorPermission(currentUser); - if (instData?.permissions?.includes('manage_instructors')) { - document.getElementById('instructor-modal').classList.remove('hidden'); - loadInstructorList(); - } else { - alert("無此權限"); - } - }); + // 2. Add Question Permission (Admin Button) + if (instructor.permissions?.includes('add_question')) { + navAdminBtn.classList.remove('hidden'); + } else { + navAdminBtn.classList.add('hidden'); } - if (navAdminBtn) { - navAdminBtn.addEventListener('click', () => { - if (confirm("即將離開儀表板前往題目管理頁面,確定嗎?")) { - window.location.hash = 'admin'; - } - }); + // 3. Manage Instructors Permission + if (instructor.permissions?.includes('manage_instructors')) { + navInstBtn.classList.remove('hidden'); + } else { + navInstBtn.classList.add('hidden'); } +}; - // Email/Password Auth Logic - if (loginBtn && registerBtn) { - // Login Handler - loginBtn.addEventListener('click', async () => { - const email = loginEmailInput.value; - const password = loginPasswordInput.value; - - if (!email || !password) { - authErrorMsg.textContent = "請輸入 Email 和密碼"; - authErrorMsg.classList.remove('hidden'); - return; - } - - try { - loginBtn.disabled = true; - loginBtn.classList.add('opacity-50'); - authErrorMsg.classList.add('hidden'); +// Auto-Check Auth on Load (Restores session from Firebase) +(async () => { + try { + const { onAuthStateChanged } = await import("https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js"); + const { auth } = await import("../services/firebase.js"); - const user = await loginWithEmail(email, password); + onAuthStateChanged(auth, async (user) => { + if (user) { + // User is known to Firebase, check if they are an instructor const instructorData = await checkInstructorPermission(user); - if (instructorData) { authModal.classList.add('hidden'); checkPermissions(instructorData); localStorage.setItem('vibecoding_instructor_name', instructorData.name); - } else { - authErrorMsg.textContent = "未授權的帳號 (Unauthorized Account)"; - authErrorMsg.classList.remove('hidden'); - await signOutUser(); - } - } catch (error) { - console.error(error); - let msg = error.code || error.message; - if (error.code === 'auth/invalid-credential' || error.code === 'auth/wrong-password' || error.code === 'auth/user-not-found') { - msg = "帳號或密碼錯誤。"; + + // Auto-reconnect room if exists + const savedRoom = localStorage.getItem('vibecoding_room_code'); + if (savedRoom) { + // If we already have a room code, we could auto-setup dashboard state here if needed + // For now, at least user is logged in + const displayRoomCode = document.getElementById('display-room-code'); + if (displayRoomCode) displayRoomCode.textContent = savedRoom; + } } - authErrorMsg.textContent = "登入失敗: " + msg; - authErrorMsg.classList.remove('hidden'); - } finally { - loginBtn.disabled = false; - loginBtn.classList.remove('opacity-50'); } }); + } catch (e) { + console.error("Auto-Auth Check Failed:", e); + } +})(); - // Forgot Password Handler - const forgotBtn = document.createElement('button'); - forgotBtn.textContent = "忘記密碼?"; - forgotBtn.className = "text-sm text-gray-400 hover:text-white mt-2 underline block mx-auto"; // Centered link - - // Insert after auth-error message or append to modal content? - // Appending to the parent of Login Button seems best, or just below it. - // The modal structure in index.html is needed to know exact placement. - // Assuming loginBtn is inside a flex column form. - loginBtn.parentNode.insertBefore(forgotBtn, loginBtn.nextSibling); - - forgotBtn.addEventListener('click', async () => { - const email = loginEmailInput.value; - if (!email) { - authErrorMsg.textContent = "請先在上欄輸入 Email 以發送重設信"; - authErrorMsg.classList.remove('hidden'); - return; - } - if (!confirm(`確定要發送重設密碼信件至 ${email} 嗎?`)) return; - +// --- Global Dashboard Buttons (Always Active) --- +const logoutBtn = document.getElementById('logout-btn'); +if (logoutBtn) { + logoutBtn.addEventListener('click', async () => { + if (confirm("確定要登出嗎?")) { try { - // Dynamically import to avoid top-level dependency if not needed - const { resetPassword } = await import("../services/auth.js"); - await resetPassword(email); - alert(`已發送重設密碼信件至 ${email},請查收信箱並依照指示重設密碼。`); - authErrorMsg.classList.add('hidden'); + await signOutUser(); + // Optional: clear local storage if strictly needed, but auth state change handles UI + // localStorage.removeItem('vibecoding_instructor_name'); + window.location.reload(); } catch (e) { - console.error(e); - let msg = e.message; - if (e.code === 'auth/user-not-found') msg = "找不到此帳號,請確認 Email 是否正確。"; - authErrorMsg.textContent = "發送失敗: " + msg; - authErrorMsg.classList.remove('hidden'); + console.error("Logout failed:", e); + alert("登出失敗"); } - }); + } + }); +} - // Register Handler - registerBtn.addEventListener('click', async () => { - const email = loginEmailInput.value; - const password = loginPasswordInput.value; +const btnOpenGallery = document.getElementById('btn-open-gallery'); +if (btnOpenGallery) { + btnOpenGallery.addEventListener('click', () => { + window.open('monster_preview.html', '_blank'); + }); +} - if (!email || !password) { - authErrorMsg.textContent = "請輸入 Email 和密碼"; - authErrorMsg.classList.remove('hidden'); - return; - } +if (navInstBtn) { + navInstBtn.addEventListener('click', async () => { + const currentUser = auth.currentUser; + const instData = await checkInstructorPermission(currentUser); + if (instData?.permissions?.includes('manage_instructors')) { + document.getElementById('instructor-modal').classList.remove('hidden'); + loadInstructorList(); + } else { + alert("無此權限"); + } + }); +} - try { - registerBtn.disabled = true; - registerBtn.classList.add('opacity-50'); - authErrorMsg.classList.add('hidden'); +if (navAdminBtn) { + navAdminBtn.addEventListener('click', () => { + if (confirm("即將離開儀表板前往題目管理頁面,確定嗎?")) { + window.location.hash = 'admin'; + } + }); +} - // Try to create auth account - const user = await registerWithEmail(email, password); - // Check if this email is in our whitelist - const instructorData = await checkInstructorPermission(user); +// Email/Password Auth Logic +if (loginBtn && registerBtn) { + // Login Handler + loginBtn.addEventListener('click', async () => { + const email = loginEmailInput.value; + const password = loginPasswordInput.value; - if (instructorData) { - authModal.classList.add('hidden'); - checkPermissions(instructorData); - localStorage.setItem('vibecoding_instructor_name', instructorData.name); - alert("註冊成功!"); - } else { - // Auth created but not in whitelist - authErrorMsg.textContent = "註冊成功,但您的 Email 未被列入講師名單。請聯繫管理員。"; - authErrorMsg.classList.remove('hidden'); - await signOutUser(); - } - } catch (error) { - console.error(error); - let msg = error.code || error.message; - if (error.code === 'auth/email-already-in-use') { - msg = "此 Email 已被註冊,請直接登入。"; - } - authErrorMsg.textContent = "註冊失敗: " + msg; + if (!email || !password) { + authErrorMsg.textContent = "請輸入 Email 和密碼"; + authErrorMsg.classList.remove('hidden'); + return; + } + + try { + loginBtn.disabled = true; + loginBtn.classList.add('opacity-50'); + authErrorMsg.classList.add('hidden'); + + const user = await loginWithEmail(email, password); + const instructorData = await checkInstructorPermission(user); + + if (instructorData) { + authModal.classList.add('hidden'); + checkPermissions(instructorData); + localStorage.setItem('vibecoding_instructor_name', instructorData.name); + } else { + authErrorMsg.textContent = "未授權的帳號 (Unauthorized Account)"; authErrorMsg.classList.remove('hidden'); - } finally { - registerBtn.disabled = false; - registerBtn.classList.remove('opacity-50'); + await signOutUser(); } - }); - } + } catch (error) { + console.error(error); + let msg = error.code || error.message; + if (error.code === 'auth/invalid-credential' || error.code === 'auth/wrong-password' || error.code === 'auth/user-not-found') { + msg = "帳號或密碼錯誤。"; + } + authErrorMsg.textContent = "登入失敗: " + msg; + authErrorMsg.classList.remove('hidden'); + } finally { + loginBtn.disabled = false; + loginBtn.classList.remove('opacity-50'); + } + }); - // Create Room - console.log("Checking createBtn:", createBtn); - if (createBtn) { - createBtn.addEventListener('click', async () => { - // 4-Digit Room Code - const roomCode = Math.floor(1000 + Math.random() * 9000).toString(); - try { - // Ensure roomInfo is visible - const roomInfo = document.getElementById('room-info'); - const displayRoomCode = document.getElementById('display-room-code'); - const createContainer = document.getElementById('create-room-container'); - const dashboardContent = document.getElementById('dashboard-content'); + // Forgot Password Handler + const forgotBtn = document.createElement('button'); + forgotBtn.textContent = "忘記密碼?"; + forgotBtn.className = "text-sm text-gray-400 hover:text-white mt-2 underline block mx-auto"; // Centered link + + // Insert after auth-error message or append to modal content? + // Appending to the parent of Login Button seems best, or just below it. + // The modal structure in index.html is needed to know exact placement. + // Assuming loginBtn is inside a flex column form. + loginBtn.parentNode.insertBefore(forgotBtn, loginBtn.nextSibling); + + forgotBtn.addEventListener('click', async () => { + const email = loginEmailInput.value; + if (!email) { + authErrorMsg.textContent = "請先在上欄輸入 Email 以發送重設信"; + authErrorMsg.classList.remove('hidden'); + return; + } + if (!confirm(`確定要發送重設密碼信件至 ${email} 嗎?`)) return; - await createRoom(roomCode, currentInstructor ? currentInstructor.name : 'Unknown'); + try { + // Dynamically import to avoid top-level dependency if not needed + const { resetPassword } = await import("../services/auth.js"); + await resetPassword(email); + alert(`已發送重設密碼信件至 ${email},請查收信箱並依照指示重設密碼。`); + authErrorMsg.classList.add('hidden'); + } catch (e) { + console.error(e); + let msg = e.message; + if (e.code === 'auth/user-not-found') msg = "找不到此帳號,請確認 Email 是否正確。"; + authErrorMsg.textContent = "發送失敗: " + msg; + authErrorMsg.classList.remove('hidden'); + } + }); - // Trigger cleanup of old rooms - cleanupOldRooms(); + // Register Handler + registerBtn.addEventListener('click', async () => { + const email = loginEmailInput.value; + const password = loginPasswordInput.value; - displayRoomCode.textContent = roomCode; + if (!email || !password) { + authErrorMsg.textContent = "請輸入 Email 和密碼"; + authErrorMsg.classList.remove('hidden'); + return; + } - // Store in LocalStorage - localStorage.setItem('vibecoding_room_code', roomCode); - localStorage.setItem('vibecoding_is_host', 'true'); + try { + registerBtn.disabled = true; + registerBtn.classList.add('opacity-50'); + authErrorMsg.classList.add('hidden'); + + // Try to create auth account + const user = await registerWithEmail(email, password); + // Check if this email is in our whitelist + const instructorData = await checkInstructorPermission(user); + + if (instructorData) { + authModal.classList.add('hidden'); + checkPermissions(instructorData); + localStorage.setItem('vibecoding_instructor_name', instructorData.name); + alert("註冊成功!"); + } else { + // Auth created but not in whitelist + authErrorMsg.textContent = "註冊成功,但您的 Email 未被列入講師名單。請聯繫管理員。"; + authErrorMsg.classList.remove('hidden'); + await signOutUser(); + } + } catch (error) { + console.error(error); + let msg = error.code || error.message; + if (error.code === 'auth/email-already-in-use') { + msg = "此 Email 已被註冊,請直接登入。"; + } + authErrorMsg.textContent = "註冊失敗: " + msg; + authErrorMsg.classList.remove('hidden'); + } finally { + registerBtn.disabled = false; + registerBtn.classList.remove('opacity-50'); + } + }); +} - // UI Updates - createContainer.classList.add('hidden'); - roomInfo.classList.remove('hidden'); - dashboardContent.classList.remove('hidden'); +// Create Room +console.log("Checking createBtn:", createBtn); +if (createBtn) { + createBtn.addEventListener('click', async () => { + // 4-Digit Room Code + const roomCode = Math.floor(1000 + Math.random() * 9000).toString(); + try { + // Ensure roomInfo is visible + const roomInfo = document.getElementById('room-info'); + const displayRoomCode = document.getElementById('display-room-code'); + const createContainer = document.getElementById('create-room-container'); + const dashboardContent = document.getElementById('dashboard-content'); - // Start Subscription - subscribeToRoom(roomCode, (data) => { - updateDashboard(data); - }); + await createRoom(roomCode, currentInstructor ? currentInstructor.name : 'Unknown'); - } catch (e) { - console.error(e); - alert("無法建立教室: " + e.message); - } - }); - } + // Trigger cleanup of old rooms + cleanupOldRooms(); - // AI Settings Logic - if (setupAiBtn) { - setupAiBtn.addEventListener('click', () => { - const key = localStorage.getItem('vibecoding_gemini_key') || ''; - const studentEnabled = localStorage.getItem('vibecoding_student_ai_enabled') === 'true'; + displayRoomCode.textContent = roomCode; - if (geminiKeyInput) geminiKeyInput.value = key; - if (toggleStudentAi) toggleStudentAi.checked = studentEnabled; + // Store in LocalStorage + localStorage.setItem('vibecoding_room_code', roomCode); + localStorage.setItem('vibecoding_is_host', 'true'); - if (aiSettingsModal) aiSettingsModal.classList.remove('hidden'); - }); - } + // UI Updates + createContainer.classList.add('hidden'); + roomInfo.classList.remove('hidden'); + dashboardContent.classList.remove('hidden'); - if (saveAiSettingsBtn) { - saveAiSettingsBtn.addEventListener('click', async () => { - const key = geminiKeyInput ? geminiKeyInput.value.trim() : ''; - const studentEnabled = toggleStudentAi ? toggleStudentAi.checked : false; + // Start Subscription + subscribeToRoom(roomCode, (data) => { + updateDashboard(data); + }); - if (key) { - localStorage.setItem('vibecoding_gemini_key', key); - } else { - localStorage.removeItem('vibecoding_gemini_key'); - } + } catch (e) { + console.error(e); + alert("無法建立教室: " + e.message); + } + }); +} - localStorage.setItem('vibecoding_student_ai_enabled', studentEnabled); +// AI Settings Logic +if (setupAiBtn) { + setupAiBtn.addEventListener('click', () => { + const key = localStorage.getItem('vibecoding_gemini_key') || ''; + const studentEnabled = localStorage.getItem('vibecoding_student_ai_enabled') === 'true'; - // Update Firestore if in a room - const roomCode = localStorage.getItem('vibecoding_instructor_room'); + if (geminiKeyInput) geminiKeyInput.value = key; + if (toggleStudentAi) toggleStudentAi.checked = studentEnabled; - if (roomCode) { - try { - const { getFirestore, doc, updateDoc } = await import("https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js"); - const db = getFirestore(); - await updateDoc(doc(db, "classrooms", roomCode), { ai_active: studentEnabled }); - } catch (e) { - console.error("Failed to sync AI setting to room:", e); - } - } + if (aiSettingsModal) aiSettingsModal.classList.remove('hidden'); + }); +} - // Initialize Gemini (if key exists) - if (key) { - try { - const { initGemini } = await import("../services/gemini.js"); - await initGemini(key); - geminiEnabled = true; // Update module-level var - alert("AI 設定已儲存並啟動!"); - } catch (e) { - console.error(e); - alert("AI 啟動失敗: " + e.message); - } - } else { - geminiEnabled = false; - alert("AI 設定已儲存 (停用)"); - } +if (saveAiSettingsBtn) { + saveAiSettingsBtn.addEventListener('click', async () => { + const key = geminiKeyInput ? geminiKeyInput.value.trim() : ''; + const studentEnabled = toggleStudentAi ? toggleStudentAi.checked : false; - if (aiSettingsModal) aiSettingsModal.classList.add('hidden'); - }); - } + if (key) { + localStorage.setItem('vibecoding_gemini_key', key); + } else { + localStorage.removeItem('vibecoding_gemini_key'); + } - // Rejoin Room - const rejoinBtn = document.getElementById('rejoin-room-btn'); - if (rejoinBtn) { - rejoinBtn.addEventListener('click', async () => { - const inputCode = document.getElementById('rejoin-room-code').value.trim().toUpperCase(); - if (!inputCode) return alert("請輸入代碼"); + localStorage.setItem('vibecoding_student_ai_enabled', studentEnabled); - try { - // Ensure roomInfo is visible - const roomInfo = document.getElementById('room-info'); - const displayRoomCode = document.getElementById('display-room-code'); - const createContainer = document.getElementById('create-room-container'); - const dashboardContent = document.getElementById('dashboard-content'); - - // Check if room exists first (optional, subscribe handles it usually) - displayRoomCode.textContent = inputCode; - localStorage.setItem('vibecoding_room_code', inputCode); - - // UI Updates - createContainer.classList.add('hidden'); - roomInfo.classList.remove('hidden'); - dashboardContent.classList.remove('hidden'); - document.getElementById('group-photo-btn').classList.remove('hidden'); - - subscribeToRoom(inputCode, async (data) => { - const users = Array.isArray(data) ? data : (data?.users ? Object.values(data.users) : []); - currentStudents = users; - - // Render if function available - if (typeof renderTransposedHeatmap === 'function') { - renderTransposedHeatmap(users); - } + // Update Firestore if in a room + const roomCode = localStorage.getItem('vibecoding_instructor_room'); - // Auto-Restore Room View if exists - if (localStorage.getItem('vibecoding_gemini_key')) { - try { - const GeminiService = await import("../services/gemini.js?t=" + Date.now()); - const apiKey = localStorage.getItem('vibecoding_gemini_key'); - const success = await GeminiService.initGemini(apiKey); - if (success) { - geminiEnabled = true; - // Check student toggle - const studentEnabled = localStorage.getItem('vibecoding_student_ai_enabled') === 'true'; - if (studentEnabled) { - // Update Firestore - const code = localStorage.getItem('vibecoding_room_code'); - if (code) { - import("https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js").then(({ doc, updateDoc, getFirestore }) => { - const db = getFirestore(); - updateDoc(doc(db, "classrooms", code), { ai_active: true }).catch(console.error); - }); - } - } - } - } catch (e) { console.warn("Gemini auto-init failed", e); } - } - }); + if (roomCode) { + try { + const { getFirestore, doc, updateDoc } = await import("https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js"); + const db = getFirestore(); + await updateDoc(doc(db, "classrooms", roomCode), { ai_active: studentEnabled }); + } catch (e) { + console.error("Failed to sync AI setting to room:", e); + } + } + // Initialize Gemini (if key exists) + if (key) { + try { + const { initGemini } = await import("../services/gemini.js"); + await initGemini(key); + geminiEnabled = true; // Update module-level var + alert("AI 設定已儲存並啟動!"); } catch (e) { console.error(e); - alert("加入失敗: " + e.message); + alert("AI 啟動失敗: " + e.message); } - }); - } + } else { + geminiEnabled = false; + alert("AI 設定已儲存 (停用)"); + } - // Leave Room - const leaveBtn = document.getElementById('leave-room-btn'); - if (leaveBtn) { - leaveBtn.addEventListener('click', () => { + if (aiSettingsModal) aiSettingsModal.classList.add('hidden'); + }); +} + +// Rejoin Room +const rejoinBtn = document.getElementById('rejoin-room-btn'); +if (rejoinBtn) { + rejoinBtn.addEventListener('click', async () => { + const inputCode = document.getElementById('rejoin-room-code').value.trim().toUpperCase(); + if (!inputCode) return alert("請輸入代碼"); + + try { + // Ensure roomInfo is visible const roomInfo = document.getElementById('room-info'); + const displayRoomCode = document.getElementById('display-room-code'); const createContainer = document.getElementById('create-room-container'); const dashboardContent = document.getElementById('dashboard-content'); - const displayRoomCode = document.getElementById('display-room-code'); - localStorage.removeItem('vibecoding_room_code'); - localStorage.removeItem('vibecoding_is_host'); + // Check if room exists first (optional, subscribe handles it usually) + displayRoomCode.textContent = inputCode; + localStorage.setItem('vibecoding_room_code', inputCode); - displayRoomCode.textContent = ''; - roomInfo.classList.add('hidden'); - dashboardContent.classList.add('hidden'); - createContainer.classList.remove('hidden'); + // UI Updates + createContainer.classList.add('hidden'); + roomInfo.classList.remove('hidden'); + dashboardContent.classList.remove('hidden'); + document.getElementById('group-photo-btn').classList.remove('hidden'); - // Unsubscribe logic if needed, usually auto-handled by new subscription or page reload - window.location.reload(); - }); - } + subscribeToRoom(inputCode, async (data) => { + const users = Array.isArray(data) ? data : (data?.users ? Object.values(data.users) : []); + currentStudents = users; - // Nav to Admin - if (navAdminBtn) { - navAdminBtn.addEventListener('click', () => { - localStorage.setItem('vibecoding_admin_referer', 'instructor'); - window.location.hash = '#admin'; - }); - } + // Render if function available + if (typeof renderTransposedHeatmap === 'function') { + renderTransposedHeatmap(users); + } - // Handle Instructor Management - navInstBtn.addEventListener('click', async () => { - const modal = document.getElementById('instructor-modal'); - const listBody = document.getElementById('instructor-list-body'); + // Auto-Restore Room View if exists + if (localStorage.getItem('vibecoding_gemini_key')) { + try { + const GeminiService = await import("../services/gemini.js?t=" + Date.now()); + const apiKey = localStorage.getItem('vibecoding_gemini_key'); + const success = await GeminiService.initGemini(apiKey); + if (success) { + geminiEnabled = true; + // Check student toggle + const studentEnabled = localStorage.getItem('vibecoding_student_ai_enabled') === 'true'; + if (studentEnabled) { + // Update Firestore + const code = localStorage.getItem('vibecoding_room_code'); + if (code) { + import("https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js").then(({ doc, updateDoc, getFirestore }) => { + const db = getFirestore(); + updateDoc(doc(db, "classrooms", code), { ai_active: true }).catch(console.error); + }); + } + } + } + } catch (e) { console.warn("Gemini auto-init failed", e); } + } + }); - // Load list - const instructors = await getInstructors(); - listBody.innerHTML = instructors.map(inst => ` + } catch (e) { + console.error(e); + alert("加入失敗: " + e.message); + } + }); +} + +// Leave Room +const leaveBtn = document.getElementById('leave-room-btn'); +if (leaveBtn) { + leaveBtn.addEventListener('click', () => { + const roomInfo = document.getElementById('room-info'); + const createContainer = document.getElementById('create-room-container'); + const dashboardContent = document.getElementById('dashboard-content'); + const displayRoomCode = document.getElementById('display-room-code'); + + localStorage.removeItem('vibecoding_room_code'); + localStorage.removeItem('vibecoding_is_host'); + + displayRoomCode.textContent = ''; + roomInfo.classList.add('hidden'); + dashboardContent.classList.add('hidden'); + createContainer.classList.remove('hidden'); + + // Unsubscribe logic if needed, usually auto-handled by new subscription or page reload + window.location.reload(); + }); +} + +// Nav to Admin +if (navAdminBtn) { + navAdminBtn.addEventListener('click', () => { + localStorage.setItem('vibecoding_admin_referer', 'instructor'); + window.location.hash = '#admin'; + }); +} + +// Handle Instructor Management +navInstBtn.addEventListener('click', async () => { + const modal = document.getElementById('instructor-modal'); + const listBody = document.getElementById('instructor-list-body'); + + // Load list + const instructors = await getInstructors(); + listBody.innerHTML = instructors.map(inst => ` ${inst.name} ${inst.email} ${inst.permissions?.map(p => { - const map = { create_room: '開房', add_question: '題目', manage_instructors: '管理' }; - return `${map[p] || p}`; - }).join('')} + const map = { create_room: '開房', add_question: '題目', manage_instructors: '管理' }; + return `${map[p] || p}`; + }).join('')} ${inst.role === 'admin' ? '不可移除' : - ``} + ``} `).join(''); - modal.classList.remove('hidden'); - }); + modal.classList.remove('hidden'); +}); - // Add New Instructor - const addInstBtn = document.getElementById('btn-add-inst'); - if (addInstBtn) { - addInstBtn.addEventListener('click', async () => { - const email = document.getElementById('new-inst-email').value.trim(); - const name = document.getElementById('new-inst-name').value.trim(); +// Add New Instructor +const addInstBtn = document.getElementById('btn-add-inst'); +if (addInstBtn) { + addInstBtn.addEventListener('click', async () => { + const email = document.getElementById('new-inst-email').value.trim(); + const name = document.getElementById('new-inst-name').value.trim(); - if (!email || !name) return alert("請輸入完整資料"); + if (!email || !name) return alert("請輸入完整資料"); - const perms = []; - if (document.getElementById('perm-room').checked) perms.push('create_room'); - if (document.getElementById('perm-q').checked) perms.push('add_question'); - if (document.getElementById('perm-inst').checked) perms.push('manage_instructors'); + const perms = []; + if (document.getElementById('perm-room').checked) perms.push('create_room'); + if (document.getElementById('perm-q').checked) perms.push('add_question'); + if (document.getElementById('perm-inst').checked) perms.push('manage_instructors'); - try { - await addInstructor(email, name, perms); - alert("新增成功"); - navInstBtn.click(); // Reload list - document.getElementById('new-inst-email').value = ''; - document.getElementById('new-inst-name').value = ''; - } catch (e) { - alert("新增失敗: " + e.message); - } - }); - } + try { + await addInstructor(email, name, perms); + alert("新增成功"); + navInstBtn.click(); // Reload list + document.getElementById('new-inst-email').value = ''; + document.getElementById('new-inst-name').value = ''; + } catch (e) { + alert("新增失敗: " + e.message); + } + }); +} - // Global helper for remove (hacky but works for simple onclick) - window.removeInst = async (email) => { - if (confirm(`確定移除 ${email}?`)) { - try { - await removeInstructor(email); - navInstBtn.click(); // Reload - } catch (e) { - alert(e.message); - } +// Global helper for remove (hacky but works for simple onclick) +window.removeInst = async (email) => { + if (confirm(`確定移除 ${email}?`)) { + try { + await removeInstructor(email); + navInstBtn.click(); // Reload + } catch (e) { + alert(e.message); } - }; + } +}; - // Auto Check Auth (Persistence) - // We rely on Firebase Auth state observer instead of session storage for security? - // Or we can just check if user is already signed in. - import("../services/firebase.js").then(async ({ auth }) => { - // Handle Redirect Result first - try { - console.log("Initializing Auth Check..."); - const { handleRedirectResult } = await import("../services/auth.js"); - const redirectUser = await handleRedirectResult(); - if (redirectUser) console.log("Redirect User Found:", redirectUser.email); - } catch (e) { console.warn("Redirect check failed", e); } - - auth.onAuthStateChanged(async (user) => { - console.log("Auth State Changed to:", user ? user.email : "Logged Out"); - if (user) { - try { - console.log("Checking permissions for:", user.email); - const instructorData = await checkInstructorPermission(user); - console.log("Permission Result:", instructorData); - - if (instructorData) { - console.log("Hiding Modal and Setting Permissions..."); - authModal.classList.add('hidden'); - checkPermissions(instructorData); - - // Auto-Restore Room View if exists - const savedRoomCode = localStorage.getItem('vibecoding_room_code'); - if (savedRoomCode) { - console.log("Restoring Room Session:", savedRoomCode); - const roomInfo = document.getElementById('room-info'); - const displayRoomCode = document.getElementById('display-room-code'); - const createContainer = document.getElementById('create-room-container'); - const dashboardContent = document.getElementById('dashboard-content'); - - // Restore UI - createContainer.classList.add('hidden'); - roomInfo.classList.remove('hidden'); - dashboardContent.classList.remove('hidden'); - displayRoomCode.textContent = savedRoomCode; - - // Re-subscribe locally using the existing updateDashboard logic if available, - // or we need to redefine the callback here. - // Since updateDashboard is inside createBtn scope, we can't mistakenly access it if it's not global. - // Wait, updateDashboard IS inside createBtn scope. That's a problem. - // We need to move updateDashboard out or duplicate the logic here. - // Duplicating logic for robustness: - subscribeToRoom(savedRoomCode, (data) => { - const users = Array.isArray(data) ? data : (data?.users ? Object.values(data.users) : []); - currentStudents = users; - renderTransposedHeatmap(users); - }); - } +// Auto Check Auth (Persistence) +// We rely on Firebase Auth state observer instead of session storage for security? +// Or we can just check if user is already signed in. +import("../services/firebase.js").then(async ({ auth }) => { + // Handle Redirect Result first + try { + console.log("Initializing Auth Check..."); + const { handleRedirectResult } = await import("../services/auth.js"); + const redirectUser = await handleRedirectResult(); + if (redirectUser) console.log("Redirect User Found:", redirectUser.email); + } catch (e) { console.warn("Redirect check failed", e); } + + auth.onAuthStateChanged(async (user) => { + console.log("Auth State Changed to:", user ? user.email : "Logged Out"); + if (user) { + try { + console.log("Checking permissions for:", user.email); + const instructorData = await checkInstructorPermission(user); + console.log("Permission Result:", instructorData); - } else { - console.warn("User logged in but not an instructor."); - // Show unauthorized message - authErrorMsg.textContent = "此帳號無講師權限"; - authErrorMsg.classList.remove('hidden'); - authModal.classList.remove('hidden'); // Ensure modal stays up + if (instructorData) { + console.log("Hiding Modal and Setting Permissions..."); + authModal.classList.add('hidden'); + checkPermissions(instructorData); + + // Auto-Restore Room View if exists + const savedRoomCode = localStorage.getItem('vibecoding_room_code'); + if (savedRoomCode) { + console.log("Restoring Room Session:", savedRoomCode); + const roomInfo = document.getElementById('room-info'); + const displayRoomCode = document.getElementById('display-room-code'); + const createContainer = document.getElementById('create-room-container'); + const dashboardContent = document.getElementById('dashboard-content'); + + // Restore UI + createContainer.classList.add('hidden'); + roomInfo.classList.remove('hidden'); + dashboardContent.classList.remove('hidden'); + displayRoomCode.textContent = savedRoomCode; + + // Re-subscribe locally using the existing updateDashboard logic if available, + // or we need to redefine the callback here. + // Since updateDashboard is inside createBtn scope, we can't mistakenly access it if it's not global. + // Wait, updateDashboard IS inside createBtn scope. That's a problem. + // We need to move updateDashboard out or duplicate the logic here. + // Duplicating logic for robustness: + subscribeToRoom(savedRoomCode, (data) => { + const users = Array.isArray(data) ? data : (data?.users ? Object.values(data.users) : []); + currentStudents = users; + renderTransposedHeatmap(users); + }); } - } catch (e) { - console.error("Permission Check Failed:", e); - authErrorMsg.textContent = "權限檢查失敗: " + e.message; + + } else { + console.warn("User logged in but not an instructor."); + // Show unauthorized message + authErrorMsg.textContent = "此帳號無講師權限"; authErrorMsg.classList.remove('hidden'); + authModal.classList.remove('hidden'); // Ensure modal stays up } - } else { - authModal.classList.remove('hidden'); - } - }); - }); - - // Define Kick Function globally (robust against auth flow) - window.confirmKick = async (userId, nickname) => { - if (confirm(`確定要踢出 ${nickname} 嗎?此動作無法復原。`)) { - try { - const { removeUser } = await import("../services/classroom.js"); - await removeUser(userId); - // UI will update automatically via subscribeToRoom } catch (e) { - console.error("Kick failed:", e); - alert("移除失敗"); + console.error("Permission Check Failed:", e); + authErrorMsg.textContent = "權限檢查失敗: " + e.message; + authErrorMsg.classList.remove('hidden'); } + } else { + authModal.classList.remove('hidden'); } - }; + }); +}); +// Define Kick Function globally (robust against auth flow) +window.confirmKick = async (userId, nickname) => { + if (confirm(`確定要踢出 ${nickname} 嗎?此動作無法復原。`)) { + try { + const { removeUser } = await import("../services/classroom.js"); + await removeUser(userId); + // UI will update automatically via subscribeToRoom + } catch (e) { + console.error("Kick failed:", e); + alert("移除失敗"); + } + } +}; - const btnAddInst = document.getElementById('btn-add-inst'); - if (btnAddInst) { - btnAddInst.addEventListener('click', async () => { - const email = document.getElementById('new-inst-email').value.trim(); - const name = document.getElementById('new-inst-name').value.trim(); - const perms = []; - if (document.getElementById('perm-room').checked) perms.push('create_room'); - if (document.getElementById('perm-q').checked) perms.push('add_question'); - if (document.getElementById('perm-inst').checked) perms.push('manage_instructors'); - if (!email || !name) { - alert("請輸入 Email 和姓名"); - return; - } +const btnAddInst = document.getElementById('btn-add-inst'); +if (btnAddInst) { + btnAddInst.addEventListener('click', async () => { + const email = document.getElementById('new-inst-email').value.trim(); + const name = document.getElementById('new-inst-name').value.trim(); + const perms = []; + if (document.getElementById('perm-room').checked) perms.push('create_room'); + if (document.getElementById('perm-q').checked) perms.push('add_question'); + if (document.getElementById('perm-inst').checked) perms.push('manage_instructors'); - try { - await addInstructor(email, name, perms); - alert("新增成功"); - document.getElementById('new-inst-email').value = ''; - document.getElementById('new-inst-name').value = ''; - loadInstructorList(); - } catch (e) { - console.error(e); - alert("新增失敗: " + e.message); - } - }); - } + if (!email || !name) { + alert("請輸入 Email 和姓名"); + return; + } - window.removeInst = async (email) => { - if (confirm(`確定要移除 ${email} 嗎?`)) { - try { - await removeInstructor(email); - loadInstructorList(); - } catch (e) { - console.error(e); - alert("移除失敗"); - } + try { + await addInstructor(email, name, perms); + alert("新增成功"); + document.getElementById('new-inst-email').value = ''; + document.getElementById('new-inst-name').value = ''; + loadInstructorList(); + } catch (e) { + console.error(e); + alert("新增失敗: " + e.message); } - }; - // Snapshot Logic - snapshotBtn.addEventListener('click', async () => { - if (isSnapshotting || typeof htmlToImage === 'undefined') { - if (typeof htmlToImage === 'undefined') alert("截圖元件尚未載入,請稍候再試"); - return; + }); +} + +window.removeInst = async (email) => { + if (confirm(`確定要移除 ${email} 嗎?`)) { + try { + await removeInstructor(email); + loadInstructorList(); + } catch (e) { + console.error(e); + alert("移除失敗"); } - isSnapshotting = true; - - const overlay = document.getElementById('snapshot-overlay'); - const countEl = document.getElementById('countdown-number'); - const container = document.getElementById('group-photo-container'); - const modal = document.getElementById('group-photo-modal'); - - // Close button hide - const closeBtn = modal.querySelector('button'); - if (closeBtn) closeBtn.style.opacity = '0'; - snapshotBtn.style.opacity = '0'; - - overlay.classList.remove('hidden'); - overlay.classList.add('flex'); - - // Countdown Sequence - const runCountdown = (num) => new Promise(resolve => { - countEl.textContent = num; - countEl.style.transform = 'scale(1.5)'; - countEl.style.opacity = '1'; - - // Animation reset - requestAnimationFrame(() => { - countEl.style.transition = 'all 0.5s ease-out'; - countEl.style.transform = 'scale(1)'; - countEl.style.opacity = '0.5'; - setTimeout(resolve, 1000); - }); + } +}; +// Snapshot Logic +snapshotBtn.addEventListener('click', async () => { + if (isSnapshotting || typeof htmlToImage === 'undefined') { + if (typeof htmlToImage === 'undefined') alert("截圖元件尚未載入,請稍候再試"); + return; + } + isSnapshotting = true; + + const overlay = document.getElementById('snapshot-overlay'); + const countEl = document.getElementById('countdown-number'); + const container = document.getElementById('group-photo-container'); + const modal = document.getElementById('group-photo-modal'); + + // Close button hide + const closeBtn = modal.querySelector('button'); + if (closeBtn) closeBtn.style.opacity = '0'; + snapshotBtn.style.opacity = '0'; + + overlay.classList.remove('hidden'); + overlay.classList.add('flex'); + + // Countdown Sequence + const runCountdown = (num) => new Promise(resolve => { + countEl.textContent = num; + countEl.style.transform = 'scale(1.5)'; + countEl.style.opacity = '1'; + + // Animation reset + requestAnimationFrame(() => { + countEl.style.transition = 'all 0.5s ease-out'; + countEl.style.transform = 'scale(1)'; + countEl.style.opacity = '0.5'; + setTimeout(resolve, 1000); }); + }); - await runCountdown(3); - await runCountdown(2); - await runCountdown(1); - - // Action! - countEl.textContent = ''; - overlay.classList.add('hidden'); - - // 1. Emojis Explosion - const emojis = ['🤘', '✌️', '👍', '🫶', '😎', '🔥']; - const cards = container.querySelectorAll('.group\\/card'); - - cards.forEach(card => { - // Find the monster image container - const imgContainer = card.querySelector('.monster-img-container'); - if (!imgContainer) return; - - // Random Emoji - const emoji = emojis[Math.floor(Math.random() * emojis.length)]; - const emojiEl = document.createElement('div'); - emojiEl.textContent = emoji; - // Position: Top-Right of the *Image*, slightly overlapping - emojiEl.className = 'absolute -top-2 -right-2 text-2xl animate-bounce z-50 drop-shadow-md transform rotate-12'; - emojiEl.style.animationDuration = '0.6s'; - imgContainer.appendChild(emojiEl); - - // Remove after 3s - setTimeout(() => emojiEl.remove(), 3000); - }); + await runCountdown(3); + await runCountdown(2); + await runCountdown(1); + + // Action! + countEl.textContent = ''; + overlay.classList.add('hidden'); + + // 1. Emojis Explosion + const emojis = ['🤘', '✌️', '👍', '🫶', '😎', '🔥']; + const cards = container.querySelectorAll('.group\\/card'); + + cards.forEach(card => { + // Find the monster image container + const imgContainer = card.querySelector('.monster-img-container'); + if (!imgContainer) return; + + // Random Emoji + const emoji = emojis[Math.floor(Math.random() * emojis.length)]; + const emojiEl = document.createElement('div'); + emojiEl.textContent = emoji; + // Position: Top-Right of the *Image*, slightly overlapping + emojiEl.className = 'absolute -top-2 -right-2 text-2xl animate-bounce z-50 drop-shadow-md transform rotate-12'; + emojiEl.style.animationDuration = '0.6s'; + imgContainer.appendChild(emojiEl); + + // Remove after 3s + setTimeout(() => emojiEl.remove(), 3000); + }); - // 2. Capture using html-to-image - setTimeout(async () => { - try { - // Flash Effect - const flash = document.createElement('div'); - flash.className = 'fixed inset-0 bg-white z-[100] transition-opacity duration-300 pointer-events-none'; - document.body.appendChild(flash); - setTimeout(() => flash.style.opacity = '0', 50); - setTimeout(() => flash.remove(), 300); - - // Use htmlToImage.toPng - const dataUrl = await htmlToImage.toPng(container, { - backgroundColor: '#111827', - pixelRatio: 2, - cacheBust: true, - }); + // 2. Capture using html-to-image + setTimeout(async () => { + try { + // Flash Effect + const flash = document.createElement('div'); + flash.className = 'fixed inset-0 bg-white z-[100] transition-opacity duration-300 pointer-events-none'; + document.body.appendChild(flash); + setTimeout(() => flash.style.opacity = '0', 50); + setTimeout(() => flash.remove(), 300); + + // Use htmlToImage.toPng + const dataUrl = await htmlToImage.toPng(container, { + backgroundColor: '#111827', + pixelRatio: 2, + cacheBust: true, + }); - // Download - const link = document.createElement('a'); - const dateStr = new Date().toISOString().slice(0, 10); - link.download = `VIBE_Class_Photo_${dateStr}.png`; - link.href = dataUrl; - link.click(); + // Download + const link = document.createElement('a'); + const dateStr = new Date().toISOString().slice(0, 10); + link.download = `VIBE_Class_Photo_${dateStr}.png`; + link.href = dataUrl; + link.click(); - } catch (e) { - console.error("Snapshot failed:", e); - alert("截圖失敗 (請嘗試手動截圖/PrtSc)\n原因: " + e.message); - } finally { - // Restore UI - if (closeBtn) closeBtn.style.opacity = '1'; - snapshotBtn.style.opacity = '1'; - isSnapshotting = false; - } - }, 600); // Slight delay for emojis to appear - }); + } catch (e) { + console.error("Snapshot failed:", e); + alert("截圖失敗 (請嘗試手動截圖/PrtSc)\n原因: " + e.message); + } finally { + // Restore UI + if (closeBtn) closeBtn.style.opacity = '1'; + snapshotBtn.style.opacity = '1'; + isSnapshotting = false; + } + }, 600); // Slight delay for emojis to appear +}); - // Group Photo Logic - groupPhotoBtn.addEventListener('click', () => { - const modal = document.getElementById('group-photo-modal'); - const container = document.getElementById('group-photo-container'); - const dateEl = document.getElementById('photo-date'); +// Group Photo Logic +groupPhotoBtn.addEventListener('click', () => { + const modal = document.getElementById('group-photo-modal'); + const container = document.getElementById('group-photo-container'); + const dateEl = document.getElementById('photo-date'); - // Update Date - const now = new Date(); - dateEl.textContent = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} `; + // Update Date + const now = new Date(); + dateEl.textContent = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} `; - // Get saved name - const savedName = localStorage.getItem('vibecoding_instructor_name') || '講師 (Instructor)'; + // Get saved name + const savedName = localStorage.getItem('vibecoding_instructor_name') || '講師 (Instructor)'; - container.innerHTML = ''; + container.innerHTML = ''; - // 1. Container for Relative Positioning with Custom Background - const relativeContainer = document.createElement('div'); - relativeContainer.className = 'relative w-full h-[600px] md:h-[700px] overflow-hidden rounded-3xl border border-gray-700/30 shadow-2xl flex items-center justify-center bg-cover bg-center'; - relativeContainer.style.backgroundImage = "url('assets/photobg.png')"; - container.appendChild(relativeContainer); + // 1. Container for Relative Positioning with Custom Background + const relativeContainer = document.createElement('div'); + relativeContainer.className = 'relative w-full h-[600px] md:h-[700px] overflow-hidden rounded-3xl border border-gray-700/30 shadow-2xl flex items-center justify-center bg-cover bg-center'; + relativeContainer.style.backgroundImage = "url('assets/photobg.png')"; + container.appendChild(relativeContainer); - // Watermark (Bottom Right, High Z-Index, Gradient Text, Dark Backdrop) - const watermark = document.createElement('div'); - watermark.className = 'absolute bottom-2 right-2 md:bottom-4 md:right-4 z-[60] bg-black/70 backdrop-blur-sm rounded-lg px-4 py-2 pointer-events-none select-none border border-white/10 shadow-lg'; + // Watermark (Bottom Right, High Z-Index, Gradient Text, Dark Backdrop) + const watermark = document.createElement('div'); + watermark.className = 'absolute bottom-2 right-2 md:bottom-4 md:right-4 z-[60] bg-black/70 backdrop-blur-sm rounded-lg px-4 py-2 pointer-events-none select-none border border-white/10 shadow-lg'; - const d = new Date(); - const dateStr = `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} `; + const d = new Date(); + const dateStr = `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} `; - watermark.innerHTML = ` + watermark.innerHTML = ` ${dateStr} VibeCoding 怪獸成長營 `; - relativeContainer.appendChild(watermark); + relativeContainer.appendChild(watermark); - // 2. Instructor Section (Absolute Center) - const instructorSection = document.createElement('div'); - instructorSection.className = 'absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center justify-center z-20 group cursor-pointer'; - instructorSection.innerHTML = ` + // 2. Instructor Section (Absolute Center) + const instructorSection = document.createElement('div'); + instructorSection.className = 'absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center justify-center z-20 group cursor-pointer'; + instructorSection.innerHTML = `
@@ -1519,98 +1517,98 @@ export function setupInstructorEvents() {
`; - relativeContainer.appendChild(instructorSection); - - // Save name on change - setTimeout(() => { - const input = document.getElementById('instructor-name-input'); - if (input) { - input.addEventListener('input', (e) => { - localStorage.setItem('vibecoding_instructor_name', e.target.value); - }); - } - }, 100); - - // 3. Students Scatter - if (currentStudents.length > 0) { - // Randomize array to prevent fixed order bias - const students = [...currentStudents].sort(() => Math.random() - 0.5); - const total = students.length; - - // --- Dynamic Sizing Logic --- - let sizeClass = 'w-20 h-20 md:w-24 md:h-24'; // Default (Size 100%) - let scaleFactor = 1.0; - - if (total >= 40) { - sizeClass = 'w-12 h-12 md:w-14 md:h-14'; // Size 60% - scaleFactor = 0.6; - } else if (total >= 20) { - sizeClass = 'w-16 h-16 md:w-20 md:h-20'; // Size 80% - scaleFactor = 0.8; - } + relativeContainer.appendChild(instructorSection); + + // Save name on change + setTimeout(() => { + const input = document.getElementById('instructor-name-input'); + if (input) { + input.addEventListener('input', (e) => { + localStorage.setItem('vibecoding_instructor_name', e.target.value); + }); + } + }, 100); + + // 3. Students Scatter + if (currentStudents.length > 0) { + // Randomize array to prevent fixed order bias + const students = [...currentStudents].sort(() => Math.random() - 0.5); + const total = students.length; + + // --- Dynamic Sizing Logic --- + let sizeClass = 'w-20 h-20 md:w-24 md:h-24'; // Default (Size 100%) + let scaleFactor = 1.0; + + if (total >= 40) { + sizeClass = 'w-12 h-12 md:w-14 md:h-14'; // Size 60% + scaleFactor = 0.6; + } else if (total >= 20) { + sizeClass = 'w-16 h-16 md:w-20 md:h-20'; // Size 80% + scaleFactor = 0.8; + } - students.forEach((s, index) => { - const progressMap = s.progress || {}; - const totalLikes = Object.values(progressMap).reduce((acc, p) => acc + (p.likes || 0), 0); - const totalCompleted = Object.values(progressMap).filter(p => p.status === 'completed').length; - - // FIXED: Prioritize stored ID if valid (same as StudentView logic) - let monster; - if (s.monster_id && s.monster_id !== 'Egg' && s.monster_id !== 'Unknown') { - const stored = MONSTER_DEFS?.find(m => m.id === s.monster_id); - if (stored) { - monster = stored; - } else { - // Fallback if ID invalid - monster = getNextMonster(s.monster_stage || 0, totalLikes, total, s.monster_id); - } + students.forEach((s, index) => { + const progressMap = s.progress || {}; + const totalLikes = Object.values(progressMap).reduce((acc, p) => acc + (p.likes || 0), 0); + const totalCompleted = Object.values(progressMap).filter(p => p.status === 'completed').length; + + // FIXED: Prioritize stored ID if valid (same as StudentView logic) + let monster; + if (s.monster_id && s.monster_id !== 'Egg' && s.monster_id !== 'Unknown') { + const stored = MONSTER_DEFS?.find(m => m.id === s.monster_id); + if (stored) { + monster = stored; } else { + // Fallback if ID invalid monster = getNextMonster(s.monster_stage || 0, totalLikes, total, s.monster_id); } + } else { + monster = getNextMonster(s.monster_stage || 0, totalLikes, total, s.monster_id); + } - // --- FIXED: Even Arc Distribution (Safe Zone: 135 deg to 405 deg) --- - // Avoids Bottom Right (0-90) and Bottom Center (90-120) entirely - const minR = 220; - // Avoids Bottom Right (0-90) and Bottom Center (90-120) entirely + // --- FIXED: Even Arc Distribution (Safe Zone: 135 deg to 405 deg) --- + // Avoids Bottom Right (0-90) and Bottom Center (90-120) entirely + const minR = 220; + // Avoids Bottom Right (0-90) and Bottom Center (90-120) entirely - // Safe Arc Range: Starts at 135 deg (Bottom Left) -> Goes CW -> Ends at 405 deg (45 deg, Bottom Right) - // Total Span = 270 degrees - // If many students, use double ring + // Safe Arc Range: Starts at 135 deg (Bottom Left) -> Goes CW -> Ends at 405 deg (45 deg, Bottom Right) + // Total Span = 270 degrees + // If many students, use double ring - const safeStartAngle = 135 * (Math.PI / 180); - const safeSpan = 270 * (Math.PI / 180); + const safeStartAngle = 135 * (Math.PI / 180); + const safeSpan = 270 * (Math.PI / 180); - // Distribute evenly - // If only 1 student, put at top (270 deg / 4.71 rad) - let finalAngle; + // Distribute evenly + // If only 1 student, put at top (270 deg / 4.71 rad) + let finalAngle; - if (total === 1) { - finalAngle = 270 * (Math.PI / 180); - } else { - const step = safeSpan / (total - 1); - finalAngle = safeStartAngle + (step * index); - } + if (total === 1) { + finalAngle = 270 * (Math.PI / 180); + } else { + const step = safeSpan / (total - 1); + finalAngle = safeStartAngle + (step * index); + } - // Radius: Fixed base + slight variation for "natural" look (but not overlap causing) - // Double ring logic if crowded - let radius = minR + (index % 2) * 40; // Zigzag radius (220, 260, 220...) to minimize overlap + // Radius: Fixed base + slight variation for "natural" look (but not overlap causing) + // Double ring logic if crowded + let radius = minR + (index % 2) * 40; // Zigzag radius (220, 260, 220...) to minimize overlap - // Reduce zigzag if few students - if (total < 10) radius = minR + (index % 2) * 20; + // Reduce zigzag if few students + if (total < 10) radius = minR + (index % 2) * 20; - const xOff = Math.cos(finalAngle) * radius; - const yOff = Math.sin(finalAngle) * radius * 0.8; + const xOff = Math.cos(finalAngle) * radius; + const yOff = Math.sin(finalAngle) * radius * 0.8; - const card = document.createElement('div'); - card.className = 'absolute flex flex-col items-center group/card z-10 hover:z-50 transition-all duration-500 cursor-move'; + const card = document.createElement('div'); + card.className = 'absolute flex flex-col items-center group/card z-10 hover:z-50 transition-all duration-500 cursor-move'; - card.style.left = `calc(50% + ${xOff}px)`; - card.style.top = `calc(50% + ${yOff}px)`; - card.style.transform = 'translate(-50%, -50%)'; + card.style.left = `calc(50% + ${xOff}px)`; + card.style.top = `calc(50% + ${yOff}px)`; + card.style.transform = 'translate(-50%, -50%)'; - const floatDelay = Math.random() * 2; + const floatDelay = Math.random() * 2; - card.innerHTML = ` + card.innerHTML = `
${monster.name.split(' ')[1] || monster.name}
@@ -1634,327 +1632,327 @@ export function setupInstructorEvents() {
${s.nickname}
- `; - relativeContainer.appendChild(card); - - // Enable Drag & Drop - setupDraggable(card, relativeContainer); - }); - } - - modal.classList.remove('hidden'); - }); - - // Helper: Drag & Drop Logic - function setupDraggable(el, container) { - let isDragging = false; - let startX, startY, initialLeft, initialTop; - - el.addEventListener('mousedown', (e) => { - isDragging = true; - startX = e.clientX; - startY = e.clientY; - - // Disable transition during drag for responsiveness - el.style.transition = 'none'; - el.style.zIndex = 100; // Bring to front - - // Convert current computed position to fixed pixels if relying on calc - const rect = el.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - - // Calculate position relative to container - // The current transform is translate(-50%, -50%). - // We want to set left/top such that the center remains under the mouse offset, - // but for simplicity, let's just use current offsetLeft/Top if possible, - // OR robustly recalculate from rects. - - // Current center point relative to container: - const centerX = rect.left - containerRect.left + rect.width / 2; - const centerY = rect.top - containerRect.top + rect.height / 2; - - // Set explicit pixel values replacing calc() - el.style.left = `${centerX}px`; - el.style.top = `${centerY}px`; - - initialLeft = centerX; - initialTop = centerY; - }); - - window.addEventListener('mousemove', (e) => { - if (!isDragging) return; - e.preventDefault(); - - const dx = e.clientX - startX; - const dy = e.clientY - startY; - - el.style.left = `${initialLeft + dx}px`; - el.style.top = `${initialTop + dy}px`; - }); + `; + relativeContainer.appendChild(card); - window.addEventListener('mouseup', () => { - if (isDragging) { - isDragging = false; - el.style.transition = ''; // Re-enable hover effects - el.style.zIndex = ''; // Restore z-index rule (or let hover take over) - } + // Enable Drag & Drop + setupDraggable(card, relativeContainer); }); } - // Add float animation style if not exists - if (!document.getElementById('anim-float')) { - const style = document.createElement('style'); - style.id = 'anim-float'; - style.innerHTML = ` - @keyframes float { - - 0 %, 100 % { transform: translateY(0) scale(1); } - 50% {transform: translateY(-5px) scale(1.02); } -} -} - `; - document.head.appendChild(style); - } + modal.classList.remove('hidden'); +}); - // Gallery Logic - document.getElementById('btn-open-gallery').addEventListener('click', () => { - window.open('monster_preview.html', '_blank'); - }); +// Helper: Drag & Drop Logic +function setupDraggable(el, container) { + let isDragging = false; + let startX, startY, initialLeft, initialTop; - // Logout Logic - document.getElementById('logout-btn').addEventListener('click', async () => { - if (confirm('確定要登出講師模式嗎? (將會回到首頁)')) { - await signOutUser(); - sessionStorage.removeItem('vibecoding_instructor_in_room'); - sessionStorage.removeItem('vibecoding_admin_referer'); - window.location.hash = ''; - window.location.reload(); - } - }); + el.addEventListener('mousedown', (e) => { + isDragging = true; + startX = e.clientX; + startY = e.clientY; - // Check Previous Session (Handled by onAuthStateChanged now) - // if (sessionStorage.getItem('vibecoding_instructor_auth') === 'true') { - // authModal.classList.add('hidden'); - // } + // Disable transition during drag for responsiveness + el.style.transition = 'none'; + el.style.zIndex = 100; // Bring to front - // Check Active Room State - const activeRoom = sessionStorage.getItem('vibecoding_instructor_in_room'); - if (activeRoom === 'true' && savedRoomCode) { - enterRoom(savedRoomCode); - } + // Convert current computed position to fixed pixels if relying on calc + const rect = el.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); - // Module-level variable to track subscription (Moved to top) + // Calculate position relative to container + // The current transform is translate(-50%, -50%). + // We want to set left/top such that the center remains under the mouse offset, + // but for simplicity, let's just use current offsetLeft/Top if possible, + // OR robustly recalculate from rects. - function enterRoom(roomCode) { - createContainer.classList.add('hidden'); - roomInfo.classList.remove('hidden'); - dashboardContent.classList.remove('hidden'); - document.getElementById('group-photo-btn').classList.remove('hidden'); // Show photo button - displayRoomCode.textContent = roomCode; - localStorage.setItem('vibecoding_instructor_room', roomCode); - sessionStorage.setItem('vibecoding_instructor_in_room', 'true'); + // Current center point relative to container: + const centerX = rect.left - containerRect.left + rect.width / 2; + const centerY = rect.top - containerRect.top + rect.height / 2; - // Unsubscribe previous if any - if (roomUnsubscribe) roomUnsubscribe(); + // Set explicit pixel values replacing calc() + el.style.left = `${centerX}px`; + el.style.top = `${centerY}px`; - // Subscribe to updates - roomUnsubscribe = subscribeToRoom(roomCode, (students) => { - currentStudents = students; - renderTransposedHeatmap(students); - }); - } + initialLeft = centerX; + initialTop = centerY; + }); - // Leave Room Logic - document.getElementById('leave-room-btn').addEventListener('click', () => { - if (confirm('確定要離開目前教室嗎?(不會刪除教室資料,僅回到選擇介面)')) { - // Unsubscribe - if (roomUnsubscribe) { - roomUnsubscribe(); - roomUnsubscribe = null; - } + window.addEventListener('mousemove', (e) => { + if (!isDragging) return; + e.preventDefault(); - // UI Reset - createContainer.classList.remove('hidden'); - roomInfo.classList.add('hidden'); - dashboardContent.classList.add('hidden'); - document.getElementById('group-photo-btn').classList.add('hidden'); // Hide photo button + const dx = e.clientX - startX; + const dy = e.clientY - startY; - // Clear Data Display - document.getElementById('heatmap-body').innerHTML = '等待資料載入...'; - document.getElementById('heatmap-header').innerHTML = '學員 / 關卡'; + el.style.left = `${initialLeft + dx}px`; + el.style.top = `${initialTop + dy}px`; + }); - // State Clear - sessionStorage.removeItem('vibecoding_instructor_in_room'); - localStorage.removeItem('vibecoding_instructor_room'); + window.addEventListener('mouseup', () => { + if (isDragging) { + isDragging = false; + el.style.transition = ''; // Re-enable hover effects + el.style.zIndex = ''; // Restore z-index rule (or let hover take over) } }); +} - // Modal Events - window.showBroadcastModal = (userId, challengeId) => { - const modal = document.getElementById('broadcast-modal'); - const content = document.getElementById('broadcast-content'); - - // Find Data - const student = currentStudents.find(s => s.id === userId); - if (!student) return alert('找不到學員資料'); - - const p = student.progress ? student.progress[challengeId] : null; - if (!p) return alert('找不到該作品資料'); +// Add float animation style if not exists +if (!document.getElementById('anim-float')) { + const style = document.createElement('style'); + style.id = 'anim-float'; + style.innerHTML = ` + @keyframes float { - const challenge = cachedChallenges.find(c => c.id === challengeId); - const title = challenge ? challenge.title : '未知題目'; + 0 %, 100 % { transform: translateY(0) scale(1); } + 50% {transform: translateY(-5px) scale(1.02); } +} +} + `; + document.head.appendChild(style); +} - // Populate UI - document.getElementById('broadcast-avatar').textContent = student.nickname[0] || '?'; - document.getElementById('broadcast-author').textContent = student.nickname; - document.getElementById('broadcast-challenge').textContent = title; - document.getElementById('broadcast-prompt').textContent = p.prompt || '(無內容)'; +// Gallery Logic +document.getElementById('btn-open-gallery').addEventListener('click', () => { + window.open('monster_preview.html', '_blank'); +}); + +// Logout Logic +document.getElementById('logout-btn').addEventListener('click', async () => { + if (confirm('確定要登出講師模式嗎? (將會回到首頁)')) { + await signOutUser(); + sessionStorage.removeItem('vibecoding_instructor_in_room'); + sessionStorage.removeItem('vibecoding_admin_referer'); + window.location.hash = ''; + window.location.reload(); + } +}); - // Store IDs for Actions (Reject/BroadcastAll) - modal.dataset.userId = userId; - modal.dataset.challengeId = challengeId; +// Check Previous Session (Handled by onAuthStateChanged now) +// if (sessionStorage.getItem('vibecoding_instructor_auth') === 'true') { +// authModal.classList.add('hidden'); +// } - // Show - modal.classList.remove('hidden'); - setTimeout(() => { - content.classList.remove('scale-95', 'opacity-0'); - content.classList.add('opacity-100', 'scale-100'); - }, 10); - }; +// Check Active Room State +const activeRoom = sessionStorage.getItem('vibecoding_instructor_in_room'); +if (activeRoom === 'true' && savedRoomCode) { + enterRoom(savedRoomCode); +} - window.closeBroadcast = () => { - const modal = document.getElementById('broadcast-modal'); - const content = document.getElementById('broadcast-content'); - content.classList.remove('opacity-100', 'scale-100'); - content.classList.add('scale-95', 'opacity-0'); - setTimeout(() => modal.classList.add('hidden'), 300); - }; +// Module-level variable to track subscription (Moved to top) - window.openStage = (prompt, author) => { - document.getElementById('broadcast-content').classList.add('hidden'); - const stage = document.getElementById('stage-view'); - stage.classList.remove('hidden'); - document.getElementById('stage-prompt').textContent = cleanText(prompt || ''); - document.getElementById('stage-author').textContent = author; - }; +function enterRoom(roomCode) { + createContainer.classList.add('hidden'); + roomInfo.classList.remove('hidden'); + dashboardContent.classList.remove('hidden'); + document.getElementById('group-photo-btn').classList.remove('hidden'); // Show photo button + displayRoomCode.textContent = roomCode; + localStorage.setItem('vibecoding_instructor_room', roomCode); + sessionStorage.setItem('vibecoding_instructor_in_room', 'true'); - window.closeStage = () => { - document.getElementById('stage-view').classList.add('hidden'); - document.getElementById('broadcast-content').classList.remove('hidden'); - }; + // Unsubscribe previous if any + if (roomUnsubscribe) roomUnsubscribe(); - document.getElementById('btn-show-stage').addEventListener('click', () => { - const prompt = document.getElementById('broadcast-prompt').textContent; - const author = document.getElementById('broadcast-author').textContent; - window.openStage(prompt, author); + // Subscribe to updates + roomUnsubscribe = subscribeToRoom(roomCode, (students) => { + currentStudents = students; + renderTransposedHeatmap(students); }); +} - // Reject Logic - document.getElementById('btn-reject-task').addEventListener('click', async () => { - if (!confirm('確定要退回此題目讓學員重做嗎?')) return; +// Leave Room Logic +document.getElementById('leave-room-btn').addEventListener('click', () => { + if (confirm('確定要離開目前教室嗎?(不會刪除教室資料,僅回到選擇介面)')) { + // Unsubscribe + if (roomUnsubscribe) { + roomUnsubscribe(); + roomUnsubscribe = null; + } - // We need student ID (userId) and Challenge ID. - const modal = document.getElementById('broadcast-modal'); - const userId = modal.dataset.userId; - const challengeId = modal.dataset.challengeId; - const roomCode = localStorage.getItem('vibecoding_instructor_room'); + // UI Reset + createContainer.classList.remove('hidden'); + roomInfo.classList.add('hidden'); + dashboardContent.classList.add('hidden'); + document.getElementById('group-photo-btn').classList.add('hidden'); // Hide photo button - console.log('Reject attempt:', { userId, challengeId, roomCode }); + // Clear Data Display + document.getElementById('heatmap-body').innerHTML = '等待資料載入...'; + document.getElementById('heatmap-header').innerHTML = '學員 / 關卡'; - if (!userId || !challengeId) { - alert('找不到學員或題目資料,請重新開啟作品'); - return; - } - if (!roomCode) { - alert('未連接到教室,請先加入教室'); - return; - } + // State Clear + sessionStorage.removeItem('vibecoding_instructor_in_room'); + localStorage.removeItem('vibecoding_instructor_room'); + } +}); + +// Modal Events +window.showBroadcastModal = (userId, challengeId) => { + const modal = document.getElementById('broadcast-modal'); + const content = document.getElementById('broadcast-content'); + + // Find Data + const student = currentStudents.find(s => s.id === userId); + if (!student) return alert('找不到學員資料'); + + const p = student.progress ? student.progress[challengeId] : null; + if (!p) return alert('找不到該作品資料'); + + const challenge = cachedChallenges.find(c => c.id === challengeId); + const title = challenge ? challenge.title : '未知題目'; + + // Populate UI + document.getElementById('broadcast-avatar').textContent = student.nickname[0] || '?'; + document.getElementById('broadcast-author').textContent = student.nickname; + document.getElementById('broadcast-challenge').textContent = title; + document.getElementById('broadcast-prompt').textContent = p.prompt || '(無內容)'; + + // Store IDs for Actions (Reject/BroadcastAll) + modal.dataset.userId = userId; + modal.dataset.challengeId = challengeId; + + // Show + modal.classList.remove('hidden'); + setTimeout(() => { + content.classList.remove('scale-95', 'opacity-0'); + content.classList.add('opacity-100', 'scale-100'); + }, 10); +}; + +window.closeBroadcast = () => { + const modal = document.getElementById('broadcast-modal'); + const content = document.getElementById('broadcast-content'); + content.classList.remove('opacity-100', 'scale-100'); + content.classList.add('scale-95', 'opacity-0'); + setTimeout(() => modal.classList.add('hidden'), 300); +}; + +window.openStage = (prompt, author) => { + document.getElementById('broadcast-content').classList.add('hidden'); + const stage = document.getElementById('stage-view'); + stage.classList.remove('hidden'); + document.getElementById('stage-prompt').textContent = cleanText(prompt || ''); + document.getElementById('stage-author').textContent = author; +}; + +window.closeStage = () => { + document.getElementById('stage-view').classList.add('hidden'); + document.getElementById('broadcast-content').classList.remove('hidden'); +}; + +document.getElementById('btn-show-stage').addEventListener('click', () => { + const prompt = document.getElementById('broadcast-prompt').textContent; + const author = document.getElementById('broadcast-author').textContent; + window.openStage(prompt, author); +}); + +// Reject Logic +document.getElementById('btn-reject-task').addEventListener('click', async () => { + if (!confirm('確定要退回此題目讓學員重做嗎?')) return; + + // We need student ID (userId) and Challenge ID. + const modal = document.getElementById('broadcast-modal'); + const userId = modal.dataset.userId; + const challengeId = modal.dataset.challengeId; + const roomCode = localStorage.getItem('vibecoding_instructor_room'); + + console.log('Reject attempt:', { userId, challengeId, roomCode }); + + if (!userId || !challengeId) { + alert('找不到學員或題目資料,��重新開啟作品'); + return; + } + if (!roomCode) { + alert('未連接到教室,請先加入教室'); + return; + } - try { - await resetProgress(userId, roomCode, challengeId); - alert('已成功退回,學員將需要重新作答'); - // Close modal - window.closeBroadcast(); - } catch (e) { - console.error(e); - alert('退回失敗: ' + e.message); - } - }); - // Prompt Viewer Logic - window.openPromptList = (type, id, title) => { - const modal = document.getElementById('prompt-list-modal'); - const container = document.getElementById('prompt-list-container'); - const titleEl = document.getElementById('prompt-list-title'); - - // Set Global State for AI Analysis Scope - if (type === 'challenge') { - window.currentViewingChallengeId = id; - } else { - window.currentViewingChallengeId = null; - } + try { + await resetProgress(userId, roomCode, challengeId); + alert('已成功退回,學員將需要重新作答'); + // Close modal + window.closeBroadcast(); + } catch (e) { + console.error(e); + alert('退回失敗: ' + e.message); + } +}); +// Prompt Viewer Logic +window.openPromptList = (type, id, title) => { + const modal = document.getElementById('prompt-list-modal'); + const container = document.getElementById('prompt-list-container'); + const titleEl = document.getElementById('prompt-list-title'); + + // Set Global State for AI Analysis Scope + if (type === 'challenge') { + window.currentViewingChallengeId = id; + } else { + window.currentViewingChallengeId = null; + } - titleEl.textContent = type === 'student' ? `${title} 的所有提示詞` : `題目:${title} 的所有作品`; + titleEl.textContent = type === 'student' ? `${title} 的所有提示詞` : `題目:${title} 的所有作品`; - // Reset Anonymous Toggle in List View - const anonCheck = document.getElementById('list-anonymous-toggle'); - if (anonCheck) anonCheck.checked = false; + // Reset Anonymous Toggle in List View + const anonCheck = document.getElementById('list-anonymous-toggle'); + if (anonCheck) anonCheck.checked = false; - container.innerHTML = ''; - modal.classList.remove('hidden'); + container.innerHTML = ''; + modal.classList.remove('hidden'); + + // Collect Prompts + let prompts = []; + // Fix: Reset selection when opening new list to prevent cross-contamination + selectedPrompts = []; + updateCompareButton(); - // Collect Prompts - let prompts = []; - // Fix: Reset selection when opening new list to prevent cross-contamination - selectedPrompts = []; - updateCompareButton(); - - if (type === 'student') { - const student = currentStudents.find(s => s.id === id); - if (student && student.progress) { - prompts = Object.entries(student.progress) - .filter(([_, p]) => p.status === 'completed' && p.prompt) - .map(([challengeId, p]) => { - const challenge = cachedChallenges.find(c => c.id === challengeId); - return { - id: `${student.id}_${challengeId}`, - title: challenge ? challenge.title : '未知題目', - prompt: p.prompt, - author: student.nickname, - studentId: student.id, - challengeId: challengeId, - time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : '' - }; + if (type === 'student') { + const student = currentStudents.find(s => s.id === id); + if (student && student.progress) { + prompts = Object.entries(student.progress) + .filter(([_, p]) => p.status === 'completed' && p.prompt) + .map(([challengeId, p]) => { + const challenge = cachedChallenges.find(c => c.id === challengeId); + return { + id: `${student.id}_${challengeId}`, + title: challenge ? challenge.title : '未知題目', + prompt: p.prompt, + author: student.nickname, + studentId: student.id, + challengeId: challengeId, + time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : '' + }; + }); + } + } else if (type === 'challenge') { + currentStudents.forEach(student => { + if (student.progress && student.progress[id]) { + const p = student.progress[id]; + if (p.status === 'completed' && p.prompt) { + prompts.push({ + id: `${student.id}_${id}`, + title: student.nickname, // When viewing challenge, title is student name + prompt: p.prompt, + author: student.nickname, + studentId: student.id, + challengeId: id, + time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : '' }); - } - } else if (type === 'challenge') { - currentStudents.forEach(student => { - if (student.progress && student.progress[id]) { - const p = student.progress[id]; - if (p.status === 'completed' && p.prompt) { - prompts.push({ - id: `${student.id}_${id}`, - title: student.nickname, // When viewing challenge, title is student name - prompt: p.prompt, - author: student.nickname, - studentId: student.id, - challengeId: id, - time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : '' - }); - } } - }); - } + } + }); + } - if (prompts.length === 0) { - container.innerHTML = '
無資料
'; - return; - } + if (prompts.length === 0) { + container.innerHTML = '
無資料
'; + return; + } - prompts.forEach(p => { - const card = document.createElement('div'); - // Reduced height (h-64 -> h-48) and padding, but larger text inside - card.className = 'bg-gray-800 rounded-xl p-3 border border-gray-700 hover:border-cyan-500 transition-colors flex flex-col h-48 group'; - card.innerHTML = ` + prompts.forEach(p => { + const card = document.createElement('div'); + // Reduced height (h-64 -> h-48) and padding, but larger text inside + card.className = 'bg-gray-800 rounded-xl p-3 border border-gray-700 hover:border-cyan-500 transition-colors flex flex-col h-48 group'; + card.innerHTML = `

${p.title}

@@ -1975,304 +1973,313 @@ export function setupInstructorEvents() {
`; - container.appendChild(card); - }); - }; - - // Helper Actions - window.confirmReset = async (userId, challengeId, title) => { - if (confirm(`確定要退回 ${title} 嗎?此動作將清除學員目前的進度。`)) { - // Unified top-level import - const roomCode = localStorage.getItem('vibecoding_room_code') || localStorage.getItem('vibecoding_instructor_room'); // Fallback - - if (userId && challengeId) { - try { - alert("正在執行退回 (列表模式)..."); - // Use top-level import directly - await resetProgress(userId, roomCode, challengeId); - // Refresh current list if open? (It will stay open but might not update immediately if realtime check isn't hooked to modal content. But subscriptions update `currentStudents`. We might need to refresh list) - // For now, simple alert or auto-close - alert("已退回"); - // close modal to refresh data context - document.getElementById('prompt-list-modal').classList.add('hidden'); - } catch (e) { - console.error(e); - alert("退回失敗"); - } - } - } - }; - - window.broadcastPrompt = (userId, challengeId) => { - window.showBroadcastModal(userId, challengeId); - }; - - // Selection Logic - let selectedPrompts = []; // Stores IDs + container.appendChild(card); + }); +}; - window.handlePromptSelection = (checkbox) => { - const id = checkbox.dataset.id; +// Helper Actions +window.confirmReset = async (userId, challengeId, title) => { + if (confirm(`確定要退回 ${title} 嗎?此動作將清除學員目前的進度。`)) { + // Unified top-level import + const roomCode = localStorage.getItem('vibecoding_room_code') || localStorage.getItem('vibecoding_instructor_room'); // Fallback - if (checkbox.checked) { - if (selectedPrompts.length >= 3) { - checkbox.checked = false; - alert('最多只能選擇 3 個提示詞進行比較'); - return; + if (userId && challengeId) { + try { + alert("正在執行退回 (列表模式)..."); + // Use top-level import directly + await resetProgress(userId, roomCode, challengeId); + // Refresh current list if open? (It will stay open but might not update immediately if realtime check isn't hooked to modal content. But subscriptions update `currentStudents`. We might need to refresh list) + // For now, simple alert or auto-close + alert("已退回"); + // close modal to refresh data context + document.getElementById('prompt-list-modal').classList.add('hidden'); + } catch (e) { + console.error(e); + alert("退回失敗"); } - selectedPrompts.push(id); - } else { - selectedPrompts = selectedPrompts.filter(pid => pid !== id); } - updateCompareButton(); - }; + } +}; - function updateCompareButton() { - const btn = document.getElementById('btn-compare-prompts'); - if (!btn) return; +window.broadcastPrompt = (userId, challengeId) => { + window.showBroadcastModal(userId, challengeId); +}; - const count = selectedPrompts.length; - const span = btn.querySelector('span'); - if (span) span.textContent = `🔍 比較已選項目 (${count}/3)`; +// Selection Logic +let selectedPrompts = []; // Stores IDs - if (count > 0) { - btn.disabled = false; - btn.classList.remove('opacity-50', 'cursor-not-allowed'); - } else { - btn.disabled = true; - btn.classList.add('opacity-50', 'cursor-not-allowed'); +window.handlePromptSelection = (checkbox) => { + const id = checkbox.dataset.id; + + if (checkbox.checked) { + if (selectedPrompts.length >= 3) { + checkbox.checked = false; + alert('最多只能選擇 3 個提示詞進行比較'); + return; } + selectedPrompts.push(id); + } else { + selectedPrompts = selectedPrompts.filter(pid => pid !== id); } - // Comparison Logic - const compareBtn = document.getElementById('btn-compare-prompts'); - if (compareBtn) { - compareBtn.addEventListener('click', () => { - const dataToCompare = []; - selectedPrompts.forEach(fullId => { - const lastUnderscore = fullId.lastIndexOf('_'); - const studentId = fullId.substring(0, lastUnderscore); - const challengeId = fullId.substring(lastUnderscore + 1); - - const student = currentStudents.find(s => s.id === studentId); - if (student && student.progress && student.progress[challengeId]) { - const p = student.progress[challengeId]; - const challenge = cachedChallenges.find(c => c.id === challengeId); - - dataToCompare.push({ - title: challenge ? challenge.title : '未知', - author: student.nickname, - prompt: p.prompt, - time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleTimeString() : '' - }); - } - }); + updateCompareButton(); +}; + +function updateCompareButton() { + const btn = document.getElementById('btn-compare-prompts'); + if (!btn) return; + + const count = selectedPrompts.length; + const span = btn.querySelector('span'); + if (span) span.textContent = `🔍 比較已選項目 (${count}/3)`; + + if (count > 0) { + btn.disabled = false; + btn.classList.remove('opacity-50', 'cursor-not-allowed'); + } else { + btn.disabled = true; + btn.classList.add('opacity-50', 'cursor-not-allowed'); + } +} +// Comparison Logic +const compareBtn = document.getElementById('btn-compare-prompts'); +if (compareBtn) { + compareBtn.addEventListener('click', () => { + const dataToCompare = []; + selectedPrompts.forEach(fullId => { + const lastUnderscore = fullId.lastIndexOf('_'); + const studentId = fullId.substring(0, lastUnderscore); + const challengeId = fullId.substring(lastUnderscore + 1); + + const student = currentStudents.find(s => s.id === studentId); + if (student && student.progress && student.progress[challengeId]) { + const p = student.progress[challengeId]; + const challenge = cachedChallenges.find(c => c.id === challengeId); - const isAnon = document.getElementById('list-anonymous-toggle')?.checked || false; - openComparisonView(dataToCompare, isAnon); + dataToCompare.push({ + title: challenge ? challenge.title : '未知', + author: student.nickname, + prompt: p.prompt, + time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleTimeString() : '' + }); + } }); - } + const isAnon = document.getElementById('list-anonymous-toggle')?.checked || false; + openComparisonView(dataToCompare, isAnon); + }); +} - // AI Analysis Logic - const aiAnalyzeBtn = document.getElementById('btn-ai-analyze'); - if (aiAnalyzeBtn) { - // Show button if key exists - if (localStorage.getItem('vibecoding_gemini_key')) { - aiAnalyzeBtn.classList.remove('hidden'); - } - aiAnalyzeBtn.addEventListener('click', async () => { - if (currentStudents.length === 0) return alert("無學生資料"); +// AI Analysis Logic +const aiAnalyzeBtn = document.getElementById('btn-ai-analyze'); +if (aiAnalyzeBtn) { + // Show button if key exists + if (localStorage.getItem('vibecoding_gemini_key')) { + aiAnalyzeBtn.classList.remove('hidden'); + } - // Robust Challenge ID Detection - let challengeId = window.currentViewingChallengeId; + aiAnalyzeBtn.addEventListener('click', async () => { + if (currentStudents.length === 0) return alert("無學生資料"); - // Fallback 1: Try to infer from first card - if (!challengeId) { - const firstCard = document.getElementById('prompt-list-container')?.firstElementChild; - if (firstCard) { - const fullId = firstCard.querySelector('input[type="checkbox"]')?.dataset.id; - if (fullId && fullId.includes('_')) { - challengeId = fullId.split('_').pop(); - window.currentViewingChallengeId = challengeId; // Set it for future stability - } + // Robust Challenge ID Detection + let challengeId = window.currentViewingChallengeId; + + // Fallback 1: Try to infer from first card + if (!challengeId) { + const firstCard = document.getElementById('prompt-list-container')?.firstElementChild; + if (firstCard) { + const fullId = firstCard.querySelector('input[type="checkbox"]')?.dataset.id; + if (fullId && fullId.includes('_')) { + challengeId = fullId.split('_').pop(); + window.currentViewingChallengeId = challengeId; // Set it for future stability } } + } - if (!challengeId) return alert("無法確認目前所在的題目,請重新開啟列表"); + if (!challengeId) return alert("無法確認目前所在的題目,請重新開啟列表"); - // UI Loading State - aiAnalyzeBtn.innerHTML = '⏳ AI 分析中 (Batch)...'; - aiAnalyzeBtn.disabled = true; - aiAnalyzeBtn.classList.add('animate-pulse'); + // UI Loading State + aiAnalyzeBtn.innerHTML = '⏳ AI 分析中 (Batch)...'; + aiAnalyzeBtn.disabled = true; + aiAnalyzeBtn.classList.add('animate-pulse'); - try { - const challenge = cachedChallenges.find(c => c.id === challengeId); - const challengeDesc = challenge ? challenge.description : "No Description"; - - // 1. Collect Valid Submissions for THIS challenge only - const submissions = []; - currentStudents.forEach(s => { - const p = s.progress?.[challengeId]; - if (p && p.status === 'completed' && p.prompt && p.prompt.length > 3) { - submissions.push({ - userId: s.id, - prompt: p.prompt, - nickname: s.nickname - }); - } - }); + try { + const challenge = cachedChallenges.find(c => c.id === challengeId); + const challengeDesc = challenge ? challenge.description : "No Description"; + + // 1. Collect Valid Submissions for THIS challenge only + const submissions = []; + currentStudents.forEach(s => { + const p = s.progress?.[challengeId]; + if (p && p.status === 'completed' && p.prompt && p.prompt.length > 3) { + submissions.push({ + userId: s.id, + prompt: p.prompt, + nickname: s.nickname + }); + } + }); - if (submissions.length === 0) throw new Error("沒有足夠的有效回答可供分析"); - - // 2. Call Batch API - const { evaluatePrompts } = await import("../services/gemini.js"); - const results = await evaluatePrompts(submissions, challengeDesc); - - // 3. Update UI with Badges - const badgeColors = { - "rough": "bg-gray-600 text-gray-200 border-gray-500", // 原石 - "precise": "bg-blue-600 text-blue-100 border-blue-400", // 精確 - "gentle": "bg-pink-600 text-pink-100 border-pink-400", // 有禮 - "creative": "bg-purple-600 text-purple-100 border-purple-400", // 創意 - "spam": "bg-red-900 text-red-200 border-red-700", // 無效 - "parrot": "bg-yellow-600 text-yellow-100 border-yellow-400" // 鸚鵡 - }; - - const badgeLabels = { - "rough": "🗿 原石", - "precise": "🎯 精確", - "gentle": "💖 有禮", - "creative": "✨ 創意", - "spam": "🗑️ 無效", - "parrot": "🦜 鸚鵡" - }; - - let count = 0; - Object.entries(results).forEach(([category, ids]) => { - ids.forEach(userId => { - // Find card - const checkbox = document.querySelector(`input[data-id="${userId}_${challengeId}"]`); - if (checkbox) { - const card = checkbox.closest('.group\\/card'); - if (card) { - const header = card.querySelector('h3')?.parentNode; - if (header) { - // Remove old badge - const oldBadge = card.querySelector('.ai-badge'); - if (oldBadge) oldBadge.remove(); - - const badge = document.createElement('span'); - badge.className = `ai-badge ml-2 text-xs px-2 py-0.5 rounded-full border ${badgeColors[category] || 'bg-gray-600'}`; - badge.textContent = badgeLabels[category] || category; - header.appendChild(badge); - count++; - } + if (submissions.length === 0) throw new Error("沒有足足夠的有效回答可供分析"); + + // 2. Call Batch API + const { initGemini, evaluatePrompts } = await import("../services/gemini.js"); + if (localStorage.getItem('vibecoding_gemini_key')) { + await initGemini(localStorage.getItem('vibecoding_gemini_key')); + } + const results = await evaluatePrompts(submissions, challengeId); + + // 3. Update UI with Badges + const badgeColors = { + "rough": "bg-gray-600 text-gray-200 border-gray-500", // 原石 + "precise": "bg-blue-600 text-blue-100 border-blue-400", // 精確 + "gentle": "bg-pink-600 text-pink-100 border-pink-400", // 有禮 + "creative": "bg-purple-600 text-purple-100 border-purple-400", // 創意 + "spam": "bg-red-900 text-red-200 border-red-700", // 無效 + "parrot": "bg-yellow-600 text-yellow-100 border-yellow-400" // 鸚鵡 + }; + + const badgeLabels = { + "rough": "🗿 原石", + "precise": "🎯 精確", + "gentle": "💖 有禮", + "creative": "✨ 創意", + "spam": "🗑️ 無效", + "parrot": "🦜 鸚鵡" + }; + + let count = 0; + Object.entries(results).forEach(([category, ids]) => { + ids.forEach(userId => { + // Find card + const checkbox = document.querySelector(`input[data-id="${userId}_${challengeId}"]`); + if (checkbox) { + const card = checkbox.closest('.group\\/card'); + if (card) { + const header = card.querySelector('h3')?.parentNode; + if (header) { + // Remove old badge + const oldBadge = card.querySelector('.ai-badge'); + if (oldBadge) oldBadge.remove(); + + const badge = document.createElement('span'); + badge.className = `ai-badge ml-2 text-xs px-2 py-0.5 rounded-full border ${badgeColors[category] || 'bg-gray-600'}`; + badge.textContent = badgeLabels[category] || category; + header.appendChild(badge); + count++; } } - }); + } }); + }); - aiAnalyzeBtn.innerHTML = `✅ 完成分析 (${count})`; - setTimeout(() => { - aiAnalyzeBtn.innerHTML = '✨ AI 選粹 (刷新)'; - aiAnalyzeBtn.disabled = false; - aiAnalyzeBtn.classList.remove('animate-pulse'); - }, 3000); - - } catch (e) { - console.error(e); - alert("分析失敗: " + e.message); - aiAnalyzeBtn.innerHTML = '❌ 重試'; + aiAnalyzeBtn.innerHTML = `✅ 完成分析 (${count})`; + setTimeout(() => { + aiAnalyzeBtn.innerHTML = '✨ AI 選粹 (刷新)'; aiAnalyzeBtn.disabled = false; aiAnalyzeBtn.classList.remove('animate-pulse'); - } - }); - if (!localStorage.getItem('vibecoding_gemini_key')) { - alert("請先設定 Gemini API Key"); - return; - } - // 1. Open the list - window.openPromptList('challenge', challengeId, challengeTitle); + }, 3000); - // 2. Trigger analysis logic automatically after slight delay (to ensure DOM render) - setTimeout(() => { - const btn = document.getElementById('btn-ai-analyze'); - if (btn && !btn.disabled) { - btn.click(); - } else { - console.warn("AI Analyze button not found or disabled"); - } - }, 300); - }; - - let isAnonymous = false; - - window.toggleAnonymous = (btn) => { - isAnonymous = !isAnonymous; - btn.textContent = isAnonymous ? '🙈 顯示姓名' : '👀 隱藏姓名'; - btn.classList.toggle('bg-gray-700'); - btn.classList.toggle('bg-purple-700'); - - // Update DOM - document.querySelectorAll('.comparison-author').forEach(el => { - if (isAnonymous) { - el.dataset.original = el.textContent; - el.textContent = '學員'; - el.classList.add('blur-sm'); // Optional Effect - setTimeout(() => el.classList.remove('blur-sm'), 300); - } else { - if (el.dataset.original) el.textContent = el.dataset.original; - } - }); - }; - window.openComparisonView = (items, initialAnonymous = false) => { - const modal = document.getElementById('comparison-modal'); - const grid = document.getElementById('comparison-grid'); + } catch (e) { + console.error(e); + alert("分析失敗: " + e.message); + aiAnalyzeBtn.innerHTML = '❌ 重試'; + aiAnalyzeBtn.disabled = false; + aiAnalyzeBtn.classList.remove('animate-pulse'); + } + }); +} - // Apply Anonymous State - isAnonymous = initialAnonymous; - const anonBtn = document.getElementById('btn-anonymous-toggle'); - // Update Toggle UI to match state - if (anonBtn) { - if (isAnonymous) { - anonBtn.textContent = '🙈 顯示姓名'; - anonBtn.classList.add('bg-purple-700'); - anonBtn.classList.remove('bg-gray-700'); - } else { - anonBtn.textContent = '👀 隱藏姓名'; - anonBtn.classList.remove('bg-purple-700'); - anonBtn.classList.add('bg-gray-700'); - } +// Direct Heatmap AI Analysis Link +window.analyzeChallenge = (challengeId, challengeTitle) => { + if (!localStorage.getItem('vibecoding_gemini_key')) { + alert("請先設定 Gemini API Key"); + return; + } + // 1. Open the list + window.openPromptList('challenge', challengeId, challengeTitle); + + // 2. Trigger analysis logic automatically after slight delay (to ensure DOM render) + setTimeout(() => { + const btn = document.getElementById('btn-ai-analyze'); + if (btn && !btn.disabled) { + btn.click(); + } else { + console.warn("AI Analyze button not found or disabled"); + } + }, 300); +}; + +let isAnonymous = false; + +window.toggleAnonymous = (btn) => { + isAnonymous = !isAnonymous; + btn.textContent = isAnonymous ? '🙈 顯示姓名' : '👀 隱藏姓名'; + btn.classList.toggle('bg-gray-700'); + btn.classList.toggle('bg-purple-700'); + + // Update DOM + document.querySelectorAll('.comparison-author').forEach(el => { + if (isAnonymous) { + el.dataset.original = el.textContent; + el.textContent = '學員'; + el.classList.add('blur-sm'); // Optional Effect + setTimeout(() => el.classList.remove('blur-sm'), 300); + } else { + if (el.dataset.original) el.textContent = el.dataset.original; } + }); +}; + +window.openComparisonView = (items, initialAnonymous = false) => { + const modal = document.getElementById('comparison-modal'); + const grid = document.getElementById('comparison-grid'); + + // Apply Anonymous State + isAnonymous = initialAnonymous; + const anonBtn = document.getElementById('btn-anonymous-toggle'); + + // Update Toggle UI to match state + if (anonBtn) { + if (isAnonymous) { + anonBtn.textContent = '🙈 顯示姓名'; + anonBtn.classList.add('bg-purple-700'); + anonBtn.classList.remove('bg-gray-700'); + } else { + anonBtn.textContent = '👀 隱藏姓名'; + anonBtn.classList.remove('bg-purple-700'); + anonBtn.classList.add('bg-gray-700'); + } + } - // Setup Grid Rows (Vertical Stacking) - let rowClass = 'grid-rows-1'; - if (items.length === 2) rowClass = 'grid-rows-2'; - if (items.length === 3) rowClass = 'grid-rows-3'; - - grid.className = `absolute inset-0 grid ${rowClass} gap-0 divide-y divide-gray-600`; - grid.innerHTML = ''; - - items.forEach(item => { - const col = document.createElement('div'); - // Check overflow-hidden to keep it contained, use flex-row for compact header + content - col.className = 'flex flex-row h-full bg-gray-900 p-4 overflow-hidden'; - - // Logic for anonymous - let displayAuthor = item.author; - let blurClass = ''; - - if (isAnonymous) { - displayAuthor = '學員'; - blurClass = 'blur-sm'; // Initial blur - // Auto remove blur after delay if needed, or keep it? - // Toggle logic removes it after delay. But initial render should probably just be static '學員' or blurred. - // The toggle logic uses dataset.original. We need to set it here too. - } + // Setup Grid Rows (Vertical Stacking) + let rowClass = 'grid-rows-1'; + if (items.length === 2) rowClass = 'grid-rows-2'; + if (items.length === 3) rowClass = 'grid-rows-3'; + + grid.className = `absolute inset-0 grid ${rowClass} gap-0 divide-y divide-gray-600`; + grid.innerHTML = ''; + + items.forEach(item => { + const col = document.createElement('div'); + // Check overflow-hidden to keep it contained, use flex-row for compact header + content + col.className = 'flex flex-row h-full bg-gray-900 p-4 overflow-hidden'; + + // Logic for anonymous + let displayAuthor = item.author; + let blurClass = ''; + + if (isAnonymous) { + displayAuthor = '學員'; + blurClass = 'blur-sm'; // Initial blur + // Auto remove blur after delay if needed, or keep it? + // Toggle logic removes it after delay. But initial render should probably just be static '學員' or blurred. + // The toggle logic uses dataset.original. We need to set it here too. + } - col.innerHTML = ` + col.innerHTML = `

${displayAuthor}

${item.title}

@@ -2281,222 +2288,222 @@ export function setupInstructorEvents() {
${cleanText(item.prompt)}
`; - grid.appendChild(col); - - // If blurred, remove blur after animation purely for effect, or keep? - // User intention "Hidden Name" usually means "Replaced by generic name". - // The blur effect in toggle logic was transient. - // If we want persistent anonymity, just "學員" is enough. - // The existing toggle logic adds 'blur-sm' then removes it in 300ms. - // We should replicate that effect if we want consistency, or just skip blur on init. - if (isAnonymous) { - const el = col.querySelector('.comparison-author'); - setTimeout(() => el.classList.remove('blur-sm'), 300); - } - }); - - document.getElementById('prompt-list-modal').classList.add('hidden'); - modal.classList.remove('hidden'); + grid.appendChild(col); + + // If blurred, remove blur after animation purely for effect, or keep? + // User intention "Hidden Name" usually means "Replaced by generic name". + // The blur effect in toggle logic was transient. + // If we want persistent anonymity, just "學員" is enough. + // The existing toggle logic adds 'blur-sm' then removes it in 300ms. + // We should replicate that effect if we want consistency, or just skip blur on init. + if (isAnonymous) { + const el = col.querySelector('.comparison-author'); + setTimeout(() => el.classList.remove('blur-sm'), 300); + } + }); - // Init Canvas (Phase 3) - setTimeout(setupCanvas, 100); + document.getElementById('prompt-list-modal').classList.add('hidden'); + modal.classList.remove('hidden'); + + // Init Canvas (Phase 3) + setTimeout(setupCanvas, 100); +}; + +window.closeComparison = () => { + document.getElementById('comparison-modal').classList.add('hidden'); + clearCanvas(); +}; + +// --- Phase 3 & 6: Annotation Tools --- +let canvas, ctx; +let isDrawing = false; +let currentPenColor = '#ef4444'; // Red default +let currentLineWidth = 3; +let currentMode = 'source-over'; // 'source-over' (Pen) or 'destination-out' (Eraser) + +window.setupCanvas = () => { + canvas = document.getElementById('annotation-canvas'); + const container = document.getElementById('comparison-container'); + if (!canvas || !container) return; + + ctx = canvas.getContext('2d'); + + // Resize + const resize = () => { + canvas.width = container.clientWidth; + canvas.height = container.clientHeight; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.strokeStyle = currentPenColor; + ctx.lineWidth = currentLineWidth; + ctx.globalCompositeOperation = currentMode; }; + resize(); + window.addEventListener('resize', resize); + + // Init Size UI & Cursor + updateSizeBtnUI(); + updateCursorStyle(); + + // Cursor Logic + const cursor = document.getElementById('tool-cursor'); + + canvas.addEventListener('mouseenter', () => cursor.classList.remove('hidden')); + canvas.addEventListener('mouseleave', () => cursor.classList.add('hidden')); + canvas.addEventListener('mousemove', (e) => { + const { x, y } = getPos(e); + cursor.style.left = `${x}px`; + cursor.style.top = `${y}px`; + }); - window.closeComparison = () => { - document.getElementById('comparison-modal').classList.add('hidden'); - clearCanvas(); - }; + // Drawing Events + const start = (e) => { + isDrawing = true; + ctx.beginPath(); - // --- Phase 3 & 6: Annotation Tools --- - let canvas, ctx; - let isDrawing = false; - let currentPenColor = '#ef4444'; // Red default - let currentLineWidth = 3; - let currentMode = 'source-over'; // 'source-over' (Pen) or 'destination-out' (Eraser) - - window.setupCanvas = () => { - canvas = document.getElementById('annotation-canvas'); - const container = document.getElementById('comparison-container'); - if (!canvas || !container) return; - - ctx = canvas.getContext('2d'); - - // Resize - const resize = () => { - canvas.width = container.clientWidth; - canvas.height = container.clientHeight; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.strokeStyle = currentPenColor; - ctx.lineWidth = currentLineWidth; - ctx.globalCompositeOperation = currentMode; - }; - resize(); - window.addEventListener('resize', resize); - - // Init Size UI & Cursor - updateSizeBtnUI(); - updateCursorStyle(); - - // Cursor Logic - const cursor = document.getElementById('tool-cursor'); - - canvas.addEventListener('mouseenter', () => cursor.classList.remove('hidden')); - canvas.addEventListener('mouseleave', () => cursor.classList.add('hidden')); - canvas.addEventListener('mousemove', (e) => { - const { x, y } = getPos(e); - cursor.style.left = `${x}px`; - cursor.style.top = `${y}px`; - }); + // Re-apply settings (state might change) + ctx.globalCompositeOperation = currentMode; + ctx.strokeStyle = currentPenColor; + ctx.lineWidth = currentLineWidth; - // Drawing Events - const start = (e) => { - isDrawing = true; - ctx.beginPath(); - - // Re-apply settings (state might change) - ctx.globalCompositeOperation = currentMode; - ctx.strokeStyle = currentPenColor; - ctx.lineWidth = currentLineWidth; - - const { x, y } = getPos(e); - ctx.moveTo(x, y); - }; - - const move = (e) => { - if (!isDrawing) return; - const { x, y } = getPos(e); - ctx.lineTo(x, y); - ctx.stroke(); - }; - - const end = () => { - isDrawing = false; - }; - - canvas.onmousedown = start; - canvas.onmousemove = move; - canvas.onmouseup = end; - canvas.onmouseleave = end; - - // Touch support - canvas.ontouchstart = (e) => { e.preventDefault(); start(e.touches[0]); }; - canvas.ontouchmove = (e) => { e.preventDefault(); move(e.touches[0]); }; - canvas.ontouchend = (e) => { e.preventDefault(); end(); }; + const { x, y } = getPos(e); + ctx.moveTo(x, y); }; - function getPos(e) { - const rect = canvas.getBoundingClientRect(); - return { - x: e.clientX - rect.left, - y: e.clientY - rect.top - }; - } - - // Unified Tool Handler - window.setPenTool = (tool, color, btn) => { - // UI Update - document.querySelectorAll('.annotation-tool').forEach(b => { - b.classList.remove('ring-white'); - b.classList.add('ring-transparent'); - }); - btn.classList.remove('ring-transparent'); - btn.classList.add('ring-white'); - - if (tool === 'eraser') { - currentMode = 'destination-out'; - // Force larger eraser size (e.g., 3x current size or fixed large) - // We'll multiply current selected size by 4 for better UX - const multiplier = 4; - // Store original explicitly if needed, but currentLineWidth is global. - // We should dynamically adjust context lineWidth during draw, or just hack it here. - // Hack: If we change currentLineWidth here, the UI size buttons might look wrong. - // Better: Update cursor style only? No, actual draw needs it. - // Let's set a separate 'actualWidth' used in draw, OR just change currentLineWidth temporarily? - // Simpler: Just change it. When user clicks size button, it resets. - // But if user clicks Pen back? We need to restore. - // Let's rely on setPenTool being called with color. - // When "Pen" is clicked, we usually don't call setPenTool with a saved size... - // Actually, let's just use a large default for eraser, and keep currentLineWidth for Pen. - // We need to change how draw() uses the width. - // BUT, since we don't want to touch draw() deep inside: - // We will hijack currentLineWidth. - if (!window.savedPenWidth) window.savedPenWidth = currentLineWidth; - currentLineWidth = window.savedPenWidth * 4; - } else { - currentMode = 'source-over'; - currentPenColor = color; - // Restore pen width - if (window.savedPenWidth) { - currentLineWidth = window.savedPenWidth; - window.savedPenWidth = null; - } - } - updateCursorStyle(); + const move = (e) => { + if (!isDrawing) return; + const { x, y } = getPos(e); + ctx.lineTo(x, y); + ctx.stroke(); }; - // Size Handler - window.setPenSize = (size, btn) => { - currentLineWidth = size; - updateSizeBtnUI(); - updateCursorStyle(); + const end = () => { + isDrawing = false; }; - function updateCursorStyle() { - const cursor = document.getElementById('tool-cursor'); - if (!cursor) return; - - // Size - cursor.style.width = `${currentLineWidth}px`; - cursor.style.height = `${currentLineWidth}px`; + canvas.onmousedown = start; + canvas.onmousemove = move; + canvas.onmouseup = end; + canvas.onmouseleave = end; + + // Touch support + canvas.ontouchstart = (e) => { e.preventDefault(); start(e.touches[0]); }; + canvas.ontouchmove = (e) => { e.preventDefault(); move(e.touches[0]); }; + canvas.ontouchend = (e) => { e.preventDefault(); end(); }; +}; + +function getPos(e) { + const rect = canvas.getBoundingClientRect(); + return { + x: e.clientX - rect.left, + y: e.clientY - rect.top + }; +} - // Color - if (currentMode === 'destination-out') { - // Eraser: White solid - cursor.style.backgroundColor = 'white'; - cursor.style.borderColor = '#999'; - } else { - // Pen: Tool color - cursor.style.backgroundColor = currentPenColor; - cursor.style.borderColor = 'rgba(255,255,255,0.8)'; +// Unified Tool Handler +window.setPenTool = (tool, color, btn) => { + // UI Update + document.querySelectorAll('.annotation-tool').forEach(b => { + b.classList.remove('ring-white'); + b.classList.add('ring-transparent'); + }); + btn.classList.remove('ring-transparent'); + btn.classList.add('ring-white'); + + if (tool === 'eraser') { + currentMode = 'destination-out'; + // Force larger eraser size (e.g., 3x current size or fixed large) + // We'll multiply current selected size by 4 for better UX + const multiplier = 4; + // Store original explicitly if needed, but currentLineWidth is global. + // We should dynamically adjust context lineWidth during draw, or just hack it here. + // Hack: If we change currentLineWidth here, the UI size buttons might look wrong. + // Better: Update cursor style only? No, actual draw needs it. + // Let's set a separate 'actualWidth' used in draw, OR just change currentLineWidth temporarily? + // Simpler: Just change it. When user clicks size button, it resets. + // But if user clicks Pen back? We need to restore. + // Let's rely on setPenTool being called with color. + // When "Pen" is clicked, we usually don't call setPenTool with a saved size... + // Actually, let's just use a large default for eraser, and keep currentLineWidth for Pen. + // We need to change how draw() uses the width. + // BUT, since we don't want to touch draw() deep inside: + // We will hijack currentLineWidth. + if (!window.savedPenWidth) window.savedPenWidth = currentLineWidth; + currentLineWidth = window.savedPenWidth * 4; + } else { + currentMode = 'source-over'; + currentPenColor = color; + // Restore pen width + if (window.savedPenWidth) { + currentLineWidth = window.savedPenWidth; + window.savedPenWidth = null; } } - - function updateSizeBtnUI() { - document.querySelectorAll('.size-btn').forEach(b => { - if (parseInt(b.dataset.size) === currentLineWidth) { - b.classList.add('bg-gray-600', 'text-white'); - b.classList.remove('text-gray-400', 'hover:bg-gray-700'); - } else { - b.classList.remove('bg-gray-600', 'text-white'); - b.classList.add('text-gray-400', 'hover:bg-gray-700'); - } - }); + updateCursorStyle(); +}; + +// Size Handler +window.setPenSize = (size, btn) => { + currentLineWidth = size; + updateSizeBtnUI(); + updateCursorStyle(); +}; + +function updateCursorStyle() { + const cursor = document.getElementById('tool-cursor'); + if (!cursor) return; + + // Size + cursor.style.width = `${currentLineWidth}px`; + cursor.style.height = `${currentLineWidth}px`; + + // Color + if (currentMode === 'destination-out') { + // Eraser: White solid + cursor.style.backgroundColor = 'white'; + cursor.style.borderColor = '#999'; + } else { + // Pen: Tool color + cursor.style.backgroundColor = currentPenColor; + cursor.style.borderColor = 'rgba(255,255,255,0.8)'; } +} - window.clearCanvas = () => { - if (canvas && ctx) { - ctx.clearRect(0, 0, canvas.width, canvas.height); +function updateSizeBtnUI() { + document.querySelectorAll('.size-btn').forEach(b => { + if (parseInt(b.dataset.size) === currentLineWidth) { + b.classList.add('bg-gray-600', 'text-white'); + b.classList.remove('text-gray-400', 'hover:bg-gray-700'); + } else { + b.classList.remove('bg-gray-600', 'text-white'); + b.classList.add('text-gray-400', 'hover:bg-gray-700'); } - }; - - /** - * Renders the Transposed Heatmap (Rows=Challenges, Cols=Students) - */ - function renderTransposedHeatmap(students) { - const thead = document.getElementById('heatmap-header'); - const tbody = document.getElementById('heatmap-body'); + }); +} - if (students.length === 0) { - thead.innerHTML = '等待資料...'; - tbody.innerHTML = '尚無學員加入'; - return; - } +window.clearCanvas = () => { + if (canvas && ctx) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + } +}; + +/** + * Renders the Transposed Heatmap (Rows=Challenges, Cols=Students) + */ +function renderTransposedHeatmap(students) { + const thead = document.getElementById('heatmap-header'); + const tbody = document.getElementById('heatmap-body'); + + if (students.length === 0) { + thead.innerHTML = '等待資料...'; + tbody.innerHTML = '尚無學員加入'; + return; + } - // 1. Render Header (Students) - // Sticky Top for Header Row - // Sticky Left for the first cell ("Challenge/Student") - let headerHtml = ` + // 1. Render Header (Students) + // Sticky Top for Header Row + // Sticky Left for the first cell ("Challenge/Student") + let headerHtml = `
題目 @@ -2505,8 +2512,8 @@ export function setupInstructorEvents() { `; - students.forEach(student => { - headerHtml += ` + students.forEach(student => { + headerHtml += `
@@ -2525,59 +2532,59 @@ export function setupInstructorEvents() {
`; - }); - thead.innerHTML = headerHtml; + }); + thead.innerHTML = headerHtml; - // 2. Render Body (Challenges as Rows) - if (cachedChallenges.length === 0) { - tbody.innerHTML = '沒有題目資料'; - return; - } + // 2. Render Body (Challenges as Rows) + if (cachedChallenges.length === 0) { + tbody.innerHTML = '沒有題目資料'; + return; + } - tbody.innerHTML = cachedChallenges.map((c, index) => { - const colors = { beginner: 'cyan', intermediate: 'blue', advanced: 'purple' }; - const color = colors[c.level] || 'gray'; - - // Build Row Cells (One per student) - const rowCells = students.map(student => { - const p = student.progress?.[c.id]; - let statusClass = 'bg-gray-800/30 border-gray-800'; // Default - let content = ''; - let action = ''; - - if (p) { - if (p.status === 'completed') { - statusClass = 'bg-green-500/20 border-green-500/50 hover:bg-green-500/40 cursor-pointer shadow-[0_0_10px_rgba(34,197,94,0.1)]'; - content = '✅'; - // Action restored: Allow direct click to open detailed view - action = `onclick="window.showBroadcastModal('${student.id}', '${c.id}')" title="完成 - 點擊查看詳情"`; - } else if (p.status === 'started') { - // Check stuck - const startedAt = p.timestamp ? p.timestamp.toDate() : new Date(); - const now = new Date(); - const diffMins = (now - startedAt) / 1000 / 60; - - if (diffMins > 5) { - statusClass = 'bg-red-900/50 border-red-500 animate-pulse cursor-help'; - content = '🆘'; - } else { - statusClass = 'bg-blue-600/20 border-blue-500'; - content = '🔵'; - } + tbody.innerHTML = cachedChallenges.map((c, index) => { + const colors = { beginner: 'cyan', intermediate: 'blue', advanced: 'purple' }; + const color = colors[c.level] || 'gray'; + + // Build Row Cells (One per student) + const rowCells = students.map(student => { + const p = student.progress?.[c.id]; + let statusClass = 'bg-gray-800/30 border-gray-800'; // Default + let content = ''; + let action = ''; + + if (p) { + if (p.status === 'completed') { + statusClass = 'bg-green-500/20 border-green-500/50 hover:bg-green-500/40 cursor-pointer shadow-[0_0_10px_rgba(34,197,94,0.1)]'; + content = '✅'; + // Action restored: Allow direct click to open detailed view + action = `onclick="window.showBroadcastModal('${student.id}', '${c.id}')" title="完成 - 點擊查看詳情"`; + } else if (p.status === 'started') { + // Check stuck + const startedAt = p.timestamp ? p.timestamp.toDate() : new Date(); + const now = new Date(); + const diffMins = (now - startedAt) / 1000 / 60; + + if (diffMins > 5) { + statusClass = 'bg-red-900/50 border-red-500 animate-pulse cursor-help'; + content = '🆘'; + } else { + statusClass = 'bg-blue-600/20 border-blue-500'; + content = '🔵'; } } + } - return ` + return `
${content}
`; - }).join(''); + }).join(''); - // Row Header (Challenge Title) - return ` + // Row Header (Challenge Title) + return `
@@ -2597,45 +2604,45 @@ export function setupInstructorEvents() { ${rowCells} `; - }).join(''); - } - - // Global scope for HTML access - function showBroadcastModal(userId, challengeId) { - const student = currentStudents.find(s => s.id === userId); - if (!student) return; - - const p = student.progress?.[challengeId]; - if (!p) return; - - const challenge = cachedChallenges.find(c => c.id === challengeId); - const title = challenge ? challenge.title : 'Unknown Challenge'; // Fallback - - const modal = document.getElementById('broadcast-modal'); - const content = document.getElementById('broadcast-content'); - - document.getElementById('broadcast-avatar').textContent = student.nickname[0]; - document.getElementById('broadcast-author').textContent = student.nickname; - document.getElementById('broadcast-challenge').textContent = title; - // content is already just text, but let's be safe - const rawText = p.prompt || p.code || ''; - const isCode = !p.prompt && !!p.code; // If only code exists, treat as code - document.getElementById('broadcast-prompt').textContent = cleanText(rawText, isCode); - document.getElementById('broadcast-prompt').style.textAlign = isCode ? 'left' : 'left'; // Always left, but explicit + }).join(''); +} - // Store IDs for actions - modal.dataset.userId = userId; - modal.classList.remove('hidden'); - // Animation trigger - setTimeout(() => { - content.classList.remove('scale-95', 'opacity-0'); - content.classList.add('opacity-100', 'scale-100'); - }, 10); - } +// Global scope for HTML access +function showBroadcastModal(userId, challengeId) { + const student = currentStudents.find(s => s.id === userId); + if (!student) return; + + const p = student.progress?.[challengeId]; + if (!p) return; + + const challenge = cachedChallenges.find(c => c.id === challengeId); + const title = challenge ? challenge.title : 'Unknown Challenge'; // Fallback + + const modal = document.getElementById('broadcast-modal'); + const content = document.getElementById('broadcast-content'); + + document.getElementById('broadcast-avatar').textContent = student.nickname[0]; + document.getElementById('broadcast-author').textContent = student.nickname; + document.getElementById('broadcast-challenge').textContent = title; + // content is already just text, but let's be safe + const rawText = p.prompt || p.code || ''; + const isCode = !p.prompt && !!p.code; // If only code exists, treat as code + document.getElementById('broadcast-prompt').textContent = cleanText(rawText, isCode); + document.getElementById('broadcast-prompt').style.textAlign = isCode ? 'left' : 'left'; // Always left, but explicit + + // Store IDs for actions + modal.dataset.userId = userId; + modal.classList.remove('hidden'); + // Animation trigger + setTimeout(() => { + content.classList.remove('scale-95', 'opacity-0'); + content.classList.add('opacity-100', 'scale-100'); + }, 10); +} - // Bind to window - window.renderTransposedHeatmap = renderTransposedHeatmap; - window.showBroadcastModal = showBroadcastModal; +// Bind to window +window.renderTransposedHeatmap = renderTransposedHeatmap; +window.showBroadcastModal = showBroadcastModal; }