diff --git "a/src/views/InstructorView.js" "b/src/views/InstructorView.js" --- "a/src/views/InstructorView.js" +++ "b/src/views/InstructorView.js" @@ -758,521 +758,486 @@ export function setupInstructorEvents() { } // Create Room - if (createBtn) { - // Dashboard Update Logic moved to top scope - - // Auto-Check Auth on Load - (async () => { - 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 signed in, check if instructor permission - try { - 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 in localStorage - const savedRoom = localStorage.getItem('vibecoding_room_code'); - if (savedRoom && !document.getElementById('dashboard-content').classList.contains('hidden')) { - // Already in dashboard, logic handled elsewhere or manual refresh needed - } else if (savedRoom) { - // Restore session logic if needed - // For now, let user click "Rejoin" or "Create". - // But wait, the user said "back button returns to login page". - // With this auth check, auth-modal will hide automatically. - } - } - } catch (e) { - console.error("Auth check failed:", e); - } - } - }); - })(); - // Create Room - 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'); + // Create Room + 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'); - await createRoom(roomCode, currentInstructor ? currentInstructor.name : 'Unknown'); + await createRoom(roomCode, currentInstructor ? currentInstructor.name : 'Unknown'); - // Trigger cleanup of old rooms - cleanupOldRooms(); + // Trigger cleanup of old rooms + cleanupOldRooms(); - displayRoomCode.textContent = roomCode; + displayRoomCode.textContent = roomCode; - // Store in LocalStorage - localStorage.setItem('vibecoding_room_code', roomCode); - localStorage.setItem('vibecoding_is_host', 'true'); + // Store in LocalStorage + localStorage.setItem('vibecoding_room_code', roomCode); + localStorage.setItem('vibecoding_is_host', 'true'); - // UI Updates - createContainer.classList.add('hidden'); - roomInfo.classList.remove('hidden'); - dashboardContent.classList.remove('hidden'); + // UI Updates + createContainer.classList.add('hidden'); + roomInfo.classList.remove('hidden'); + dashboardContent.classList.remove('hidden'); - // Start Subscription - subscribeToRoom(roomCode, (data) => { - updateDashboard(data); - }); + // Start Subscription + subscribeToRoom(roomCode, (data) => { + updateDashboard(data); + }); - } catch (e) { - console.error(e); - alert("無法建立教室: " + e.message); - } - }); - } + } catch (e) { + console.error(e); + alert("無法建立教室: " + e.message); + } + }); + } - // AI Settings Logic - if (setupAiBtn) { - setupAiBtn.addEventListener('click', () => { - const key = localStorage.getItem('vibecoding_gemini_key') || ''; - const studentEnabled = localStorage.getItem('vibecoding_student_ai_enabled') === 'true'; + // AI Settings Logic + if (setupAiBtn) { + setupAiBtn.addEventListener('click', () => { + const key = localStorage.getItem('vibecoding_gemini_key') || ''; + const studentEnabled = localStorage.getItem('vibecoding_student_ai_enabled') === 'true'; - if (geminiKeyInput) geminiKeyInput.value = key; - if (toggleStudentAi) toggleStudentAi.checked = studentEnabled; + if (geminiKeyInput) geminiKeyInput.value = key; + if (toggleStudentAi) toggleStudentAi.checked = studentEnabled; - if (aiSettingsModal) aiSettingsModal.classList.remove('hidden'); - }); - } + if (aiSettingsModal) aiSettingsModal.classList.remove('hidden'); + }); + } - if (saveAiSettingsBtn) { - saveAiSettingsBtn.addEventListener('click', async () => { - const key = geminiKeyInput ? geminiKeyInput.value.trim() : ''; - const studentEnabled = toggleStudentAi ? toggleStudentAi.checked : false; + if (saveAiSettingsBtn) { + saveAiSettingsBtn.addEventListener('click', async () => { + const key = geminiKeyInput ? geminiKeyInput.value.trim() : ''; + const studentEnabled = toggleStudentAi ? toggleStudentAi.checked : false; - if (key) { - localStorage.setItem('vibecoding_gemini_key', key); - } else { - localStorage.removeItem('vibecoding_gemini_key'); - } + if (key) { + localStorage.setItem('vibecoding_gemini_key', key); + } else { + localStorage.removeItem('vibecoding_gemini_key'); + } - localStorage.setItem('vibecoding_student_ai_enabled', studentEnabled); + localStorage.setItem('vibecoding_student_ai_enabled', studentEnabled); - // Update Firestore if in a room - const roomCode = localStorage.getItem('vibecoding_instructor_room'); + // Update Firestore if in a room + const roomCode = localStorage.getItem('vibecoding_instructor_room'); - 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 (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("AI 啟動失敗: " + e.message); - } - } else { - geminiEnabled = false; - alert("AI 設定已儲存 (停用)"); + // 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 (aiSettingsModal) aiSettingsModal.classList.add('hidden'); - }); - } + 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("請輸入代碼"); + // 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'); - - // 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'); - - subscribeToRoom(inputCode, async (data) => { - // Check if updateDashboard is defined in scope - // Auto-Restore Room View if exists - if (localStorage.getItem('vibecoding_gemini_key')) { - 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'; - // We need to store this in Firestore for students to see? - // Yes, 'settings/global' or room specific. - // For simplicity, let's assume 'settings/instructor_config' or just trust this is Local-only "AI Analysis" tool if not persisted. - // Wait, user wants Student AI to be enabled by this toggle. - // So we MUST persist this state to Firestore so StudentView can read it. - if (studentEnabled) { - // Update Firestore (assuming we have a 'settings' doc or room doc) - // Ideally we update the ROOM doc to say 'ai_enabled: true'. - // Finding current room... - const code = localStorage.getItem('vibecoding_room_code'); - if (code) { - const { updateDoc, doc, db } = await import("../services/classroom.js"); // We need raw Firestore access or a helper - // Let's simplify: Helper in classroom.js "setRoomSettings(code, { aiEnabled: true })" - // I'll add the Firestore update logic directly here for speed. - 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); - }); - } + 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'); + + subscribeToRoom(inputCode, async (data) => { + // Check if updateDashboard is defined in scope + // Auto-Restore Room View if exists + if (localStorage.getItem('vibecoding_gemini_key')) { + 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'; + // We need to store this in Firestore for students to see? + // Yes, 'settings/global' or room specific. + // For simplicity, let's assume 'settings/instructor_config' or just trust this is Local-only "AI Analysis" tool if not persisted. + // Wait, user wants Student AI to be enabled by this toggle. + // So we MUST persist this state to Firestore so StudentView can read it. + if (studentEnabled) { + // Update Firestore (assuming we have a 'settings' doc or room doc) + // Ideally we update the ROOM doc to say 'ai_enabled: true'. + // Finding current room... + const code = localStorage.getItem('vibecoding_room_code'); + if (code) { + const { updateDoc, doc, db } = await import("../services/classroom.js"); // We need raw Firestore access or a helper + // Let's simplify: Helper in classroom.js "setRoomSettings(code, { aiEnabled: true })" + // I'll add the Firestore update logic directly here for speed. + 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); + }); } } } + } - // 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(); - }); - } + // 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'); - // Nav to Admin - if (navAdminBtn) { - navAdminBtn.addEventListener('click', () => { - localStorage.setItem('vibecoding_admin_referer', 'instructor'); - window.location.hash = '#admin'; - }); - } + localStorage.removeItem('vibecoding_room_code'); + localStorage.removeItem('vibecoding_is_host'); + + displayRoomCode.textContent = ''; + roomInfo.classList.add('hidden'); + dashboardContent.classList.add('hidden'); + createContainer.classList.remove('hidden'); - // Handle Instructor Management - navInstBtn.addEventListener('click', async () => { - const modal = document.getElementById('instructor-modal'); - const listBody = document.getElementById('instructor-list-body'); + // Unsubscribe logic if needed, usually auto-handled by new subscription or page reload + window.location.reload(); + }); + } - // Load list - const instructors = await getInstructors(); - listBody.innerHTML = instructors.map(inst => ` + // 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'); - }); - - // 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(); + modal.classList.remove('hidden'); + }); - if (!email || !name) return alert("請輸入完整資料"); + // 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(); - 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) return alert("請輸入完整資料"); - 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); - } - }); - } + 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'); - - // 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); - } + 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); } - }; + }); + } - // 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); - }); - } - } 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 + // 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); + }); } - } 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'); + } catch (e) { + 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("移除失敗"); - } + // 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("移除失敗"); } - }; + } + }; - // 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); - }); + // 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 = `
@@ -1291,98 +1256,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}
@@ -1407,77 +1372,77 @@ export function setupInstructorEvents() {
${s.nickname}
`; - relativeContainer.appendChild(card); + relativeContainer.appendChild(card); - // Enable Drag & Drop - setupDraggable(card, relativeContainer); - }); - } + // Enable Drag & Drop + setupDraggable(card, relativeContainer); + }); + } - modal.classList.remove('hidden'); - }); + modal.classList.remove('hidden'); + }); - // Helper: Drag & Drop Logic - function setupDraggable(el, container) { - let isDragging = false; - let startX, startY, initialLeft, initialTop; + // 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; + 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 + // 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(); + // 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. + // 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; + // 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`; + // Set explicit pixel values replacing calc() + el.style.left = `${centerX}px`; + el.style.top = `${centerY}px`; - initialLeft = centerX; - initialTop = centerY; - }); + initialLeft = centerX; + initialTop = centerY; + }); - window.addEventListener('mousemove', (e) => { - if (!isDragging) return; - e.preventDefault(); + window.addEventListener('mousemove', (e) => { + if (!isDragging) return; + e.preventDefault(); - const dx = e.clientX - startX; - const dy = e.clientY - startY; + const dx = e.clientX - startX; + const dy = e.clientY - startY; - el.style.left = `${initialLeft + dx}px`; - el.style.top = `${initialTop + dy}px`; - }); + el.style.left = `${initialLeft + dx}px`; + el.style.top = `${initialTop + dy}px`; + }); - 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) - } - }); - } + 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) + } + }); + } - // Add float animation style if not exists - if (!document.getElementById('anim-float')) { - const style = document.createElement('style'); - style.id = 'anim-float'; - style.innerHTML = ` + // 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); } @@ -1485,248 +1450,248 @@ export function setupInstructorEvents() { } } `; - document.head.appendChild(style); - } + document.head.appendChild(style); + } - // Gallery Logic - document.getElementById('btn-open-gallery').addEventListener('click', () => { - window.open('monster_preview.html', '_blank'); - }); + // 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(); - } - }); + // 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(); + } + }); - // Check Previous Session (Handled by onAuthStateChanged now) - // if (sessionStorage.getItem('vibecoding_instructor_auth') === 'true') { - // authModal.classList.add('hidden'); - // } + // Check Previous Session (Handled by onAuthStateChanged now) + // if (sessionStorage.getItem('vibecoding_instructor_auth') === 'true') { + // authModal.classList.add('hidden'); + // } - // Check Active Room State - const activeRoom = sessionStorage.getItem('vibecoding_instructor_in_room'); - if (activeRoom === 'true' && savedRoomCode) { - enterRoom(savedRoomCode); - } + // Check Active Room State + const activeRoom = sessionStorage.getItem('vibecoding_instructor_in_room'); + if (activeRoom === 'true' && savedRoomCode) { + enterRoom(savedRoomCode); + } - // Module-level variable to track subscription (Moved to top) + // Module-level variable to track subscription (Moved to top) - 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'); + 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'); - // Unsubscribe previous if any - if (roomUnsubscribe) roomUnsubscribe(); + // Unsubscribe previous if any + if (roomUnsubscribe) roomUnsubscribe(); - // Subscribe to updates - roomUnsubscribe = subscribeToRoom(roomCode, (students) => { - currentStudents = students; - renderTransposedHeatmap(students); - }); - } + // Subscribe to updates + roomUnsubscribe = subscribeToRoom(roomCode, (students) => { + currentStudents = students; + renderTransposedHeatmap(students); + }); + } - // Leave Room Logic - document.getElementById('leave-room-btn').addEventListener('click', () => { - if (confirm('確定要離開目前教室嗎?(不會刪除教室資料,僅回到選擇介面)')) { - // Unsubscribe - if (roomUnsubscribe) { - roomUnsubscribe(); - roomUnsubscribe = null; - } + // Leave Room Logic + document.getElementById('leave-room-btn').addEventListener('click', () => { + if (confirm('確定要離開目前教室嗎?(不會刪除教室資料,僅回到選擇介面)')) { + // Unsubscribe + if (roomUnsubscribe) { + roomUnsubscribe(); + roomUnsubscribe = null; + } - // 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 + // 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 - // Clear Data Display - document.getElementById('heatmap-body').innerHTML = '等待資料載入...'; - document.getElementById('heatmap-header').innerHTML = '學員 / 關卡'; + // Clear Data Display + document.getElementById('heatmap-body').innerHTML = '等待資料載入...'; + document.getElementById('heatmap-header').innerHTML = '學員 / 關卡'; - // State Clear - sessionStorage.removeItem('vibecoding_instructor_in_room'); - localStorage.removeItem('vibecoding_instructor_room'); - } - }); + // 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); - }; + // 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); + }); - 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); - }; + // Reject Logic + document.getElementById('btn-reject-task').addEventListener('click', async () => { + if (!confirm('確定要退回此題目讓學員重做嗎?')) return; - 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; - }; + // 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'); - window.closeStage = () => { - document.getElementById('stage-view').classList.add('hidden'); - document.getElementById('broadcast-content').classList.remove('hidden'); - }; + console.log('Reject attempt:', { userId, challengeId, roomCode }); - 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); - }); + if (!userId || !challengeId) { + alert('找不到學員或題目資料,請重新開啟作品'); + return; + } + if (!roomCode) { + alert('未連接到教室,請先加入教室'); + return; + } - // Reject Logic - document.getElementById('btn-reject-task').addEventListener('click', async () => { - if (!confirm('確定要退回此題目讓學員重做嗎?')) 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; + } - // 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'); + titleEl.textContent = type === 'student' ? `${title} 的所有提示詞` : `題目:${title} 的所有作品`; - console.log('Reject attempt:', { userId, challengeId, roomCode }); + // Reset Anonymous Toggle in List View + const anonCheck = document.getElementById('list-anonymous-toggle'); + if (anonCheck) anonCheck.checked = false; - if (!userId || !challengeId) { - alert('找不到學員或題目資料,請重新開啟作品'); - return; - } - if (!roomCode) { - alert('未連接到教室,請先加入教室'); - return; - } + container.innerHTML = ''; + modal.classList.remove('hidden'); - 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; - } + // Collect Prompts + let prompts = []; + // Fix: Reset selection when opening new list to prevent cross-contamination + selectedPrompts = []; + updateCompareButton(); - titleEl.textContent = type === 'student' ? `${title} 的所有提示詞` : `題目:${title} 的所有作品`; - - // Reset Anonymous Toggle in List View - const anonCheck = document.getElementById('list-anonymous-toggle'); - if (anonCheck) anonCheck.checked = false; - - container.innerHTML = ''; - modal.classList.remove('hidden'); - - // 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}

@@ -1747,193 +1712,193 @@ export function setupInstructorEvents() {
`; - container.appendChild(card); - }); - }; + container.appendChild(card); + }); + }; - // Helper Actions - window.confirmReset = async (userId, challengeId, title) => { - if (confirm(`確定要退回 ${title} 嗎?此動作將清除學員目前的進度。`)) { - const roomCode = localStorage.getItem('vibecoding_instructor_room'); - if (userId && challengeId && roomCode) { - try { - const { resetProgress } = await import("../services/classroom.js"); - 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("退回失敗"); - } + // Helper Actions + window.confirmReset = async (userId, challengeId, title) => { + if (confirm(`確定要退回 ${title} 嗎?此動作將清除學員目前的進度。`)) { + const roomCode = localStorage.getItem('vibecoding_instructor_room'); + if (userId && challengeId && roomCode) { + try { + const { resetProgress } = await import("../services/classroom.js"); + 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 - - 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); - } - updateCompareButton(); - }; + window.broadcastPrompt = (userId, challengeId) => { + window.showBroadcastModal(userId, challengeId); + }; - function updateCompareButton() { - const btn = document.getElementById('btn-compare-prompts'); - if (!btn) return; + // Selection Logic + let selectedPrompts = []; // Stores IDs - const count = selectedPrompts.length; - const span = btn.querySelector('span'); - if (span) span.textContent = `🔍 比較已選項目 (${count}/3)`; + window.handlePromptSelection = (checkbox) => { + const id = checkbox.dataset.id; - 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'); + 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() : '' - }); - } - }); - - const isAnon = document.getElementById('list-anonymous-toggle')?.checked || false; - openComparisonView(dataToCompare, isAnon); - }); + 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); + + 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'); + } - // Get currently viewed challenge ID from title text or context - // We are inside openPromptList('challenge', id, title) context usually? - // Actually, openPromptList sets the modal context. We need to know which challenge we are viewing. - // We can look at the first rendered card's data-id (studentId_challengeId) - // OR save current viewing challenge ID in a global var. - // Let's infer from the first card for now or add a global 'currentChallengeId'. - // Better: Update openPromptList to store `currentViewingChallengeId`. + aiAnalyzeBtn.addEventListener('click', async () => { + if (currentStudents.length === 0) return alert("無學生資料"); - if (!window.currentViewingChallengeId) return alert("請先開啟某個題目的作品列表"); + // Get currently viewed challenge ID from title text or context + // We are inside openPromptList('challenge', id, title) context usually? + // Actually, openPromptList sets the modal context. We need to know which challenge we are viewing. + // We can look at the first rendered card's data-id (studentId_challengeId) + // OR save current viewing challenge ID in a global var. + // Let's infer from the first card for now or add a global 'currentChallengeId'. + // Better: Update openPromptList to store `currentViewingChallengeId`. - aiAnalyzeBtn.innerHTML = '🤖 分析中...'; - aiAnalyzeBtn.disabled = true; + if (!window.currentViewingChallengeId) return alert("請先開啟某個題目的作品列表"); - try { - // Collect submissions for this challenge - const submissions = []; - currentStudents.forEach(s => { - const p = s.progress?.[window.currentViewingChallengeId]; - if (p && p.status === 'completed' && p.prompt) { - submissions.push({ - userId: s.id, - prompt: p.prompt - }); - } - }); + aiAnalyzeBtn.innerHTML = '🤖 分析中...'; + aiAnalyzeBtn.disabled = true; + + try { + // Collect submissions for this challenge + const submissions = []; + currentStudents.forEach(s => { + const p = s.progress?.[window.currentViewingChallengeId]; + if (p && p.status === 'completed' && p.prompt) { + submissions.push({ + userId: s.id, + prompt: p.prompt + }); + } + }); - if (submissions.length === 0) throw new Error("無有效作品"); - - // Get Challenge Description - const challenge = cachedChallenges.find(c => c.id === window.currentViewingChallengeId); - const desc = challenge ? challenge.description : ''; - - // Dynamic Import - const { initGemini, evaluatePrompts } = await import("../services/gemini.js"); - // Ensure AI is initialized with the stored key - const geminiKey = localStorage.getItem('vibecoding_gemini_key'); - await initGemini(geminiKey); - const results = await evaluatePrompts(submissions, desc); - - // Apply Results to UI (Badges) - // Results: { rough: [ids], brave: [ids], ... } - - // Map categories to badges - const BadgeMap = { - rough: { icon: '💎', color: 'text-blue-400', label: '原石' }, - precise: { icon: '🎯', color: 'text-green-400', label: '精確' }, - gentle: { icon: '🤝', color: 'text-pink-400', label: '有禮' }, - creative: { icon: '🦄', color: 'text-purple-400', label: '創意' }, - spam: { icon: '🗑️', color: 'text-gray-500', label: '無效' }, - parrot: { icon: '🦜', color: 'text-yellow-400', label: '鸚鵡' }, - cheater: { icon: '⚠️', color: 'text-red-500', label: '疑似作弊' } - }; - - // Iterate cards in DOM - const container = document.getElementById('prompt-list-container'); - const cards = container.querySelectorAll('.group'); // Cards have 'group' class - - // Reset previous badges - document.querySelectorAll('.ai-badge').forEach(e => e.remove()); - - // Build summary for display - let summaryHtml = '
'; - const categoryOrder = ['creative', 'precise', 'rough', 'gentle', 'parrot', 'spam']; - - // Get student names for display - const getStudentName = (userId) => { - const student = currentStudents.find(s => s.id === userId); - return student?.nickname || student?.displayName || userId.slice(0, 6); - }; - - categoryOrder.forEach(cat => { - const idsRaw = results[cat]; - if (!idsRaw || idsRaw.length === 0) return; - - // Clean IDs (remove "ID_" prefix if present) - const ids = idsRaw.map(id => id.replace(/^ID_/, '')); - - const badge = BadgeMap[cat]; - if (!badge) return; - - const names = ids.map(id => getStudentName(id)).join(', '); - summaryHtml += ` + if (submissions.length === 0) throw new Error("無有效作品"); + + // Get Challenge Description + const challenge = cachedChallenges.find(c => c.id === window.currentViewingChallengeId); + const desc = challenge ? challenge.description : ''; + + // Dynamic Import + const { initGemini, evaluatePrompts } = await import("../services/gemini.js"); + // Ensure AI is initialized with the stored key + const geminiKey = localStorage.getItem('vibecoding_gemini_key'); + await initGemini(geminiKey); + const results = await evaluatePrompts(submissions, desc); + + // Apply Results to UI (Badges) + // Results: { rough: [ids], brave: [ids], ... } + + // Map categories to badges + const BadgeMap = { + rough: { icon: '💎', color: 'text-blue-400', label: '原石' }, + precise: { icon: '🎯', color: 'text-green-400', label: '精確' }, + gentle: { icon: '🤝', color: 'text-pink-400', label: '有禮' }, + creative: { icon: '🦄', color: 'text-purple-400', label: '創意' }, + spam: { icon: '🗑️', color: 'text-gray-500', label: '無效' }, + parrot: { icon: '🦜', color: 'text-yellow-400', label: '鸚鵡' }, + cheater: { icon: '⚠️', color: 'text-red-500', label: '疑似作弊' } + }; + + // Iterate cards in DOM + const container = document.getElementById('prompt-list-container'); + const cards = container.querySelectorAll('.group'); // Cards have 'group' class + + // Reset previous badges + document.querySelectorAll('.ai-badge').forEach(e => e.remove()); + + // Build summary for display + let summaryHtml = '
'; + const categoryOrder = ['creative', 'precise', 'rough', 'gentle', 'parrot', 'spam']; + + // Get student names for display + const getStudentName = (userId) => { + const student = currentStudents.find(s => s.id === userId); + return student?.nickname || student?.displayName || userId.slice(0, 6); + }; + + categoryOrder.forEach(cat => { + const idsRaw = results[cat]; + if (!idsRaw || idsRaw.length === 0) return; + + // Clean IDs (remove "ID_" prefix if present) + const ids = idsRaw.map(id => id.replace(/^ID_/, '')); + + const badge = BadgeMap[cat]; + if (!badge) return; + + const names = ids.map(id => getStudentName(id)).join(', '); + summaryHtml += `
${badge.icon} @@ -1943,51 +1908,51 @@ export function setupInstructorEvents() {
${names}
`; - }); - summaryHtml += '
'; - - // Apply badges to cards - cards.forEach(card => { - const checkbox = card.querySelector('input[type="checkbox"]'); - if (!checkbox) return; - - // data-id is "studentId_challengeId" - const fullId = checkbox.dataset.id; - const studentId = fullId.split('_')[0]; // Simple split - - // Find which category this student falls into - let matchedCategory = null; - for (const [cat, idsRaw] of Object.entries(results)) { - const ids = idsRaw?.map(id => id.replace(/^ID_/, '')) || []; - if (ids.includes(studentId)) { - matchedCategory = cat; - break; - } + }); + summaryHtml += '
'; + + // Apply badges to cards + cards.forEach(card => { + const checkbox = card.querySelector('input[type="checkbox"]'); + if (!checkbox) return; + + // data-id is "studentId_challengeId" + const fullId = checkbox.dataset.id; + const studentId = fullId.split('_')[0]; // Simple split + + // Find which category this student falls into + let matchedCategory = null; + for (const [cat, idsRaw] of Object.entries(results)) { + const ids = idsRaw?.map(id => id.replace(/^ID_/, '')) || []; + if (ids.includes(studentId)) { + matchedCategory = cat; + break; } + } - if (matchedCategory && BadgeMap[matchedCategory]) { - const badge = BadgeMap[matchedCategory]; - const badgeEl = document.createElement('div'); - badgeEl.className = `ai-badge absolute top-2 right-12 px-2 py-0.5 rounded border border-gray-600 bg-gray-900 ${badge.color} text-xs font-bold flex items-center shadow-lg transform scale-100 animate-pulse`; - badgeEl.innerHTML = `${badge.icon} ${badge.label}`; - card.style.position = 'relative'; // Ensure absolute positioning works - card.appendChild(badgeEl); - - // If spam/parrot, border red - if (matchedCategory === 'spam' || matchedCategory === 'parrot') { - card.classList.add('border-red-500'); - } + if (matchedCategory && BadgeMap[matchedCategory]) { + const badge = BadgeMap[matchedCategory]; + const badgeEl = document.createElement('div'); + badgeEl.className = `ai-badge absolute top-2 right-12 px-2 py-0.5 rounded border border-gray-600 bg-gray-900 ${badge.color} text-xs font-bold flex items-center shadow-lg transform scale-100 animate-pulse`; + badgeEl.innerHTML = `${badge.icon} ${badge.label}`; + card.style.position = 'relative'; // Ensure absolute positioning works + card.appendChild(badgeEl); + + // If spam/parrot, border red + if (matchedCategory === 'spam' || matchedCategory === 'parrot') { + card.classList.add('border-red-500'); } - }); + } + }); - // Show summary modal - const existingSummaryModal = document.getElementById('ai-summary-modal'); - if (existingSummaryModal) existingSummaryModal.remove(); + // Show summary modal + const existingSummaryModal = document.getElementById('ai-summary-modal'); + if (existingSummaryModal) existingSummaryModal.remove(); - const summaryModal = document.createElement('div'); - summaryModal.id = 'ai-summary-modal'; - summaryModal.className = 'fixed inset-0 bg-black/80 flex items-center justify-center z-[100] p-4'; - summaryModal.innerHTML = ` + const summaryModal = document.createElement('div'); + summaryModal.id = 'ai-summary-modal'; + summaryModal.className = 'fixed inset-0 bg-black/80 flex items-center justify-center z-[100] p-4'; + summaryModal.innerHTML = `

✨ AI 分析結果 @@ -2000,108 +1965,108 @@ export function setupInstructorEvents() {

`; - document.body.appendChild(summaryModal); - - document.getElementById('close-ai-summary').onclick = () => summaryModal.remove(); + document.body.appendChild(summaryModal); - } catch (e) { - console.error(e); - alert("分析失敗: " + e.message); - } finally { - aiAnalyzeBtn.innerHTML = '✨ AI 選粹'; - aiAnalyzeBtn.disabled = false; - } - }); - } + document.getElementById('close-ai-summary').onclick = () => summaryModal.remove(); - // Direct Heatmap AI Analysis Link - window.analyzeChallenge = (challengeId, challengeTitle) => { - if (!localStorage.getItem('vibecoding_gemini_key')) { - alert("請先設定 Gemini API Key"); - return; + } catch (e) { + console.error(e); + alert("分析失敗: " + e.message); + } finally { + aiAnalyzeBtn.innerHTML = '✨ AI 選粹'; + aiAnalyzeBtn.disabled = false; } - // 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'); - } + // 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}

@@ -2110,222 +2075,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 = `
題目 @@ -2334,8 +2299,8 @@ export function setupInstructorEvents() { `; - students.forEach(student => { - headerHtml += ` + students.forEach(student => { + headerHtml += `
@@ -2354,59 +2319,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-default shadow-[0_0_10px_rgba(34,197,94,0.1)]'; - content = '✅'; - // Action removed: Moved to prompt list view - action = `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-default shadow-[0_0_10px_rgba(34,197,94,0.1)]'; + content = '✅'; + // Action removed: Moved to prompt list view + action = `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 `
@@ -2426,51 +2391,51 @@ export function setupInstructorEvents() { ${rowCells} `; - }).join(''); - } + }).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 - - // 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; - }); - } catch (e) { - console.error(e); - alert("無法重新加入: " + e.message); - } - }); - } + // Bind to window + window.renderTransposedHeatmap = renderTransposedHeatmap; + window.showBroadcastModal = showBroadcastModal; + }); + } catch (e) { + console.error(e); + alert("無法重新加入: " + e.message); + } + }); } } +