Spaces:
Running
Running
Upload 8 files
Browse files- src/views/InstructorView.js +65 -51
- src/views/LandingView.js +2 -0
src/views/InstructorView.js
CHANGED
|
@@ -181,12 +181,9 @@ export function setupInstructorEvents() {
|
|
| 181 |
displayRoomCode.textContent = roomCode;
|
| 182 |
localStorage.setItem('vibecoding_instructor_room', roomCode);
|
| 183 |
|
| 184 |
-
//
|
| 185 |
-
renderHeatmapHeaders();
|
| 186 |
-
|
| 187 |
-
// Subscribe
|
| 188 |
subscribeToRoom(roomCode, (students) => {
|
| 189 |
-
|
| 190 |
});
|
| 191 |
}
|
| 192 |
|
|
@@ -219,94 +216,111 @@ export function setupInstructorEvents() {
|
|
| 219 |
});
|
| 220 |
}
|
| 221 |
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
| 226 |
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
|
|
|
|
|
|
| 230 |
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-48 bg-black text-white text-xs p-2 rounded hidden group-hover:block z-50 pointer-events-none">
|
| 240 |
-
${c.title}
|
| 241 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
`;
|
| 243 |
-
headerRow.appendChild(th);
|
| 244 |
});
|
| 245 |
-
|
| 246 |
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
tbody.innerHTML = '<tr><td colspan="100" class="text-center py-10 text-gray-500">尚無學員加入</td></tr>';
|
| 251 |
return;
|
| 252 |
}
|
| 253 |
|
| 254 |
-
|
| 255 |
-
|
|
|
|
| 256 |
|
| 257 |
-
|
| 258 |
-
const
|
| 259 |
const p = student.progress?.[c.id];
|
| 260 |
-
let statusClass = 'bg-gray-800/
|
| 261 |
let content = '';
|
| 262 |
let action = '';
|
| 263 |
|
| 264 |
if (p) {
|
| 265 |
if (p.status === 'completed') {
|
| 266 |
-
statusClass = 'bg-green-500/20 border-green-500/50 hover:bg-green-500/40 cursor-pointer';
|
| 267 |
content = '✅';
|
| 268 |
-
// Escaped prompt for safety
|
| 269 |
const safePrompt = p.prompt.replace(/"/g, '"').replace(/'/g, "\\'");
|
| 270 |
action = `onclick="window.showBroadcastModal('${student.nickname}', '${c.title}', '${safePrompt}')"`;
|
| 271 |
} else if (p.status === 'started') {
|
| 272 |
// Check stuck
|
| 273 |
-
const startedAt = p.timestamp ? p.timestamp.toDate() : new Date();
|
| 274 |
const now = new Date();
|
| 275 |
const diffMins = (now - startedAt) / 1000 / 60;
|
| 276 |
|
| 277 |
if (diffMins > 5) {
|
| 278 |
-
statusClass = 'bg-red-900/50 border-red-500 animate-pulse';
|
| 279 |
content = '🆘';
|
| 280 |
} else {
|
| 281 |
-
statusClass = 'bg-blue-600/
|
| 282 |
content = '🔵';
|
| 283 |
}
|
| 284 |
}
|
| 285 |
-
} else {
|
| 286 |
-
// Not started
|
| 287 |
-
statusClass = 'bg-gray-800/30 border-gray-800';
|
| 288 |
}
|
| 289 |
|
| 290 |
return `
|
| 291 |
-
<td class="p-
|
| 292 |
-
<div class="w-
|
| 293 |
${content}
|
| 294 |
</div>
|
| 295 |
</td>
|
| 296 |
`;
|
| 297 |
}).join('');
|
| 298 |
|
|
|
|
| 299 |
return `
|
| 300 |
-
<tr class="hover:bg-gray-800/
|
| 301 |
-
<td class="p-
|
| 302 |
-
<div class="flex items-center
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
|
|
|
|
|
|
| 307 |
</div>
|
| 308 |
</td>
|
| 309 |
-
${
|
| 310 |
</tr>
|
| 311 |
`;
|
| 312 |
}).join('');
|
|
|
|
| 181 |
displayRoomCode.textContent = roomCode;
|
| 182 |
localStorage.setItem('vibecoding_instructor_room', roomCode);
|
| 183 |
|
| 184 |
+
// Subscribe to updates
|
|
|
|
|
|
|
|
|
|
| 185 |
subscribeToRoom(roomCode, (students) => {
|
| 186 |
+
renderTransposedHeatmap(students);
|
| 187 |
});
|
| 188 |
}
|
| 189 |
|
|
|
|
| 216 |
});
|
| 217 |
}
|
| 218 |
|
| 219 |
+
/**
|
| 220 |
+
* Renders the Transposed Heatmap (Rows=Challenges, Cols=Students)
|
| 221 |
+
*/
|
| 222 |
+
function renderTransposedHeatmap(students) {
|
| 223 |
+
const thead = document.getElementById('heatmap-header');
|
| 224 |
+
const tbody = document.getElementById('heatmap-body');
|
| 225 |
|
| 226 |
+
if (students.length === 0) {
|
| 227 |
+
thead.innerHTML = '<th class="p-4 text-left">等待資料...</th>';
|
| 228 |
+
tbody.innerHTML = '<tr><td class="p-10 text-center text-gray-500">尚無學員加入</td></tr>';
|
| 229 |
+
return;
|
| 230 |
+
}
|
| 231 |
|
| 232 |
+
// 1. Render Header (Students)
|
| 233 |
+
// Sticky Top for Header Row
|
| 234 |
+
// Sticky Left for the first cell ("Challenge/Student")
|
| 235 |
+
let headerHtml = `
|
| 236 |
+
<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">
|
| 237 |
+
<div class="flex justify-between items-end">
|
| 238 |
+
<span class="text-sm text-gray-400">題目</span>
|
| 239 |
+
<span class="text-sm text-cyan-400">學員 (${students.length})</span>
|
|
|
|
|
|
|
| 240 |
</div>
|
| 241 |
+
</th>
|
| 242 |
+
`;
|
| 243 |
+
|
| 244 |
+
students.forEach(student => {
|
| 245 |
+
headerHtml += `
|
| 246 |
+
<th class="p-2 text-center sticky top-0 bg-gray-800 z-20 border-b border-gray-700 min-w-[80px] group">
|
| 247 |
+
<div class="flex flex-col items-center space-y-2 py-2">
|
| 248 |
+
<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">
|
| 249 |
+
${student.nickname[0]}
|
| 250 |
+
<!-- Online Indicator (Simulated) -->
|
| 251 |
+
<div class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 border-2 border-gray-800 rounded-full"></div>
|
| 252 |
+
</div>
|
| 253 |
+
<span class="text-xs text-gray-300 font-medium truncate max-w-[80px] writing-vertical-lr" style="writing-mode: vertical-rl; text-orientation: mixed;">
|
| 254 |
+
${student.nickname}
|
| 255 |
+
</span>
|
| 256 |
+
</div>
|
| 257 |
+
</th>
|
| 258 |
`;
|
|
|
|
| 259 |
});
|
| 260 |
+
thead.innerHTML = headerHtml;
|
| 261 |
|
| 262 |
+
// 2. Render Body (Challenges as Rows)
|
| 263 |
+
if (cachedChallenges.length === 0) {
|
| 264 |
+
tbody.innerHTML = '<tr><td colspan="100" class="text-center py-4">沒有題目資料</td></tr>';
|
|
|
|
| 265 |
return;
|
| 266 |
}
|
| 267 |
|
| 268 |
+
tbody.innerHTML = cachedChallenges.map((c, index) => {
|
| 269 |
+
const colors = { beginner: 'cyan', intermediate: 'blue', advanced: 'purple' };
|
| 270 |
+
const color = colors[c.level] || 'gray';
|
| 271 |
|
| 272 |
+
// Build Row Cells (One per student)
|
| 273 |
+
const rowCells = students.map(student => {
|
| 274 |
const p = student.progress?.[c.id];
|
| 275 |
+
let statusClass = 'bg-gray-800/30 border-gray-800'; // Default
|
| 276 |
let content = '';
|
| 277 |
let action = '';
|
| 278 |
|
| 279 |
if (p) {
|
| 280 |
if (p.status === 'completed') {
|
| 281 |
+
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)]';
|
| 282 |
content = '✅';
|
|
|
|
| 283 |
const safePrompt = p.prompt.replace(/"/g, '"').replace(/'/g, "\\'");
|
| 284 |
action = `onclick="window.showBroadcastModal('${student.nickname}', '${c.title}', '${safePrompt}')"`;
|
| 285 |
} else if (p.status === 'started') {
|
| 286 |
// Check stuck
|
| 287 |
+
const startedAt = p.timestamp ? p.timestamp.toDate() : new Date();
|
| 288 |
const now = new Date();
|
| 289 |
const diffMins = (now - startedAt) / 1000 / 60;
|
| 290 |
|
| 291 |
if (diffMins > 5) {
|
| 292 |
+
statusClass = 'bg-red-900/50 border-red-500 animate-pulse cursor-help';
|
| 293 |
content = '🆘';
|
| 294 |
} else {
|
| 295 |
+
statusClass = 'bg-blue-600/20 border-blue-500';
|
| 296 |
content = '🔵';
|
| 297 |
}
|
| 298 |
}
|
|
|
|
|
|
|
|
|
|
| 299 |
}
|
| 300 |
|
| 301 |
return `
|
| 302 |
+
<td class="p-1 border border-gray-800/50 text-center align-middle h-14 hover:bg-white/5 transition-colors">
|
| 303 |
+
<div class="mx-auto w-10 h-10 rounded-lg border flex items-center justify-center ${statusClass} transition-all duration-300" ${action}>
|
| 304 |
${content}
|
| 305 |
</div>
|
| 306 |
</td>
|
| 307 |
`;
|
| 308 |
}).join('');
|
| 309 |
|
| 310 |
+
// Row Header (Challenge Title)
|
| 311 |
return `
|
| 312 |
+
<tr class="hover:bg-gray-800/50 transition-colors">
|
| 313 |
+
<td class="p-3 sticky left-0 bg-gray-900 z-10 border-r border-b border-gray-700 shadow-md">
|
| 314 |
+
<div class="flex items-center justify-between">
|
| 315 |
+
<div class="flex flex-col">
|
| 316 |
+
<span class="text-xs text-${color}-400 font-bold uppercase tracking-wider mb-0.5">${c.level}</span>
|
| 317 |
+
<span class="font-bold text-white text-sm truncate max-w-[180px]" title="${c.title}">${index + 1}. ${c.title}</span>
|
| 318 |
+
</div>
|
| 319 |
+
<!-- Stats (Optional) -->
|
| 320 |
+
<!-- <span class="text-xs text-gray-500">0%</span> -->
|
| 321 |
</div>
|
| 322 |
</td>
|
| 323 |
+
${rowCells}
|
| 324 |
</tr>
|
| 325 |
`;
|
| 326 |
}).join('');
|
src/views/LandingView.js
CHANGED
|
@@ -67,6 +67,8 @@ export function setupLandingEvents(navigateTo) {
|
|
| 67 |
});
|
| 68 |
|
| 69 |
instructorBtn.addEventListener('click', () => {
|
|
|
|
|
|
|
| 70 |
navigateTo('instructor');
|
| 71 |
});
|
| 72 |
}
|
|
|
|
| 67 |
});
|
| 68 |
|
| 69 |
instructorBtn.addEventListener('click', () => {
|
| 70 |
+
// Clear any previous admin referer to ensure clean state
|
| 71 |
+
localStorage.removeItem('vibecoding_admin_referer');
|
| 72 |
navigateTo('instructor');
|
| 73 |
});
|
| 74 |
}
|