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 // Create Room 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'); // Unified Entry Logic (Ensures Group Photo button is shown) console.log("Room created, calling enterRoom..."); enterRoom(roomCode); } 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); // Unified Entry Logic console.log("Rejoining room, calling enterRoom..."); enterRoom(inputCode); } 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); // Restore Room Session using the unified function // This ensures all UI elements (including Group Photo button) are shown correctly console.log("Calling enterRoom to restore session..."); enterRoom(savedRoomCode); } } 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 // 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); }); }); 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 if (groupPhotoBtn) { 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 // Module-level variable to track subscription (Moved to top) window.enterRoom = function (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_room_code', 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_room_code'); } }); // 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_room_code'); if (userId && challengeId && roomCode) { 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'); 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) => { console.log('🔵 confirmReset called'); console.log(' userId:', userId); console.log(' challengeId:', challengeId); console.log(' title:', title); console.log(' typeof resetProgress:', typeof resetProgress); if (confirm(`確定要退回 ${title} 嗎?此動作將清除學員目前的進度。`)) { console.log('✅ User confirmed reset'); const roomCode = localStorage.getItem('vibecoding_room_code'); console.log(' roomCode:', roomCode); if (userId && challengeId && roomCode) { console.log('✅ All parameters valid, calling resetProgress...'); try { // Use already imported resetProgress function await resetProgress(userId, roomCode, challengeId); console.log('✅ resetProgress completed successfully'); alert("已退回"); // close modal to refresh data context document.getElementById('prompt-list-modal').classList.add('hidden'); console.log('✅ Modal closed'); } catch (e) { console.error("❌ 退回失敗:", e); console.error("Error stack:", e.stack); alert("退回失敗: " + e.message); } } else { console.error('❌ Missing required parameters:'); console.error(' userId:', userId, '(valid:', !!userId, ')'); console.error(' challengeId:', challengeId, '(valid:', !!challengeId, ')'); console.error(' roomCode:', roomCode, '(valid:', !!roomCode, ')'); alert('缺少必要參數,無法執行退回操作'); } } else { console.log('❌ User cancelled reset'); } }; 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(''); } }