diff --git "a/src/views/InstructorView.js" "b/src/views/InstructorView.js" new file mode 100644--- /dev/null +++ "b/src/views/InstructorView.js" @@ -0,0 +1,1970 @@ +import { createRoom, subscribeToRoom, getChallenges, resetProgress, removeUser, cleanupOldRooms } from "../services/classroom.js"; +import { loginWithEmail, registerWithEmail, signOutUser, checkInstructorPermission, getInstructors, addInstructor, updateInstructor, removeInstructor } from "../services/auth.js"; +import { generateMonsterSVG, getNextMonster, MONSTER_DEFS } from "../utils/monsterUtils.js"; + +// Load html-to-image dynamically (Better support than html2canvas) +const script = document.createElement('script'); +script.src = "https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.js"; +document.head.appendChild(script); + +let cachedChallenges = []; +let currentStudents = []; + +export async function renderInstructorView() { + // Pre-fetch challenges for table headers + try { + cachedChallenges = await getChallenges(); + window.cachedChallenges = cachedChallenges; // Expose for dashboard + } catch (e) { + console.error("Failed header load", e); + } + + return ` +
+
+
🔒
+

講師登入

+

請輸入帳號密碼登入

+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+

如果是第一次使用,請先通知管理員新增您的 Email 到白名單,然後點選「註冊」設定密碼。

+ + +
+
+ + + + + + + + + + + + + + + + + +
+ +
+
+

+ 儀表板 v26.01.27 +

+ +
+ +
+
+
未開始
+
進行中
+
已完成
+
卡關 (>5m)
+
+ + + + +
+ + + + +
+
+
+ + + +
+ + +
+ + + +
+ `; +} + +export function setupInstructorEvents() { + // Utility for cleaning prompt indentation + // Utility for cleaning prompt indentation + // Utility for cleaning text for display + function cleanText(str, isCode = false) { + if (!str) return ''; + + // 1. Convert HTML entities if present (common in innerHTML injection flows) + str = str.replace(/ /g, ' '); + str = str.replace(//gi, '\n'); + + // 2. Normalize line endings + str = str.replace(/\r\n/g, '\n'); + + if (isCode) { + // Smart Dedent for Code (Preserve relative indent) + while (str.startsWith('\n')) str = str.slice(1); + str = str.trimEnd(); + + const lines = str.split('\n'); + if (lines.length === 0) return ''; + + let minIndent = null; + for (const line of lines) { + // Determine indent level + const content = line.replace(/^[\s\u3000\u00A0]+/, ''); + if (content.length === 0) continue; // Skip empty/whitespace-only lines + + const currentIndent = line.length - content.length; + if (minIndent === null || currentIndent < minIndent) { + minIndent = currentIndent; + } + } + if (minIndent === null) return str; + return lines.map(line => { + if (line.trim().length === 0) return ''; + return line.slice(minIndent); + }).join('\n'); + } else { + // Aggressive Flatten for Text Prompts (Force Left Align) + return str.split('\n') + .map(line => line.replace(/^[\s\u3000\u00A0]+/g, '')) // Regex remove ALL leading whitespace (Space, Tab, FullWidth, NBSP) + .filter((line, index, arr) => { + // Remove leading/trailing empty lines + if (line.trim() === '' && (index === 0 || index === arr.length - 1)) return false; + return true; + }) + .join('\n') + .trim(); + } + } + + let roomUnsubscribe = null; + let currentInstructor = null; + + // UI References + const authModal = document.getElementById('auth-modal'); + // New Auth Elements + const loginEmailInput = document.getElementById('login-email'); + const loginPasswordInput = document.getElementById('login-password'); + const loginBtn = document.getElementById('login-btn'); + const registerBtn = document.getElementById('register-btn'); + const authErrorMsg = document.getElementById('auth-error'); + + // Remove old authBtn reference if present + // const authBtn = document.getElementById('auth-btn'); + + const navAdminBtn = document.getElementById('nav-admin-btn'); + const navInstBtn = document.getElementById('nav-instructors-btn'); + const createBtn = document.getElementById('create-room-btn'); + + // Other UI + const roomInfo = document.getElementById('room-info'); + const createContainer = document.getElementById('create-room-container'); + const dashboardContent = document.getElementById('dashboard-content'); + const displayRoomCode = document.getElementById('display-room-code'); + const groupPhotoBtn = document.getElementById('group-photo-btn'); + const snapshotBtn = document.getElementById('snapshot-btn'); + let isSnapshotting = false; + + // Permission Check Helper + const checkPermissions = (instructor) => { + if (!instructor) return; + + currentInstructor = instructor; + + // 1. Create Room Permission + if (instructor.permissions?.includes('create_room')) { + createBtn.classList.remove('hidden', 'opacity-50', 'cursor-not-allowed'); + createBtn.disabled = false; + } else { + createBtn.classList.add('opacity-50', 'cursor-not-allowed'); + createBtn.disabled = true; + createBtn.title = "無此權限"; + } + + // 2. Add Question Permission (Admin Button) + if (instructor.permissions?.includes('add_question')) { + navAdminBtn.classList.remove('hidden'); + } else { + navAdminBtn.classList.add('hidden'); + } + + // 3. Manage Instructors Permission + if (instructor.permissions?.includes('manage_instructors')) { + navInstBtn.classList.remove('hidden'); + } else { + navInstBtn.classList.add('hidden'); + } + }; + + // Email/Password Auth Logic + if (loginBtn && registerBtn) { + // Login Handler + loginBtn.addEventListener('click', async () => { + const email = loginEmailInput.value; + const password = loginPasswordInput.value; + + if (!email || !password) { + authErrorMsg.textContent = "請輸入 Email 和密碼"; + authErrorMsg.classList.remove('hidden'); + return; + } + + try { + loginBtn.disabled = true; + loginBtn.classList.add('opacity-50'); + authErrorMsg.classList.add('hidden'); + + const user = await loginWithEmail(email, password); + const instructorData = await checkInstructorPermission(user); + + if (instructorData) { + authModal.classList.add('hidden'); + checkPermissions(instructorData); + localStorage.setItem('vibecoding_instructor_name', instructorData.name); + } else { + authErrorMsg.textContent = "未授權的帳號 (Unauthorized Account)"; + authErrorMsg.classList.remove('hidden'); + await signOutUser(); + } + } catch (error) { + console.error(error); + let msg = error.code || error.message; + if (error.code === 'auth/invalid-credential' || error.code === 'auth/wrong-password' || error.code === 'auth/user-not-found') { + msg = "帳號或密碼錯誤。"; + } + authErrorMsg.textContent = "登入失敗: " + msg; + authErrorMsg.classList.remove('hidden'); + } finally { + loginBtn.disabled = false; + loginBtn.classList.remove('opacity-50'); + } + }); + + // Forgot Password Handler + const forgotBtn = document.createElement('button'); + forgotBtn.textContent = "忘記密碼?"; + forgotBtn.className = "text-sm text-gray-400 hover:text-white mt-2 underline block mx-auto"; // Centered link + + // Insert after auth-error message or append to modal content? + // Appending to the parent of Login Button seems best, or just below it. + // The modal structure in index.html is needed to know exact placement. + // Assuming loginBtn is inside a flex column form. + loginBtn.parentNode.insertBefore(forgotBtn, loginBtn.nextSibling); + + forgotBtn.addEventListener('click', async () => { + const email = loginEmailInput.value; + if (!email) { + authErrorMsg.textContent = "請先在上欄輸入 Email 以發送重設信"; + authErrorMsg.classList.remove('hidden'); + return; + } + if (!confirm(`確定要發送重設密碼信件至 ${email} 嗎?`)) return; + + try { + // Dynamically import to avoid top-level dependency if not needed + const { resetPassword } = await import("../services/auth.js"); + await resetPassword(email); + alert(`已發送重設密碼信件至 ${email},請查收信箱並依照指示重設密碼。`); + authErrorMsg.classList.add('hidden'); + } catch (e) { + console.error(e); + let msg = e.message; + if (e.code === 'auth/user-not-found') msg = "找不到此帳號,請確認 Email 是否正確。"; + authErrorMsg.textContent = "發送失敗: " + msg; + authErrorMsg.classList.remove('hidden'); + } + }); + + // Register Handler + registerBtn.addEventListener('click', async () => { + const email = loginEmailInput.value; + const password = loginPasswordInput.value; + + if (!email || !password) { + authErrorMsg.textContent = "請輸入 Email 和密碼"; + authErrorMsg.classList.remove('hidden'); + return; + } + + try { + registerBtn.disabled = true; + registerBtn.classList.add('opacity-50'); + authErrorMsg.classList.add('hidden'); + + // Try to create auth account + const user = await registerWithEmail(email, password); + // Check if this email is in our whitelist + const instructorData = await checkInstructorPermission(user); + + if (instructorData) { + authModal.classList.add('hidden'); + checkPermissions(instructorData); + localStorage.setItem('vibecoding_instructor_name', instructorData.name); + alert("註冊成功!"); + } else { + // Auth created but not in whitelist + authErrorMsg.textContent = "註冊成功,但您的 Email 未被列入講師名單。請聯繫管理員。"; + authErrorMsg.classList.remove('hidden'); + await signOutUser(); + } + } catch (error) { + console.error(error); + let msg = error.code || error.message; + if (error.code === 'auth/email-already-in-use') { + msg = "此 Email 已被註冊,請直接登入。"; + } + authErrorMsg.textContent = "註冊失敗: " + msg; + authErrorMsg.classList.remove('hidden'); + } finally { + registerBtn.disabled = false; + registerBtn.classList.remove('opacity-50'); + } + }); + } + + // Create Room + if (createBtn) { + // Dashboard Update Logic + const updateDashboard = (data) => { + const users = Array.isArray(data) ? data : (data?.users ? Object.values(data.users) : []); + + // Update global state for modals/snapshot + currentStudents = users; + + // Use the full heatmap renderer + renderTransposedHeatmap(users); + } + + // 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'); + + // Trigger cleanup of old rooms + cleanupOldRooms(); + + displayRoomCode.textContent = roomCode; + + // 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'); + + // Start Subscription + subscribeToRoom(roomCode, (data) => { + updateDashboard(data); + }); + + } catch (e) { + console.error(e); + alert("無法建立教室: " + e.message); + } + }); + } + + + + // 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, (data) => { + // Check if updateDashboard is defined in scope + if (typeof updateDashboard === 'function') { + updateDashboard(data); + } else { + console.error("updateDashboard function missing"); + } + }); + } catch (e) { + alert("重回失敗: " + e.message); + } + }); + } + + // Leave Room + const leaveBtn = document.getElementById('leave-room-btn'); + if (leaveBtn) { + leaveBtn.addEventListener('click', () => { + const roomInfo = document.getElementById('room-info'); + const createContainer = document.getElementById('create-room-container'); + const dashboardContent = document.getElementById('dashboard-content'); + const displayRoomCode = document.getElementById('display-room-code'); + + localStorage.removeItem('vibecoding_room_code'); + localStorage.removeItem('vibecoding_is_host'); + + displayRoomCode.textContent = ''; + roomInfo.classList.add('hidden'); + dashboardContent.classList.add('hidden'); + createContainer.classList.remove('hidden'); + + // Unsubscribe logic if needed, usually auto-handled by new subscription or page reload + window.location.reload(); + }); + } + + // Nav to Admin + if (navAdminBtn) { + navAdminBtn.addEventListener('click', () => { + localStorage.setItem('vibecoding_admin_referer', 'instructor'); + window.location.hash = '#admin'; + }); + } + + // Handle Instructor Management + navInstBtn.addEventListener('click', async () => { + const modal = document.getElementById('instructor-modal'); + const listBody = document.getElementById('instructor-list-body'); + + // Load list + const instructors = await getInstructors(); + listBody.innerHTML = instructors.map(inst => ` + + ${inst.name} + ${inst.email} + + ${inst.permissions?.map(p => { + const map = { create_room: '開房', add_question: '題目', manage_instructors: '管理' }; + return `${map[p] || p}`; + }).join('')} + + + ${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(); + + if (!email || !name) return alert("請輸入完整資料"); + + const perms = []; + if (document.getElementById('perm-room').checked) perms.push('create_room'); + if (document.getElementById('perm-q').checked) perms.push('add_question'); + if (document.getElementById('perm-inst').checked) perms.push('manage_instructors'); + + try { + await addInstructor(email, name, perms); + alert("新增成功"); + navInstBtn.click(); // Reload list + document.getElementById('new-inst-email').value = ''; + document.getElementById('new-inst-name').value = ''; + } catch (e) { + alert("新增失敗: " + e.message); + } + }); + } + + + // Global helper for remove (hacky but works for simple onclick) + window.removeInst = async (email) => { + if (confirm(`確定移除 ${email}?`)) { + try { + await removeInstructor(email); + navInstBtn.click(); // Reload + } catch (e) { + alert(e.message); + } + } + }; + + // 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 + } + } 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("移除失敗"); + } + } + }; + + + // 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); + }); + + // 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(); + + } 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'); + + // 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)'; + + 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); + + // 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')} `; + + watermark.innerHTML = ` + + ${dateStr} VibeCoding 怪獸成長營 + + `; + 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 = ` +
+
+ + Instructor + + +
+ 👑 + +
+
+ `; + 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); + } + } 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 + + // 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); + + // 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); + } + + // 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; + + 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'; + + 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; + + card.innerHTML = ` + +
+
${monster.name.split(' ')[1] || monster.name}
+
+ Lv.${totalCompleted + 1} +
+ + ${totalLikes} +
+
+
+ + +
+
+ ${generateMonsterSVG(monster)} +
+
+ + +
+
${s.nickname}
+
+ `; + relativeContainer.appendChild(card); + + // Enable Drag & Drop + setupDraggable(card, relativeContainer); + }); + } + + modal.classList.remove('hidden'); + }); + + // Helper: Drag & Drop Logic + function setupDraggable(el, container) { + let isDragging = false; + let startX, startY, initialLeft, initialTop; + + el.addEventListener('mousedown', (e) => { + isDragging = true; + startX = e.clientX; + startY = e.clientY; + + // Disable transition during drag for responsiveness + el.style.transition = 'none'; + el.style.zIndex = 100; // Bring to front + + // Convert current computed position to fixed pixels if relying on calc + const rect = el.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + // Calculate position relative to container + // The current transform is translate(-50%, -50%). + // We want to set left/top such that the center remains under the mouse offset, + // but for simplicity, let's just use current offsetLeft/Top if possible, + // OR robustly recalculate from rects. + + // Current center point relative to container: + const centerX = rect.left - containerRect.left + rect.width / 2; + const centerY = rect.top - containerRect.top + rect.height / 2; + + // Set explicit pixel values replacing calc() + el.style.left = `${centerX}px`; + el.style.top = `${centerY}px`; + + initialLeft = centerX; + initialTop = centerY; + }); + + window.addEventListener('mousemove', (e) => { + if (!isDragging) return; + e.preventDefault(); + + const dx = e.clientX - startX; + const dy = e.clientY - startY; + + el.style.left = `${initialLeft + dx}px`; + el.style.top = `${initialTop + dy}px`; + }); + + 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 = ` + @keyframes float { + + 0 %, 100 % { transform: translateY(0) scale(1); } + 50% {transform: translateY(-5px) scale(1.02); } +} +} + `; + 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) + + 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(); + + // 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; + } + + // 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 = '學員 / 關卡'; + + // State Clear + sessionStorage.removeItem('vibecoding_instructor_in_room'); + localStorage.removeItem('vibecoding_instructor_room'); + } + }); + + // Modal Events + window.showBroadcastModal = (userId, challengeId) => { + const modal = document.getElementById('broadcast-modal'); + const content = document.getElementById('broadcast-content'); + + // Find Data + const student = currentStudents.find(s => s.id === userId); + if (!student) return alert('找不到學員資料'); + + const p = student.progress ? student.progress[challengeId] : null; + if (!p) return alert('找不到該作品資料'); + + const challenge = cachedChallenges.find(c => c.id === challengeId); + const title = challenge ? challenge.title : '未知題目'; + + // Populate UI + document.getElementById('broadcast-avatar').textContent = student.nickname[0] || '?'; + document.getElementById('broadcast-author').textContent = student.nickname; + document.getElementById('broadcast-challenge').textContent = title; + document.getElementById('broadcast-prompt').textContent = p.prompt || '(無內容)'; + + // Store IDs for Actions (Reject/BroadcastAll) + modal.dataset.userId = userId; + modal.dataset.challengeId = challengeId; + + // Show + modal.classList.remove('hidden'); + setTimeout(() => { + content.classList.remove('scale-95', 'opacity-0'); + content.classList.add('opacity-100', 'scale-100'); + }, 10); + }; + + window.closeBroadcast = () => { + const modal = document.getElementById('broadcast-modal'); + const content = document.getElementById('broadcast-content'); + content.classList.remove('opacity-100', 'scale-100'); + content.classList.add('scale-95', 'opacity-0'); + setTimeout(() => modal.classList.add('hidden'), 300); + }; + + window.openStage = (prompt, author) => { + document.getElementById('broadcast-content').classList.add('hidden'); + const stage = document.getElementById('stage-view'); + stage.classList.remove('hidden'); + document.getElementById('stage-prompt').textContent = cleanText(prompt || ''); + document.getElementById('stage-author').textContent = author; + }; + + window.closeStage = () => { + document.getElementById('stage-view').classList.add('hidden'); + document.getElementById('broadcast-content').classList.remove('hidden'); + }; + + document.getElementById('btn-show-stage').addEventListener('click', () => { + const prompt = document.getElementById('broadcast-prompt').textContent; + const author = document.getElementById('broadcast-author').textContent; + window.openStage(prompt, author); + }); + + // Reject Logic + document.getElementById('btn-reject-task').addEventListener('click', async () => { + if (!confirm('確定要退回此題目讓學員重做嗎?')) return; + + // We need student ID (userId) and Challenge ID. + // Currently showBroadcastModal only receives nickname, title, prompt. + // We need to attach data-userid and data-challengeid to the modal. + const modal = document.getElementById('broadcast-modal'); + const userId = modal.dataset.userId; + const challengeId = modal.dataset.challengeId; + const roomCode = localStorage.getItem('vibecoding_instructor_room'); + + if (userId && challengeId && roomCode) { + try { + await resetProgress(userId, roomCode, challengeId); + // Close modal + window.closeBroadcast(); + } catch (e) { + console.error(e); + alert('退回失敗'); + } + } + }); + // 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'); + + 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; + } + + 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}

+ + +
+ +
${cleanText(p.prompt)}
+ + +
+ ${p.time} +
+ +
+
+ `; + 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("退回失敗"); + } + } + } + }; + + 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(); + }; + + 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); + }); + } + + 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}

+
+ + +
${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(); }; + }; + + 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(); + }; + + // 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)'; + } + } + + 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'); + } + }); + } + + 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-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 ` + +
+ ${content} +
+ + `; + }).join(''); + + // Row Header (Challenge Title) + return ` + + +
+
+ ${c.level} + +
+ + +
+ + ${rowCells} + + `; + }).join(''); + } + + // Global scope for HTML access + // Global scope for HTML access + window.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.dataset.challengeId = challengeId; + + modal.classList.remove('hidden'); + // Animation trigger + setTimeout(() => { + content.classList.remove('scale-95', 'opacity-0'); + content.classList.add('opacity-100', 'scale-100'); + }, 10); + }; +}