Spaces:
Running
Running
| import { createRoom, subscribeToRoom, getChallenges, resetProgress } from "../services/classroom.js"; | |
| let cachedChallenges = []; | |
| export async function renderInstructorView() { | |
| // Pre-fetch challenges for table headers | |
| try { | |
| cachedChallenges = await getChallenges(); | |
| } catch (e) { | |
| console.error("Failed header load", e); | |
| } | |
| return ` | |
| <div id="auth-modal" class="fixed inset-0 bg-black bg-opacity-90 backdrop-blur z-50 flex items-center justify-center"> | |
| <div class="bg-gray-800 p-8 rounded-xl border border-gray-600 shadow-2xl max-w-sm w-full"> | |
| <h2 class="text-xl font-bold text-center mb-6 text-white">🔒 講師身分驗證</h2> | |
| <input type="password" id="instructor-password" class="w-full bg-gray-900 border border-gray-700 rounded p-3 text-white text-center text-lg tracking-widest mb-4 focus:border-cyan-500 focus:outline-none" placeholder="輸入密碼"> | |
| <button id="auth-btn" class="w-full bg-purple-600 hover:bg-purple-500 text-white font-bold py-3 rounded-lg transition-colors">確認進入</button> | |
| </div> | |
| </div> | |
| <!-- Broadcast Modal (Hidden by default) --> | |
| <div id="broadcast-modal" class="fixed inset-0 bg-black/90 backdrop-blur z-50 hidden flex flex-col items-center justify-center p-8 transition-opacity duration-300"> | |
| <button onclick="closeBroadcast()" class="absolute top-6 right-6 text-gray-400 hover:text-white text-2xl">✕</button> | |
| <div id="broadcast-content" class="bg-gray-800 border border-gray-600 rounded-2xl p-8 max-w-4xl w-full text-center shadow-2xl transform transition-transform scale-95 opacity-0"> | |
| <div class="mb-4 flex flex-col items-center"> | |
| <div class="w-16 h-16 rounded-full bg-cyan-600 flex items-center justify-center text-3xl font-bold text-white mb-2" id="broadcast-avatar"> | |
| D | |
| </div> | |
| <h3 class="text-xl text-cyan-300 font-bold" id="broadcast-author">Dave</h3> | |
| <span class="text-gray-500 text-sm" id="broadcast-challenge">Challenge Name</span> | |
| </div> | |
| <div class="bg-black/30 rounded-xl p-6 mb-8 text-left overflow-auto max-h-[50vh]"> | |
| <pre class="text-green-400 font-mono text-lg whitespace-pre-wrap" id="broadcast-prompt">Loading...</pre> | |
| </div> | |
| <div class="flex justify-center space-x-4"> | |
| <button id="btn-show-stage" class="bg-blue-600 hover:bg-blue-500 text-white font-bold py-3 px-8 rounded-xl flex items-center space-x-2 text-lg"> | |
| <span>🖥️ 投放到大螢幕 (本機)</span> | |
| </button> | |
| <button id="btn-reject-task" class="bg-red-600 hover:bg-red-500 text-white font-bold py-3 px-8 rounded-xl flex items-center space-x-2 text-lg shadow-lg"> | |
| <span>🛑 退回重做 (Reject)</span> | |
| </button> | |
| <!-- Future Feature: Send to Students --> | |
| <!-- | |
| <button id="btn-broadcast-all" class="bg-purple-600 hover:bg-purple-500 text-white font-bold py-3 px-8 rounded-xl flex items-center space-x-2 text-lg opacity-50 cursor-not-allowed" title="此功能開發中"> | |
| <span>📡 推送給所有人</span> | |
| </button> | |
| --> | |
| </div> | |
| </div> | |
| <!-- Big Screen Mode (Initially hidden inside modal) --> | |
| <div id="stage-view" class="hidden absolute inset-0 bg-gray-900 flex flex-col items-center justify-center p-10"> | |
| <button onclick="closeStage()" class="absolute top-6 right-6 text-gray-500 hover:text-white text-4xl">✕</button> | |
| <h1 class="text-4xl font-bold text-cyan-400 mb-8" id="stage-title">優秀作品展示</h1> | |
| <div class="bg-black border-2 border-cyan-500/50 rounded-2xl p-10 max-w-6xl w-full shadow-[0_0_50px_rgba(6,182,212,0.2)]"> | |
| <pre class="text-3xl text-green-400 font-mono whitespace-pre-wrap leading-relaxed" id="stage-prompt">...</pre> | |
| </div> | |
| <div class="mt-8 text-2xl text-gray-400"> | |
| Author: <span class="text-white font-bold" id="stage-author">Dave</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="min-h-screen p-6 pb-20 bg-gray-900 text-white"> | |
| <!-- Header --> | |
| <header class="flex flex-col md:flex-row justify-between items-center mb-6 bg-gray-800 p-4 rounded-xl border border-gray-700 space-y-4 md:space-y-0 sticky top-0 z-30 shadow-lg"> | |
| <div class="flex items-center space-x-4"> | |
| <h1 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-600"> | |
| 儀表板 | |
| </h1> | |
| <div id="room-info" class="hidden flex items-center space-x-2 bg-black/30 px-3 py-1 rounded-lg border border-gray-700"> | |
| <span class="text-xs text-gray-500 uppercase">Room</span> | |
| <span id="display-room-code" class="text-xl font-mono font-bold text-cyan-400 tracking-widest"></span> | |
| </div> | |
| </div> | |
| <div class="flex space-x-3"> | |
| <div class="flex items-center space-x-2 text-xs text-gray-400 mr-4 border-r border-gray-700 pr-4"> | |
| <div class="flex items-center"><div class="w-3 h-3 bg-gray-700 rounded-sm mr-1"></div> 未開始</div> | |
| <div class="flex items-center"><div class="w-3 h-3 bg-blue-600 rounded-sm mr-1"></div> 進行中</div> | |
| <div class="flex items-center"><div class="w-3 h-3 bg-green-500 rounded-sm mr-1"></div> 已完成</div> | |
| <div class="flex items-center"><div class="w-3 h-3 bg-red-500 animate-pulse rounded-sm mr-1"></div> 卡關 (>5m)</div> | |
| </div> | |
| <button id="nav-admin-btn" class="bg-gray-700 hover:bg-gray-600 text-white font-bold py-2 px-4 rounded-lg transition-all border border-gray-600"> | |
| 管理題目 | |
| </button> | |
| <div id="create-room-container" class="flex items-center space-x-2"> | |
| <input type="text" id="rejoin-room-code" placeholder="代碼" class="bg-gray-900 border border-gray-700 text-white px-3 py-2 rounded-lg w-20 text-center focus:outline-none focus:border-cyan-500"> | |
| <button id="rejoin-room-btn" class="bg-gray-700 hover:bg-gray-600 text-white px-3 py-2 rounded-lg">重回</button> | |
| <button id="create-room-btn" class="bg-purple-600 hover:bg-purple-500 text-white font-bold px-4 py-2 rounded-lg shadow-lg">開房</button> | |
| </div> | |
| </div> | |
| </header> | |
| <!-- Heatmap Content --> | |
| <div id="dashboard-content" class="hidden overflow-x-auto pb-10"> | |
| <table class="w-full border-collapse"> | |
| <thead> | |
| <tr id="heatmap-header"> | |
| <th class="p-3 text-left sticky left-0 bg-gray-800 z-20 border-b border-gray-700 min-w-[150px]">學員 / 關卡</th> | |
| <!-- Challenges headers generated dynamically --> | |
| </tr> | |
| </thead> | |
| <tbody id="heatmap-body"> | |
| <!-- Rows generated dynamically --> | |
| <tr><td colspan="100" class="text-center py-10 text-gray-500">等待資料載入...</td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| 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'); | |
| 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) => { | |
| 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 = '<th class="p-4 text-left">等待資料...</th>'; | |
| tbody.innerHTML = '<tr><td class="p-10 text-center text-gray-500">尚無學員加入</td></tr>'; | |
| return; | |
| } | |
| // 1. Render Header (Students) | |
| // Sticky Top for Header Row | |
| // Sticky Left for the first cell ("Challenge/Student") | |
| let headerHtml = ` | |
| <th class="p-3 text-left sticky left-0 top-0 bg-gray-800 z-30 border-b border-gray-600 min-w-[200px] border-r border-gray-700 shadow-md"> | |
| <div class="flex justify-between items-end"> | |
| <span class="text-sm text-gray-400">題目</span> | |
| <span class="text-sm text-cyan-400">學員 (${students.length})</span> | |
| </div> | |
| </th> | |
| `; | |
| students.forEach(student => { | |
| headerHtml += ` | |
| <th class="p-2 text-center sticky top-0 bg-gray-800 z-20 border-b border-gray-700 min-w-[80px] group"> | |
| <div class="flex flex-col items-center space-y-2 py-2"> | |
| <div class="w-8 h-8 rounded-full bg-gradient-to-br from-gray-700 to-gray-600 flex items-center justify-center text-xs font-bold text-white uppercase border border-gray-500 shadow-sm relative"> | |
| ${student.nickname[0]} | |
| <!-- Online Indicator (Simulated) --> | |
| <div class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 border-2 border-gray-800 rounded-full"></div> | |
| </div> | |
| <span class="text-xs text-gray-300 font-medium truncate max-w-[80px] writing-vertical-lr" style="writing-mode: vertical-rl; text-orientation: mixed;"> | |
| ${student.nickname} | |
| </span> | |
| </div> | |
| </th> | |
| `; | |
| }); | |
| thead.innerHTML = headerHtml; | |
| // 2. Render Body (Challenges as Rows) | |
| if (cachedChallenges.length === 0) { | |
| tbody.innerHTML = '<tr><td colspan="100" class="text-center py-4">沒有題目資料</td></tr>'; | |
| 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 ` | |
| <td class="p-1 border border-gray-800/50 text-center align-middle h-14 hover:bg-white/5 transition-colors"> | |
| <div class="mx-auto w-10 h-10 rounded-lg border flex items-center justify-center ${statusClass} transition-all duration-300" ${action}> | |
| ${content} | |
| </div> | |
| </td> | |
| `; | |
| }).join(''); | |
| // Row Header (Challenge Title) | |
| return ` | |
| <tr class="hover:bg-gray-800/50 transition-colors"> | |
| <td class="p-3 sticky left-0 bg-gray-900 z-10 border-r border-b border-gray-700 shadow-md"> | |
| <div class="flex items-center justify-between"> | |
| <div class="flex flex-col"> | |
| <span class="text-xs text-${color}-400 font-bold uppercase tracking-wider mb-0.5">${c.level}</span> | |
| <span class="font-bold text-white text-sm truncate max-w-[180px]" title="${c.title}">${index + 1}. ${c.title}</span> | |
| </div> | |
| <!-- Stats (Optional) --> | |
| <!-- <span class="text-xs text-gray-500">0%</span> --> | |
| </div> | |
| </td> | |
| ${rowCells} | |
| </tr> | |
| `; | |
| }).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); | |
| }; | |