import { createRoom, subscribeToRoom, getChallenges, resetProgress, removeUser } from "../services/classroom.js"; import { generateMonsterSVG, getNextMonster } 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(); } catch (e) { console.error("Failed header load", e); } return `

🔒 講師身分驗證

儀表板 v26.01.27

未開始
進行中
已完成
卡關 (>5m)
`; } export function setupInstructorEvents() { // Auth Logic const authBtn = document.getElementById('auth-btn'); const pwdInput = document.getElementById('instructor-password'); const authModal = document.getElementById('auth-modal'); // Default password check const checkPassword = async () => { const { verifyInstructorPassword } = await import("../services/classroom.js"); authBtn.textContent = "驗證中..."; authBtn.disabled = true; try { const isValid = await verifyInstructorPassword(pwdInput.value); if (isValid) { authModal.classList.add('hidden'); // Store session to avoid re-login on reload (Optional, for now just per session) } else { alert('密碼錯誤'); pwdInput.value = ''; } } catch (e) { console.error(e); alert("驗證出錯"); } finally { authBtn.textContent = "確認進入"; authBtn.disabled = false; } }; authBtn.addEventListener('click', checkPassword); pwdInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') checkPassword(); }); const createBtn = document.getElementById('create-room-btn'); 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 navAdminBtn = document.getElementById('nav-admin-btn'); const groupPhotoBtn = document.getElementById('group-photo-btn'); const snapshotBtn = document.getElementById('snapshot-btn'); let isSnapshotting = false; // 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 monster = getNextMonster(s.monster_stage || 0, totalLikes, total, s.monster_id); // Scatter Logic: Radial Distribution with Jitter // Min radius increased to verify clearance around label const minR = 220; const maxR = 350; // Angle: Evenly distributed + Jitter let baseAngle = (index / total) * 2 * Math.PI; const angleJitter = (Math.random() - 0.5) * 0.5; let finalAngle = baseAngle + angleJitter; // Collision Avoidance for Bottom Label (approx 90 deg / PI/2) // Expanded exclusion zone for better label clearance (60 to 120 degrees) const deg = finalAngle * (180 / Math.PI) % 360; const normalizedDeg = deg < 0 ? deg + 360 : deg; // 1. Avoid Instructor Label (Bottom Center: 60-120 deg) if (normalizedDeg > 60 && normalizedDeg < 120) { // Push angle strictly out of the zone const distTo60 = Math.abs(normalizedDeg - 60); const distTo120 = Math.abs(normalizedDeg - 120); if (distTo60 < distTo120) { finalAngle = (60 - 15) * (Math.PI / 180); // Move to 45 deg } else { finalAngle = (120 + 15) * (Math.PI / 180); // Move to 135 deg } } // 2. Recalculate degree after shift const finalDeg = finalAngle * (180 / Math.PI) % 360; const normFinalDeg = finalDeg < 0 ? finalDeg + 360 : finalDeg; // Radius: Random within range let radius = minR + Math.random() * (maxR - minR); // 3. Avoid Watermark (Bottom Right: 0-60 deg) // If in this sector, pull them in closer to center to avoid the corner watermark if (normFinalDeg >= 0 && normFinalDeg < 60) { radius = minR + Math.random() * 40; // Max radius restricted to minR + 40 (approx 260px) } // 4. Extra space for bottom area (outside watermark/label zones) // If 120-150 deg (Bottom Left), can go further out if (normFinalDeg > 120 && normFinalDeg < 150) { radius += 40; } 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'; 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.${(s.monster_stage || 0) + 1}
${totalLikes}
${generateMonsterSVG(monster)}
${s.nickname}
`; relativeContainer.appendChild(card); }); } modal.classList.remove('hidden'); }); // 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); } navAdminBtn.addEventListener('click', () => { // Save current room to return later const currentRoom = localStorage.getItem('vibecoding_instructor_room'); localStorage.setItem('vibecoding_admin_referer', 'instructor'); // track entry source window.location.hash = 'admin'; }); // Auto-fill code const savedRoomCode = localStorage.getItem('vibecoding_instructor_room'); if (savedRoomCode) { document.getElementById('rejoin-room-code').value = savedRoomCode; } const rejoinBtn = document.getElementById('rejoin-room-btn'); rejoinBtn.addEventListener('click', () => { const code = document.getElementById('rejoin-room-code').value.trim(); if (!code) return alert('請輸入教室代碼'); enterRoom(code); }); createBtn.addEventListener('click', async () => { try { createBtn.disabled = true; createBtn.textContent = "..."; const roomCode = await createRoom(); enterRoom(roomCode); } catch (error) { console.error(error); alert("建立失敗"); createBtn.disabled = false; } }); function enterRoom(roomCode) { createContainer.classList.add('hidden'); roomInfo.classList.remove('hidden'); dashboardContent.classList.remove('hidden'); displayRoomCode.textContent = roomCode; localStorage.setItem('vibecoding_instructor_room', roomCode); // Subscribe to updates subscribeToRoom(roomCode, (students) => { currentStudents = students; renderTransposedHeatmap(students); }); } // Modal Events 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 = 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('退回失敗'); } } }); } /** * 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]}
${student.nickname}
`; }); thead.innerHTML = headerHtml; // 2. Render Body (Challenges as Rows) if (cachedChallenges.length === 0) { tbody.innerHTML = '沒有題目資料'; return; } tbody.innerHTML = cachedChallenges.map((c, index) => { const colors = { beginner: 'cyan', intermediate: 'blue', advanced: 'purple' }; const color = colors[c.level] || 'gray'; // Build Row Cells (One per student) const rowCells = students.map(student => { const p = student.progress?.[c.id]; let statusClass = 'bg-gray-800/30 border-gray-800'; // Default let content = ''; let action = ''; if (p) { if (p.status === 'completed') { statusClass = 'bg-green-500/20 border-green-500/50 hover:bg-green-500/40 cursor-pointer shadow-[0_0_10px_rgba(34,197,94,0.1)]'; content = '✅'; const safePrompt = p.prompt.replace(/"/g, '"').replace(/'/g, "\\'"); action = `onclick="window.showBroadcastModal('${student.id}', '${c.id}', '${student.nickname}', '${c.title}', '${safePrompt}')"`; } 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} ${index + 1}. ${c.title}
${rowCells} `; }).join(''); } // Global scope for HTML access window.showBroadcastModal = (userId, challengeId, nickname, title, prompt) => { const modal = document.getElementById('broadcast-modal'); const content = document.getElementById('broadcast-content'); document.getElementById('broadcast-avatar').textContent = nickname[0]; document.getElementById('broadcast-author').textContent = nickname; document.getElementById('broadcast-challenge').textContent = title; document.getElementById('broadcast-prompt').textContent = prompt; // 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); };