diff --git "a/src/views/InstructorView.js" "b/src/views/InstructorView.js" --- "a/src/views/InstructorView.js" +++ "b/src/views/InstructorView.js" @@ -1276,283 +1276,281 @@ if (navInstBtn) { 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) { + // 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("Checking permissions for:", user.email); - const instructorData = await checkInstructorPermission(user); - console.log("Permission Result:", instructorData); - - if (instructorData) { - console.log("Hiding Modal and Setting Permissions..."); - if (authModal) 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'); + 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..."); + if (authModal) 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); + }); + } - // 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 + } + } catch (e) { + console.error("Permission Check Failed:", e); + if (authErrorMsg) { + authErrorMsg.textContent = "權限檢查失敗: " + e.message; + authErrorMsg.classList.remove('hidden'); + } } - } 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 + authModal.classList.remove('hidden'); } - } catch (e) { - console.error("Permission Check Failed:", e); - if (authErrorMsg) { - authErrorMsg.textContent = "權限檢查失敗: " + e.message; - authErrorMsg.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("移除失敗"); } } - } else { - authModal.classList.remove('hidden'); - } - }); -}); - -// Define Kick Function globally (robust against auth flow) -window.confirmKick = async (userId, nickname) => { - if (confirm(`確定要踢出 ${nickname} 嗎?此動作無法復原。`)) { - try { - const { removeUser } = await import("../services/classroom.js"); - await removeUser(userId); - // UI will update automatically via subscribeToRoom - } catch (e) { - console.error("Kick failed:", e); - alert("移除失敗"); - } - } -}; - + }; -const btnAddInst = document.getElementById('btn-add-inst'); -if (btnAddInst) { - btnAddInst.addEventListener('click', async () => { - const email = document.getElementById('new-inst-email').value.trim(); - const name = document.getElementById('new-inst-name').value.trim(); - const perms = []; - if (document.getElementById('perm-room').checked) perms.push('create_room'); - if (document.getElementById('perm-q').checked) perms.push('add_question'); - if (document.getElementById('perm-inst').checked) perms.push('manage_instructors'); - if (!email || !name) { - alert("請輸入 Email 和姓名"); - return; - } + const btnAddInst = document.getElementById('btn-add-inst'); + if (btnAddInst) { + btnAddInst.addEventListener('click', async () => { + const email = document.getElementById('new-inst-email').value.trim(); + const name = document.getElementById('new-inst-name').value.trim(); + const perms = []; + if (document.getElementById('perm-room').checked) perms.push('create_room'); + if (document.getElementById('perm-q').checked) perms.push('add_question'); + if (document.getElementById('perm-inst').checked) perms.push('manage_instructors'); - try { - await addInstructor(email, name, perms); - alert("新增成功"); - document.getElementById('new-inst-email').value = ''; - document.getElementById('new-inst-name').value = ''; - loadInstructorList(); - } catch (e) { - console.error(e); - alert("新增失敗: " + e.message); - } - }); -} + if (!email || !name) { + alert("請輸入 Email 和姓名"); + return; + } -window.removeInst = async (email) => { - if (confirm(`確定要移除 ${email} 嗎?`)) { - try { - await removeInstructor(email); - loadInstructorList(); - } catch (e) { - console.error(e); - alert("移除失敗"); - } - } -}; -// Snapshot Logic -if (snapshotBtn) { - 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); + try { + await addInstructor(email, name, perms); + alert("新增成功"); + document.getElementById('new-inst-email').value = ''; + document.getElementById('new-inst-name').value = ''; + loadInstructorList(); + } catch (e) { + console.error(e); + alert("新增失敗: " + e.message); + } }); - }); + } - 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); - }); + window.removeInst = async (email) => { + if (confirm(`確定要移除 ${email} 嗎?`)) { + try { + await removeInstructor(email); + loadInstructorList(); + } catch (e) { + console.error(e); + alert("移除失敗"); + } + } + }; + // Snapshot Logic + if (snapshotBtn) { + 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); + }); + }); - // 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, + 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); }); - // 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(); + // 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, + }); - } 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 - }); + // 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 + }); - // 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 = `
@@ -1571,98 +1569,98 @@ if (snapshotBtn) {
`; - relativeContainer.appendChild(instructorSection); + 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); + // 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; } - } 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 + 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); + } - // 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 + // --- 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 - const safeStartAngle = 135 * (Math.PI / 180); - const safeSpan = 270 * (Math.PI / 180); + // 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 - // Distribute evenly - // If only 1 student, put at top (270 deg / 4.71 rad) - let finalAngle; + const safeStartAngle = 135 * (Math.PI / 180); + const safeSpan = 270 * (Math.PI / 180); - if (total === 1) { - finalAngle = 270 * (Math.PI / 180); - } else { - const step = safeSpan / (total - 1); - finalAngle = safeStartAngle + (step * index); - } + // Distribute evenly + // If only 1 student, put at top (270 deg / 4.71 rad) + let finalAngle; - // 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 + 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 - // 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}
@@ -1687,77 +1685,77 @@ if (snapshotBtn) {
${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); } @@ -1765,248 +1763,248 @@ if (snapshotBtn) { } } `; - document.head.appendChild(style); - } - - // 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(); - } - }); - - // 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); - } - - // Module-level variable to track subscription (Moved to top) + document.head.appendChild(style); + } - 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'); + // Gallery Logic + document.getElementById('btn-open-gallery').addEventListener('click', () => { + window.open('monster_preview.html', '_blank'); + }); - // Unsubscribe previous if any - if (roomUnsubscribe) roomUnsubscribe(); + // 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(); + } + }); - // Subscribe to updates - roomUnsubscribe = subscribeToRoom(roomCode, (students) => { - currentStudents = students; - renderTransposedHeatmap(students); - }); - } + // Check Previous Session (Handled by onAuthStateChanged now) + // if (sessionStorage.getItem('vibecoding_instructor_auth') === 'true') { + // authModal.classList.add('hidden'); + // } - // Leave Room Logic - document.getElementById('leave-room-btn').addEventListener('click', () => { - if (confirm('確定要離開目前教室嗎?(不會刪除教室資料,僅回到選擇��面)')) { - // Unsubscribe - if (roomUnsubscribe) { - roomUnsubscribe(); - roomUnsubscribe = null; + // Check Active Room State + const activeRoom = sessionStorage.getItem('vibecoding_instructor_in_room'); + if (activeRoom === 'true' && savedRoomCode) { + enterRoom(savedRoomCode); } - // 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 + // Module-level variable to track subscription (Moved to top) - // Clear Data Display - document.getElementById('heatmap-body').innerHTML = '等待資料載入...'; - document.getElementById('heatmap-header').innerHTML = '學員 / 關卡'; + 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'); - // State Clear - sessionStorage.removeItem('vibecoding_instructor_in_room'); - localStorage.removeItem('vibecoding_instructor_room'); - } - }); + // Unsubscribe previous if any + if (roomUnsubscribe) roomUnsubscribe(); - // 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('找不到該作品資料'); + // Subscribe to updates + roomUnsubscribe = subscribeToRoom(roomCode, (students) => { + currentStudents = students; + renderTransposedHeatmap(students); + }); + } - const challenge = cachedChallenges.find(c => c.id === challengeId); - const title = challenge ? challenge.title : '未知題目'; + // Leave Room Logic + document.getElementById('leave-room-btn').addEventListener('click', () => { + if (confirm('確定要離開目前教室嗎?(不會刪除教室資料,僅回到選擇介面)')) { + // Unsubscribe + if (roomUnsubscribe) { + roomUnsubscribe(); + roomUnsubscribe = null; + } - // 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 || '(無內容)'; + // 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 - // Store IDs for Actions (Reject/BroadcastAll) - modal.dataset.userId = userId; - modal.dataset.challengeId = challengeId; + // Clear Data Display + document.getElementById('heatmap-body').innerHTML = '等待資料載入...'; + document.getElementById('heatmap-header').innerHTML = '學員 / 關卡'; - // Show - modal.classList.remove('hidden'); - setTimeout(() => { - content.classList.remove('scale-95', 'opacity-0'); - content.classList.add('opacity-100', 'scale-100'); - }, 10); - }; + // State Clear + sessionStorage.removeItem('vibecoding_instructor_in_room'); + localStorage.removeItem('vibecoding_instructor_room'); + } + }); - 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); - }; + // Modal Events + window.showBroadcastModal = (userId, challengeId) => { + const modal = document.getElementById('broadcast-modal'); + const content = document.getElementById('broadcast-content'); - 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; - }; + // Find Data + const student = currentStudents.find(s => s.id === userId); + if (!student) return alert('找不到學員資料'); - window.closeStage = () => { - document.getElementById('stage-view').classList.add('hidden'); - document.getElementById('broadcast-content').classList.remove('hidden'); - }; + const p = student.progress ? student.progress[challengeId] : null; + if (!p) return alert('找不到該作品資料'); - 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); - }); + const challenge = cachedChallenges.find(c => c.id === challengeId); + const title = challenge ? challenge.title : '未知題目'; - // Reject Logic - document.getElementById('btn-reject-task').addEventListener('click', async () => { - if (!confirm('確定要退回此題目讓學員重做嗎?')) return; + // 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 || '(無內容)'; - // 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'); + // Store IDs for Actions (Reject/BroadcastAll) + modal.dataset.userId = userId; + modal.dataset.challengeId = challengeId; - console.log('Reject attempt:', { userId, challengeId, roomCode }); + // 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); + }); - 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(); - - 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() : '' - }); + 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() : '' + }; + }); } + } 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}

@@ -2027,313 +2025,313 @@ if (snapshotBtn) {
`; - container.appendChild(card); - }); - }; - - // Helper Actions - window.confirmReset = async (userId, challengeId, title) => { - if (confirm(`確定要退回 ${title} 嗎?此��作將清除學員目前的進度。`)) { - // Unified top-level import - const roomCode = localStorage.getItem('vibecoding_room_code') || localStorage.getItem('vibecoding_instructor_room'); // Fallback - - if (userId && challengeId) { - try { - alert("正在執行退回 (列表模式)..."); - // Use top-level import directly - await resetProgress(userId, roomCode, challengeId); - // Refresh current list if open? (It will stay open but might not update immediately if realtime check isn't hooked to modal content. But subscriptions update `currentStudents`. We might need to refresh list) - // For now, simple alert or auto-close - alert("已退回"); - // close modal to refresh data context - document.getElementById('prompt-list-modal').classList.add('hidden'); - } catch (e) { - console.error(e); - alert("退回失敗"); + container.appendChild(card); + }); + }; + + // Helper Actions + window.confirmReset = async (userId, challengeId, title) => { + if (confirm(`確定要退回 ${title} 嗎?此動作將清除學員目前的進度。`)) { + // Unified top-level import + const roomCode = localStorage.getItem('vibecoding_room_code') || localStorage.getItem('vibecoding_instructor_room'); // Fallback + + if (userId && challengeId) { + try { + alert("正在執行退回 (列表模式)..."); + // Use top-level import directly + await resetProgress(userId, roomCode, challengeId); + // Refresh current list if open? (It will stay open but might not update immediately if realtime check isn't hooked to modal content. But subscriptions update `currentStudents`. We might need to refresh list) + // For now, simple alert or auto-close + alert("已退回"); + // close modal to refresh data context + document.getElementById('prompt-list-modal').classList.add('hidden'); + } catch (e) { + console.error(e); + alert("退回失敗"); + } + } } - } - } - }; + }; - window.broadcastPrompt = (userId, challengeId) => { - window.showBroadcastModal(userId, challengeId); - }; - - // Selection Logic - let selectedPrompts = []; // Stores IDs - - 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'); - } - } - // 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() : '' - }); + if (checkbox.checked) { + if (selectedPrompts.length >= 3) { + checkbox.checked = false; + alert('最多只能選擇 3 個提示詞進行比較'); + return; + } + selectedPrompts.push(id); + } else { + selectedPrompts = selectedPrompts.filter(pid => pid !== id); } - }); - - 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; - // 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'); - } + const count = selectedPrompts.length; + const span = btn.querySelector('span'); + if (span) span.textContent = `🔍 比較已選項目 (${count}/3)`; - aiAnalyzeBtn.addEventListener('click', async () => { - if (currentStudents.length === 0) return alert("無學生資料"); - - // Robust Challenge ID Detection - let challengeId = window.currentViewingChallengeId; - - // Fallback 1: Try to infer from first card - if (!challengeId) { - const firstCard = document.getElementById('prompt-list-container')?.firstElementChild; - if (firstCard) { - const fullId = firstCard.querySelector('input[type="checkbox"]')?.dataset.id; - if (fullId && fullId.includes('_')) { - challengeId = fullId.split('_').pop(); - window.currentViewingChallengeId = challengeId; // Set it for future stability - } + if (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() : '' + }); + } + }); - if (!challengeId) return alert("無法確認目前所在的題目,請重新開啟列表"); - - // UI Loading State - aiAnalyzeBtn.innerHTML = '⏳ AI 分析中 (Batch)...'; - aiAnalyzeBtn.disabled = true; - aiAnalyzeBtn.classList.add('animate-pulse'); - - try { - const challenge = cachedChallenges.find(c => c.id === challengeId); - const challengeDesc = challenge ? challenge.description : "No Description"; - - // 1. Collect Valid Submissions for THIS challenge only - const submissions = []; - currentStudents.forEach(s => { - const p = s.progress?.[challengeId]; - if (p && p.status === 'completed' && p.prompt && p.prompt.length > 3) { - submissions.push({ - userId: s.id, - prompt: p.prompt, - nickname: s.nickname - }); - } + const isAnon = document.getElementById('list-anonymous-toggle')?.checked || false; + openComparisonView(dataToCompare, isAnon); }); + } - if (submissions.length === 0) throw new Error("沒有足足夠的有效回答可供分析"); - // 2. Call Batch API - const { initGemini, evaluatePrompts } = await import("../services/gemini.js"); + // AI Analysis Logic + const aiAnalyzeBtn = document.getElementById('btn-ai-analyze'); + if (aiAnalyzeBtn) { + // Show button if key exists if (localStorage.getItem('vibecoding_gemini_key')) { - await initGemini(localStorage.getItem('vibecoding_gemini_key')); + aiAnalyzeBtn.classList.remove('hidden'); } - const results = await evaluatePrompts(submissions, challengeId); - - // 3. Update UI with Badges - const badgeColors = { - "rough": "bg-gray-600 text-gray-200 border-gray-500", // 原石 - "precise": "bg-blue-600 text-blue-100 border-blue-400", // 精確 - "gentle": "bg-pink-600 text-pink-100 border-pink-400", // 有禮 - "creative": "bg-purple-600 text-purple-100 border-purple-400", // 創意 - "spam": "bg-red-900 text-red-200 border-red-700", // 無效 - "parrot": "bg-yellow-600 text-yellow-100 border-yellow-400" // 鸚鵡 - }; - const badgeLabels = { - "rough": "🗿 原石", - "precise": "🎯 精確", - "gentle": "💖 有禮", - "creative": "✨ 創意", - "spam": "🗑️ 無效", - "parrot": "🦜 鸚鵡" - }; + aiAnalyzeBtn.addEventListener('click', async () => { + if (currentStudents.length === 0) return alert("無學生資料"); - let count = 0; - Object.entries(results).forEach(([category, ids]) => { - ids.forEach(userId => { - // Find card - const checkbox = document.querySelector(`input[data-id="${userId}_${challengeId}"]`); - if (checkbox) { - const card = checkbox.closest('.group\\/card'); - if (card) { - const header = card.querySelector('h3')?.parentNode; - if (header) { - // Remove old badge - const oldBadge = card.querySelector('.ai-badge'); - if (oldBadge) oldBadge.remove(); - - const badge = document.createElement('span'); - badge.className = `ai-badge ml-2 text-xs px-2 py-0.5 rounded-full border ${badgeColors[category] || 'bg-gray-600'}`; - badge.textContent = badgeLabels[category] || category; - header.appendChild(badge); - count++; - } + // Robust Challenge ID Detection + let challengeId = window.currentViewingChallengeId; + + // Fallback 1: Try to infer from first card + if (!challengeId) { + const firstCard = document.getElementById('prompt-list-container')?.firstElementChild; + if (firstCard) { + const fullId = firstCard.querySelector('input[type="checkbox"]')?.dataset.id; + if (fullId && fullId.includes('_')) { + challengeId = fullId.split('_').pop(); + window.currentViewingChallengeId = challengeId; // Set it for future stability } } - }); - }); + } - aiAnalyzeBtn.innerHTML = `✅ 完成分析 (${count})`; - setTimeout(() => { - aiAnalyzeBtn.innerHTML = '✨ AI 選粹 (刷新)'; - aiAnalyzeBtn.disabled = false; - aiAnalyzeBtn.classList.remove('animate-pulse'); - }, 3000); + if (!challengeId) return alert("無法確認目前所在的題目,請重新開啟列表"); + // UI Loading State + aiAnalyzeBtn.innerHTML = '⏳ AI 分析中 (Batch)...'; + aiAnalyzeBtn.disabled = true; + aiAnalyzeBtn.classList.add('animate-pulse'); - } catch (e) { - console.error(e); - alert("分析失敗: " + e.message); - aiAnalyzeBtn.innerHTML = '❌ 重試'; - aiAnalyzeBtn.disabled = false; - aiAnalyzeBtn.classList.remove('animate-pulse'); - } - }); - } + try { + const challenge = cachedChallenges.find(c => c.id === challengeId); + const challengeDesc = challenge ? challenge.description : "No Description"; + + // 1. Collect Valid Submissions for THIS challenge only + const submissions = []; + currentStudents.forEach(s => { + const p = s.progress?.[challengeId]; + if (p && p.status === 'completed' && p.prompt && p.prompt.length > 3) { + submissions.push({ + userId: s.id, + prompt: p.prompt, + nickname: s.nickname + }); + } + }); + if (submissions.length === 0) throw new Error("沒有足足夠的有效回答可供分析"); - // 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. Call Batch API + const { initGemini, evaluatePrompts } = await import("../services/gemini.js"); + if (localStorage.getItem('vibecoding_gemini_key')) { + await initGemini(localStorage.getItem('vibecoding_gemini_key')); + } + const results = await evaluatePrompts(submissions, challengeId); + + // 3. Update UI with Badges + const badgeColors = { + "rough": "bg-gray-600 text-gray-200 border-gray-500", // 原石 + "precise": "bg-blue-600 text-blue-100 border-blue-400", // 精確 + "gentle": "bg-pink-600 text-pink-100 border-pink-400", // 有禮 + "creative": "bg-purple-600 text-purple-100 border-purple-400", // 創意 + "spam": "bg-red-900 text-red-200 border-red-700", // 無效 + "parrot": "bg-yellow-600 text-yellow-100 border-yellow-400" // 鸚鵡 + }; - // 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); - }; + const badgeLabels = { + "rough": "🗿 原石", + "precise": "🎯 精確", + "gentle": "💖 有禮", + "creative": "✨ 創意", + "spam": "🗑️ 無效", + "parrot": "🦜 鸚鵡" + }; - 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; - } - }); - }; + let count = 0; + Object.entries(results).forEach(([category, ids]) => { + ids.forEach(userId => { + // Find card + const checkbox = document.querySelector(`input[data-id="${userId}_${challengeId}"]`); + if (checkbox) { + const card = checkbox.closest('.group\\/card'); + if (card) { + const header = card.querySelector('h3')?.parentNode; + if (header) { + // Remove old badge + const oldBadge = card.querySelector('.ai-badge'); + if (oldBadge) oldBadge.remove(); + + const badge = document.createElement('span'); + badge.className = `ai-badge ml-2 text-xs px-2 py-0.5 rounded-full border ${badgeColors[category] || 'bg-gray-600'}`; + badge.textContent = badgeLabels[category] || category; + header.appendChild(badge); + count++; + } + } + } + }); + }); - window.openComparisonView = (items, initialAnonymous = false) => { - const modal = document.getElementById('comparison-modal'); - const grid = document.getElementById('comparison-grid'); + aiAnalyzeBtn.innerHTML = `✅ 完成分析 (${count})`; + setTimeout(() => { + aiAnalyzeBtn.innerHTML = '✨ AI 選粹 (刷新)'; + aiAnalyzeBtn.disabled = false; + aiAnalyzeBtn.classList.remove('animate-pulse'); + }, 3000); - // 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'); + } catch (e) { + console.error(e); + alert("分析失敗: " + e.message); + aiAnalyzeBtn.innerHTML = '❌ 重試'; + aiAnalyzeBtn.disabled = false; + aiAnalyzeBtn.classList.remove('animate-pulse'); + } + }); } - } - // 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 = ` + // 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. + } + + col.innerHTML = `

${displayAuthor}

${item.title}

@@ -2342,363 +2340,213 @@ if (snapshotBtn) {
${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'); - - // 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`; - }); - - // 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(); }; - }; + 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); + } + }); - function getPos(e) { - const rect = canvas.getBoundingClientRect(); - return { - x: e.clientX - rect.left, - y: e.clientY - rect.top - }; - } + 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`; + }); - // 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(); - }; + // Drawing Events + const start = (e) => { + isDrawing = true; + ctx.beginPath(); - // Size Handler - window.setPenSize = (size, btn) => { - currentLineWidth = size; - updateSizeBtnUI(); - updateCursorStyle(); - }; + // Re-apply settings (state might change) + ctx.globalCompositeOperation = currentMode; + ctx.strokeStyle = currentPenColor; + ctx.lineWidth = currentLineWidth; - function updateCursorStyle() { - const cursor = document.getElementById('tool-cursor'); - if (!cursor) return; + const { x, y } = getPos(e); + ctx.moveTo(x, y); + }; - // Size - cursor.style.width = `${currentLineWidth}px`; - cursor.style.height = `${currentLineWidth}px`; + const move = (e) => { + if (!isDrawing) return; + const { x, y } = getPos(e); + ctx.lineTo(x, y); + ctx.stroke(); + }; - // 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)'; - } - } + const end = () => { + isDrawing = false; + }; - 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'); + 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 + }; } - }); - } - - 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 = ` - -
- 題目 - 學員 (${students.length}) -
- - `; - students.forEach(student => { - headerHtml += ` - -
-
- ${student.nickname[0]} - -
-
-
- - -
-
- - `; - }); - thead.innerHTML = headerHtml; - - // 2. Render Body (Challenges as Rows) - if (cachedChallenges.length === 0) { - tbody.innerHTML = '沒有題目資料'; - return; - } - - tbody.innerHTML = cachedChallenges.map((c, index) => { - const colors = { beginner: 'cyan', intermediate: 'blue', advanced: 'purple' }; - const color = colors[c.level] || 'gray'; - - // Build Row Cells (One per student) - const rowCells = students.map(student => { - const p = student.progress?.[c.id]; - let statusClass = 'bg-gray-800/30 border-gray-800'; // Default - let content = ''; - let action = ''; - - if (p) { - if (p.status === 'completed') { - statusClass = 'bg-green-500/20 border-green-500/50 hover:bg-green-500/40 cursor-pointer shadow-[0_0_10px_rgba(34,197,94,0.1)]'; - content = '✅'; - // Action restored: Allow direct click to open detailed view - action = `onclick="window.showBroadcastModal('${student.id}', '${c.id}')" title="完成 - 點擊查看詳情"`; - } else if (p.status === 'started') { - // Check stuck - const startedAt = p.timestamp ? p.timestamp.toDate() : new Date(); - const now = new Date(); - const diffMins = (now - startedAt) / 1000 / 60; - - if (diffMins > 5) { - statusClass = 'bg-red-900/50 border-red-500 animate-pulse cursor-help'; - content = '🆘'; - } else { - statusClass = 'bg-blue-600/20 border-blue-500'; - content = '🔵'; - } + // 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(); + }; + + // 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)'; + } + } - return ` - -
- ${content} -
- - `; - }).join(''); - - // Row Header (Challenge Title) - return ` - - -
-
- ${c.level} - -
- - - -
- - ${rowCells} - - `; - }).join(''); - } - - // Global scope for HTML access - function showBroadcastModal(userId, challengeId) { - const student = currentStudents.find(s => s.id === userId); - if (!student) return; + 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'); + } + }); + } - const p = student.progress?.[challengeId]; - if (!p) return; + window.clearCanvas = () => { + if (canvas && ctx) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + } + }; - const challenge = cachedChallenges.find(c => c.id === challengeId); - const title = challenge ? challenge.title : 'Unknown Challenge'; // Fallback - const modal = document.getElementById('broadcast-modal'); - const content = document.getElementById('broadcast-content'); - document.getElementById('broadcast-avatar').textContent = student.nickname[0]; - document.getElementById('broadcast-author').textContent = student.nickname; - document.getElementById('broadcast-challenge').textContent = title; - // content is already just text, but let's be safe - const rawText = p.prompt || p.code || ''; - const isCode = !p.prompt && !!p.code; // If only code exists, treat as code - document.getElementById('broadcast-prompt').textContent = cleanText(rawText, isCode); - document.getElementById('broadcast-prompt').style.textAlign = isCode ? 'left' : 'left'; // Always left, but explicit - // Store IDs for actions - modal.dataset.userId = userId; - modal.classList.remove('hidden'); - // Animation trigger - setTimeout(() => { - content.classList.remove('scale-95', 'opacity-0'); - content.classList.add('opacity-100', 'scale-100'); - }, 10); + // Bind to window + window.renderTransposedHeatmap = renderTransposedHeatmap; + window.showBroadcastModal = showBroadcastModal; + } } - - - - // Bind to window - window.renderTransposedHeatmap = renderTransposedHeatmap; - window.showBroadcastModal = showBroadcastModal; }