import { createRoom, subscribeToRoom, getChallenges, resetProgress, removeUser } from "../services/classroom.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(); } 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'); // 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("移除失敗"); } } }; // 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 sessionStorage.setItem('vibecoding_instructor_auth', 'true'); } 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 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); } 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); }); // Gallery Logic document.getElementById('btn-open-gallery').addEventListener('click', () => { window.open('monster_preview.html', '_blank'); }); // Logout Logic document.getElementById('logout-btn').addEventListener('click', () => { if (confirm('確定要登出講師模式嗎? (將會回到首頁)')) { sessionStorage.removeItem('vibecoding_instructor_auth'); sessionStorage.removeItem('vibecoding_instructor_in_room'); sessionStorage.removeItem('vibecoding_admin_referer'); // We can optionally clear room history or keep it // localStorage.removeItem('vibecoding_instructor_room'); // Clear hash to trigger main router back to Landing or default window.location.hash = ''; window.location.reload(); } }); 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; } }); // Check Previous Session 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 let roomUnsubscribe = null; 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.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('退回失敗'); } } }); // 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} 的所有作品`; container.innerHTML = ''; modal.classList.remove('hidden'); // Collect Prompts let prompts = []; 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, 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, 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'); card.className = 'bg-gray-800 rounded-xl p-4 border border-gray-700 hover:border-cyan-500 transition-colors flex flex-col h-64'; card.innerHTML = `

${p.title}

${p.prompt}
${p.time}
`; container.appendChild(card); }); }; // 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() : '' }); } }); openComparisonView(dataToCompare); }); } window.openComparisonView = (items) => { const modal = document.getElementById('comparison-modal'); const grid = document.getElementById('comparison-grid'); // Setup Grid Columns let colClass = 'grid-cols-1'; if (items.length === 2) colClass = 'grid-cols-2'; if (items.length === 3) colClass = 'grid-cols-3'; grid.className = `absolute inset-0 grid ${colClass} gap-0 divide-x divide-gray-700`; grid.innerHTML = ''; items.forEach(item => { const col = document.createElement('div'); col.className = 'flex flex-col h-full bg-gray-900 p-6'; col.innerHTML = `

${item.author}

${item.title}

${item.prompt}
`; grid.appendChild(col); }); 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: Annotation Tools --- let canvas, ctx; let isDrawing = false; let currentPenColor = '#ef4444'; // Red default 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 = 3; }; resize(); window.addEventListener('resize', resize); // Drawing Events const start = (e) => { isDrawing = true; ctx.beginPath(); 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 }; } window.setPenColor = (color, btn) => { currentPenColor = color; if (ctx) ctx.strokeStyle = color; // 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'); }; window.clearCanvas = () => { if (canvas && ctx) { ctx.clearRect(0, 0, canvas.width, canvas.height); } }; } /** * Renders the Transposed Heatmap (Rows=Challenges, Cols=Students) */ function renderTransposedHeatmap(students) { const thead = document.getElementById('heatmap-header'); const tbody = document.getElementById('heatmap-body'); if (students.length === 0) { thead.innerHTML = '等待資料...'; tbody.innerHTML = '尚無學員加入'; return; } // 1. Render Header (Students) // Sticky Top for Header Row // Sticky Left for the first cell ("Challenge/Student") let headerHtml = `
題目 學員 (${students.length})
`; students.forEach(student => { headerHtml += `
${student.nickname[0]}
`; }); thead.innerHTML = headerHtml; // 2. Render Body (Challenges as Rows) if (cachedChallenges.length === 0) { tbody.innerHTML = '沒有題目資料'; return; } tbody.innerHTML = cachedChallenges.map((c, index) => { const colors = { beginner: 'cyan', intermediate: 'blue', advanced: 'purple' }; const color = colors[c.level] || 'gray'; // Build Row Cells (One per student) const rowCells = students.map(student => { const p = student.progress?.[c.id]; let statusClass = 'bg-gray-800/30 border-gray-800'; // Default let content = ''; let action = ''; if (p) { if (p.status === 'completed') { statusClass = 'bg-green-500/20 border-green-500/50 hover:bg-green-500/40 cursor-pointer shadow-[0_0_10px_rgba(34,197,94,0.1)]'; content = '✅'; // Pass only IDs, lookup logic is in the modal function now to avoid escaping issues action = `onclick = "window.showBroadcastModal('${student.id}', '${c.id}')"`; } 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 document.getElementById('broadcast-prompt').textContent = p.prompt || p.code || ''; // robust fallback // 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); };