Vibecodingex / src /views /InstructorView.js
Lashtw's picture
Upload 10 files
21778f6 verified
raw
history blame
19.6 kB
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, '&quot;').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);
};