Lashtw commited on
Commit
fb48817
·
verified ·
1 Parent(s): 61dc0d9

Upload 9 files

Browse files
Files changed (1) hide show
  1. src/views/InstructorView.js +791 -785
src/views/InstructorView.js CHANGED
@@ -720,133 +720,136 @@ export function setupInstructorEvents() {
720
 
721
  modal.classList.remove('hidden');
722
  });
 
723
 
724
- // Add New Instructor
725
- const addInstBtn = document.getElementById('btn-add-inst');
726
- if (addInstBtn) {
727
- addInstBtn.addEventListener('click', async () => {
728
- const email = document.getElementById('new-inst-email').value.trim();
729
- const name = document.getElementById('new-inst-name').value.trim();
730
 
731
- if (!email || !name) return alert("請輸入完整資料");
732
 
733
- const perms = [];
734
- if (document.getElementById('perm-room').checked) perms.push('create_room');
735
- if (document.getElementById('perm-q').checked) perms.push('add_question');
736
- if (document.getElementById('perm-inst').checked) perms.push('manage_instructors');
737
 
738
- try {
739
- await addInstructor(email, name, perms);
740
- alert("新增成功");
741
- navInstBtn.click(); // Reload list
742
- document.getElementById('new-inst-email').value = '';
743
- document.getElementById('new-inst-name').value = '';
744
- } catch (e) {
745
- alert("新增失敗: " + e.message);
746
- }
747
- });
748
- }
749
-
750
-
751
- // Global helper for remove (hacky but works for simple onclick)
752
- window.removeInst = async (email) => {
753
- if (confirm(`確定移除 ${email}?`)) {
754
- try {
755
- await removeInstructor(email);
756
- navInstBtn.click(); // Reload
757
- } catch (e) {
758
- alert(e.message);
759
- }
760
  }
761
- };
 
 
762
 
763
- // Auto Check Auth (Persistence)
764
- // We rely on Firebase Auth state observer instead of session storage for security?
765
- // Or we can just check if user is already signed in.
766
- import("../services/firebase.js").then(async ({ auth }) => {
767
- // Handle Redirect Result first
768
  try {
769
- console.log("Initializing Auth Check...");
770
- const { handleRedirectResult } = await import("../services/auth.js");
771
- const redirectUser = await handleRedirectResult();
772
- if (redirectUser) console.log("Redirect User Found:", redirectUser.email);
773
- } catch (e) { console.warn("Redirect check failed", e); }
774
-
775
- auth.onAuthStateChanged(async (user) => {
776
- console.log("Auth State Changed to:", user ? user.email : "Logged Out");
777
- if (user) {
778
- try {
779
- console.log("Checking permissions for:", user.email);
780
- const instructorData = await checkInstructorPermission(user);
781
- console.log("Permission Result:", instructorData);
782
-
783
- if (instructorData) {
784
- console.log("Hiding Modal and Setting Permissions...");
785
- authModal.classList.add('hidden');
786
- checkPermissions(instructorData);
787
-
788
- // Auto-Restore Room View if exists
789
- const savedRoomCode = localStorage.getItem('vibecoding_room_code');
790
- if (savedRoomCode) {
791
- console.log("Restoring Room Session:", savedRoomCode);
792
- const roomInfo = document.getElementById('room-info');
793
- const displayRoomCode = document.getElementById('display-room-code');
794
- const createContainer = document.getElementById('create-room-container');
795
- const dashboardContent = document.getElementById('dashboard-content');
796
-
797
- // Restore UI
798
- createContainer.classList.add('hidden');
799
- roomInfo.classList.remove('hidden');
800
- dashboardContent.classList.remove('hidden');
801
- displayRoomCode.textContent = savedRoomCode;
802
-
803
- // Re-subscribe locally using the existing updateDashboard logic if available,
804
- // or we need to redefine the callback here.
805
- // Since updateDashboard is inside createBtn scope, we can't mistakenly access it if it's not global.
806
- // Wait, updateDashboard IS inside createBtn scope. That's a problem.
807
- // We need to move updateDashboard out or duplicate the logic here.
808
- // Duplicating logic for robustness:
809
- subscribeToRoom(savedRoomCode, (data) => {
810
- const users = Array.isArray(data) ? data : (data?.users ? Object.values(data.users) : []);
811
- currentStudents = users;
812
- renderTransposedHeatmap(users);
813
- });
814
- }
815
 
816
- } else {
817
- console.warn("User logged in but not an instructor.");
818
- // Show unauthorized message
819
- authErrorMsg.textContent = "此帳號無講師權限";
820
- authErrorMsg.classList.remove('hidden');
821
- authModal.classList.remove('hidden'); // Ensure modal stays up
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
822
  }
823
- } catch (e) {
824
- console.error("Permission Check Failed:", e);
825
- authErrorMsg.textContent = "權限檢查失敗: " + e.message;
 
 
826
  authErrorMsg.classList.remove('hidden');
 
827
  }
828
- } else {
829
- authModal.classList.remove('hidden');
 
 
830
  }
831
- });
 
 
832
  });
 
833
 
834
- // Define Kick Function globally (robust against auth flow)
835
- window.confirmKick = async (userId, nickname) => {
836
- if (confirm(`確定要踢出 ${nickname} 嗎?此動作無法復原。`)) {
837
- try {
838
- const { removeUser } = await import("../services/classroom.js");
839
- await removeUser(userId);
840
- // UI will update automatically via subscribeToRoom
841
- } catch (e) {
842
- console.error("Kick failed:", e);
843
- alert("移除失敗");
844
- }
845
  }
846
- };
 
847
 
848
 
849
- // Snapshot Logic
 
 
850
  snapshotBtn.addEventListener('click', async () => {
851
  if (isSnapshotting || typeof htmlToImage === 'undefined') {
852
  if (typeof htmlToImage === 'undefined') alert("截圖元件尚未載入,請稍候再試");
@@ -947,8 +950,10 @@ export function setupInstructorEvents() {
947
  }
948
  }, 600); // Slight delay for emojis to appear
949
  });
 
950
 
951
- // Group Photo Logic
 
952
  groupPhotoBtn.addEventListener('click', () => {
953
  const modal = document.getElementById('group-photo-modal');
954
  const container = document.getElementById('group-photo-container');
@@ -1130,68 +1135,69 @@ export function setupInstructorEvents() {
1130
 
1131
  modal.classList.remove('hidden');
1132
  });
 
1133
 
1134
- // Helper: Drag & Drop Logic
1135
- function setupDraggable(el, container) {
1136
- let isDragging = false;
1137
- let startX, startY, initialLeft, initialTop;
1138
 
1139
- el.addEventListener('mousedown', (e) => {
1140
- isDragging = true;
1141
- startX = e.clientX;
1142
- startY = e.clientY;
1143
 
1144
- // Disable transition during drag for responsiveness
1145
- el.style.transition = 'none';
1146
- el.style.zIndex = 100; // Bring to front
1147
 
1148
- // Convert current computed position to fixed pixels if relying on calc
1149
- const rect = el.getBoundingClientRect();
1150
- const containerRect = container.getBoundingClientRect();
1151
 
1152
- // Calculate position relative to container
1153
- // The current transform is translate(-50%, -50%).
1154
- // We want to set left/top such that the center remains under the mouse offset,
1155
- // but for simplicity, let's just use current offsetLeft/Top if possible,
1156
- // OR robustly recalculate from rects.
1157
 
1158
- // Current center point relative to container:
1159
- const centerX = rect.left - containerRect.left + rect.width / 2;
1160
- const centerY = rect.top - containerRect.top + rect.height / 2;
1161
 
1162
- // Set explicit pixel values replacing calc()
1163
- el.style.left = `${centerX}px`;
1164
- el.style.top = `${centerY}px`;
1165
 
1166
- initialLeft = centerX;
1167
- initialTop = centerY;
1168
- });
1169
 
1170
- window.addEventListener('mousemove', (e) => {
1171
- if (!isDragging) return;
1172
- e.preventDefault();
1173
 
1174
- const dx = e.clientX - startX;
1175
- const dy = e.clientY - startY;
1176
 
1177
- el.style.left = `${initialLeft + dx}px`;
1178
- el.style.top = `${initialTop + dy}px`;
1179
- });
1180
 
1181
- window.addEventListener('mouseup', () => {
1182
- if (isDragging) {
1183
- isDragging = false;
1184
- el.style.transition = ''; // Re-enable hover effects
1185
- el.style.zIndex = ''; // Restore z-index rule (or let hover take over)
1186
- }
1187
- });
1188
- }
1189
 
1190
- // Add float animation style if not exists
1191
- if (!document.getElementById('anim-float')) {
1192
- const style = document.createElement('style');
1193
- style.id = 'anim-float';
1194
- style.innerHTML = `
1195
  @keyframes float {
1196
 
1197
  0 %, 100 % { transform: translateY(0) scale(1); }
@@ -1199,234 +1205,234 @@ export function setupInstructorEvents() {
1199
  }
1200
  }
1201
  `;
1202
- document.head.appendChild(style);
1203
- }
1204
-
1205
- // Gallery Logic
1206
- document.getElementById('btn-open-gallery').addEventListener('click', () => {
1207
- window.open('monster_preview.html', '_blank');
1208
- });
1209
 
1210
- // Logout Logic
1211
- document.getElementById('logout-btn').addEventListener('click', async () => {
1212
- if (confirm('確定要登出講師模式嗎? (將會回到首頁)')) {
1213
- await signOutUser();
1214
- sessionStorage.removeItem('vibecoding_instructor_in_room');
1215
- sessionStorage.removeItem('vibecoding_admin_referer');
1216
- window.location.hash = '';
1217
- window.location.reload();
1218
- }
1219
- });
 
 
 
 
 
1220
 
1221
- // Check Previous Session (Handled by onAuthStateChanged now)
1222
- // if (sessionStorage.getItem('vibecoding_instructor_auth') === 'true') {
1223
- // authModal.classList.add('hidden');
1224
- // }
1225
 
1226
- // Check Active Room State
1227
- const activeRoom = sessionStorage.getItem('vibecoding_instructor_in_room');
1228
- if (activeRoom === 'true' && savedRoomCode) {
1229
- enterRoom(savedRoomCode);
1230
- }
1231
 
1232
- // Module-level variable to track subscription (Moved to top)
1233
 
1234
- function enterRoom(roomCode) {
1235
- createContainer.classList.add('hidden');
1236
- roomInfo.classList.remove('hidden');
1237
- dashboardContent.classList.remove('hidden');
1238
- document.getElementById('group-photo-btn').classList.remove('hidden'); // Show photo button
1239
- displayRoomCode.textContent = roomCode;
1240
- localStorage.setItem('vibecoding_room_code', roomCode);
1241
- sessionStorage.setItem('vibecoding_instructor_in_room', 'true');
1242
 
1243
- // Unsubscribe previous if any
1244
- if (roomUnsubscribe) roomUnsubscribe();
1245
 
1246
- // Subscribe to updates
1247
- roomUnsubscribe = subscribeToRoom(roomCode, (students) => {
1248
- currentStudents = students;
1249
- renderTransposedHeatmap(students);
1250
- });
1251
- }
1252
 
1253
- // Leave Room Logic
1254
- document.getElementById('leave-room-btn').addEventListener('click', () => {
1255
- if (confirm('確定要離開目前教室嗎?(不會刪除教室資料,僅回到選擇介面)')) {
1256
- // Unsubscribe
1257
- if (roomUnsubscribe) {
1258
- roomUnsubscribe();
1259
- roomUnsubscribe = null;
1260
- }
1261
 
1262
- // UI Reset
1263
- createContainer.classList.remove('hidden');
1264
- roomInfo.classList.add('hidden');
1265
- dashboardContent.classList.add('hidden');
1266
- document.getElementById('group-photo-btn').classList.add('hidden'); // Hide photo button
1267
 
1268
- // Clear Data Display
1269
- document.getElementById('heatmap-body').innerHTML = '<tr><td colspan="100" class="text-center py-10 text-gray-500">等待資料載入...</td></tr>';
1270
- document.getElementById('heatmap-header').innerHTML = '<th class="p-3 text-left sticky left-0 bg-gray-800 z-20 border-b border-gray-700 min-w-[150px]">學員 / 關卡</th>';
1271
 
1272
- // State Clear
1273
- sessionStorage.removeItem('vibecoding_instructor_in_room');
1274
- localStorage.removeItem('vibecoding_room_code');
1275
- }
1276
- });
1277
 
1278
- // Modal Events
1279
- window.showBroadcastModal = (userId, challengeId) => {
1280
- const modal = document.getElementById('broadcast-modal');
1281
- const content = document.getElementById('broadcast-content');
1282
 
1283
- // Find Data
1284
- const student = currentStudents.find(s => s.id === userId);
1285
- if (!student) return alert('找不到學員資料');
1286
 
1287
- const p = student.progress ? student.progress[challengeId] : null;
1288
- if (!p) return alert('找不到該作品資料');
1289
 
1290
- const challenge = cachedChallenges.find(c => c.id === challengeId);
1291
- const title = challenge ? challenge.title : '未知題目';
1292
 
1293
- // Populate UI
1294
- document.getElementById('broadcast-avatar').textContent = student.nickname[0] || '?';
1295
- document.getElementById('broadcast-author').textContent = student.nickname;
1296
- document.getElementById('broadcast-challenge').textContent = title;
1297
- document.getElementById('broadcast-prompt').textContent = p.prompt || '(無內容)';
1298
 
1299
- // Store IDs for Actions (Reject/BroadcastAll)
1300
- modal.dataset.userId = userId;
1301
- modal.dataset.challengeId = challengeId;
1302
 
1303
- // Show
1304
- modal.classList.remove('hidden');
1305
- setTimeout(() => {
1306
- content.classList.remove('scale-95', 'opacity-0');
1307
- content.classList.add('opacity-100', 'scale-100');
1308
- }, 10);
1309
- };
1310
 
1311
- window.closeBroadcast = () => {
1312
- const modal = document.getElementById('broadcast-modal');
1313
- const content = document.getElementById('broadcast-content');
1314
- content.classList.remove('opacity-100', 'scale-100');
1315
- content.classList.add('scale-95', 'opacity-0');
1316
- setTimeout(() => modal.classList.add('hidden'), 300);
1317
- };
1318
 
1319
- window.openStage = (prompt, author) => {
1320
- document.getElementById('broadcast-content').classList.add('hidden');
1321
- const stage = document.getElementById('stage-view');
1322
- stage.classList.remove('hidden');
1323
- document.getElementById('stage-prompt').textContent = cleanText(prompt || '');
1324
- document.getElementById('stage-author').textContent = author;
1325
- };
1326
 
1327
- window.closeStage = () => {
1328
- document.getElementById('stage-view').classList.add('hidden');
1329
- document.getElementById('broadcast-content').classList.remove('hidden');
1330
- };
1331
 
1332
- document.getElementById('btn-show-stage').addEventListener('click', () => {
1333
- const prompt = document.getElementById('broadcast-prompt').textContent;
1334
- const author = document.getElementById('broadcast-author').textContent;
1335
- window.openStage(prompt, author);
1336
- });
1337
 
1338
- // Reject Logic
1339
- document.getElementById('btn-reject-task').addEventListener('click', async () => {
1340
- if (!confirm('確定要退回此題目讓學員重做嗎?')) return;
1341
 
1342
- // We need student ID (userId) and Challenge ID.
1343
- // Currently showBroadcastModal only receives nickname, title, prompt.
1344
- // We need to attach data-userid and data-challengeid to the modal.
1345
- const modal = document.getElementById('broadcast-modal');
1346
- const userId = modal.dataset.userId;
1347
- const challengeId = modal.dataset.challengeId;
1348
- const roomCode = localStorage.getItem('vibecoding_room_code');
1349
 
1350
- if (userId && challengeId && roomCode) {
1351
- try {
1352
- await resetProgress(userId, roomCode, challengeId);
1353
- alert('已成功退回,學員可重新作答');
1354
- // Close modal
1355
- window.closeBroadcast();
1356
- } catch (e) {
1357
- console.error('退回失敗:', e);
1358
- alert('退回失敗: ' + e.message);
1359
- }
1360
  }
1361
- });
1362
- // Prompt Viewer Logic
1363
- window.openPromptList = (type, id, title) => {
1364
- const modal = document.getElementById('prompt-list-modal');
1365
- const container = document.getElementById('prompt-list-container');
1366
- const titleEl = document.getElementById('prompt-list-title');
 
1367
 
1368
- titleEl.textContent = type === 'student' ? `${title} 的所有提示詞` : `題目:${title} 的所有作品`;
1369
 
1370
- // Reset Anonymous Toggle in List View
1371
- const anonCheck = document.getElementById('list-anonymous-toggle');
1372
- if (anonCheck) anonCheck.checked = false;
1373
 
1374
- container.innerHTML = '';
1375
- modal.classList.remove('hidden');
1376
 
1377
- // Collect Prompts
1378
- let prompts = [];
1379
- // Fix: Reset selection when opening new list to prevent cross-contamination
1380
- selectedPrompts = [];
1381
- updateCompareButton();
1382
-
1383
- if (type === 'student') {
1384
- const student = currentStudents.find(s => s.id === id);
1385
- if (student && student.progress) {
1386
- prompts = Object.entries(student.progress)
1387
- .filter(([_, p]) => p.status === 'completed' && p.prompt)
1388
- .map(([challengeId, p]) => {
1389
- const challenge = cachedChallenges.find(c => c.id === challengeId);
1390
- return {
1391
- id: `${student.id}_${challengeId}`,
1392
- title: challenge ? challenge.title : '未知題目',
1393
- prompt: p.prompt,
1394
- author: student.nickname,
1395
- studentId: student.id,
1396
- challengeId: challengeId,
1397
- time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : ''
1398
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1399
  });
1400
- }
1401
- } else if (type === 'challenge') {
1402
- currentStudents.forEach(student => {
1403
- if (student.progress && student.progress[id]) {
1404
- const p = student.progress[id];
1405
- if (p.status === 'completed' && p.prompt) {
1406
- prompts.push({
1407
- id: `${student.id}_${id}`,
1408
- title: student.nickname, // When viewing challenge, title is student name
1409
- prompt: p.prompt,
1410
- author: student.nickname,
1411
- studentId: student.id,
1412
- challengeId: id,
1413
- time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : ''
1414
- });
1415
- }
1416
  }
1417
- });
1418
- }
 
1419
 
1420
- if (prompts.length === 0) {
1421
- container.innerHTML = '<div class="col-span-full text-center text-gray-500 py-10">無資料</div>';
1422
- return;
1423
- }
1424
 
1425
- prompts.forEach(p => {
1426
- const card = document.createElement('div');
1427
- // Reduced height (h-64 -> h-48) and padding, but larger text inside
1428
- card.className = 'bg-gray-800 rounded-xl p-3 border border-gray-700 hover:border-cyan-500 transition-colors flex flex-col h-48 group';
1429
- card.innerHTML = `
1430
  <div class="flex justify-between items-start mb-1.5">
1431
  <h3 class="font-bold text-white text-base truncate w-3/4" title="${p.title}">${p.title}</h3>
1432
  <!-- Checkbox -->
@@ -1447,186 +1453,186 @@ export function setupInstructorEvents() {
1447
  </div>
1448
  </div>
1449
  `;
1450
- container.appendChild(card);
1451
- });
1452
- };
1453
-
1454
- // Helper Actions
1455
- window.confirmReset = async (userId, challengeId, title) => {
1456
- console.log('🔵 confirmReset called');
1457
- console.log(' userId:', userId);
1458
- console.log(' challengeId:', challengeId);
1459
- console.log(' title:', title);
1460
- console.log(' typeof resetProgress:', typeof resetProgress);
1461
-
1462
- if (confirm(`確定要退回 ${title} 嗎?此動作將清除學員目前的進度。`)) {
1463
- console.log('✅ User confirmed reset');
1464
- const roomCode = localStorage.getItem('vibecoding_room_code');
1465
- console.log(' roomCode:', roomCode);
1466
-
1467
- if (userId && challengeId && roomCode) {
1468
- console.log('✅ All parameters valid, calling resetProgress...');
1469
- try {
1470
- // Use already imported resetProgress function
1471
- await resetProgress(userId, roomCode, challengeId);
1472
- console.log('✅ resetProgress completed successfully');
1473
- alert("已退回");
1474
- // close modal to refresh data context
1475
- document.getElementById('prompt-list-modal').classList.add('hidden');
1476
- console.log('✅ Modal closed');
1477
- } catch (e) {
1478
- console.error("❌ 退回失敗:", e);
1479
- console.error("Error stack:", e.stack);
1480
- alert("退回失敗: " + e.message);
1481
- }
1482
- } else {
1483
- console.error('❌ Missing required parameters:');
1484
- console.error(' userId:', userId, '(valid:', !!userId, ')');
1485
- console.error(' challengeId:', challengeId, '(valid:', !!challengeId, ')');
1486
- console.error(' roomCode:', roomCode, '(valid:', !!roomCode, ')');
1487
- alert('缺少必要參數,無法執行退回操作');
1488
- }
1489
- } else {
1490
- console.log('❌ User cancelled reset');
1491
- }
1492
- };
1493
-
1494
- window.broadcastPrompt = (userId, challengeId) => {
1495
- window.showBroadcastModal(userId, challengeId);
1496
- };
1497
 
1498
- // Selection Logic
1499
- let selectedPrompts = []; // Stores IDs
 
 
 
 
 
1500
 
1501
- window.handlePromptSelection = (checkbox) => {
1502
- const id = checkbox.dataset.id;
 
 
1503
 
1504
- if (checkbox.checked) {
1505
- if (selectedPrompts.length >= 3) {
1506
- checkbox.checked = false;
1507
- alert('最多只能選擇 3 個提示詞進行比較');
1508
- return;
 
 
 
 
 
 
 
 
 
1509
  }
1510
- selectedPrompts.push(id);
1511
  } else {
1512
- selectedPrompts = selectedPrompts.filter(pid => pid !== id);
 
 
 
 
1513
  }
1514
- updateCompareButton();
1515
- };
 
 
1516
 
1517
- function updateCompareButton() {
1518
- const btn = document.getElementById('btn-compare-prompts');
1519
- if (!btn) return;
1520
 
1521
- const count = selectedPrompts.length;
1522
- const span = btn.querySelector('span');
1523
- if (span) span.textContent = `🔍 比較已選項目 (${count}/3)`;
1524
 
1525
- if (count > 0) {
1526
- btn.disabled = false;
1527
- btn.classList.remove('opacity-50', 'cursor-not-allowed');
1528
- } else {
1529
- btn.disabled = true;
1530
- btn.classList.add('opacity-50', 'cursor-not-allowed');
 
 
1531
  }
 
 
 
1532
  }
1533
- // Comparison Logic
1534
- const compareBtn = document.getElementById('btn-compare-prompts');
1535
- if (compareBtn) {
1536
- compareBtn.addEventListener('click', () => {
1537
- const dataToCompare = [];
1538
- selectedPrompts.forEach(fullId => {
1539
- const lastUnderscore = fullId.lastIndexOf('_');
1540
- const studentId = fullId.substring(0, lastUnderscore);
1541
- const challengeId = fullId.substring(lastUnderscore + 1);
1542
-
1543
- const student = currentStudents.find(s => s.id === studentId);
1544
- if (student && student.progress && student.progress[challengeId]) {
1545
- const p = student.progress[challengeId];
1546
- const challenge = cachedChallenges.find(c => c.id === challengeId);
1547
 
1548
- dataToCompare.push({
1549
- title: challenge ? challenge.title : '未知',
1550
- author: student.nickname,
1551
- prompt: p.prompt,
1552
- time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleTimeString() : ''
1553
- });
1554
- }
1555
- });
1556
 
1557
- const isAnon = document.getElementById('list-anonymous-toggle')?.checked || false;
1558
- openComparisonView(dataToCompare, isAnon);
1559
- });
1560
- }
1561
 
1562
- let isAnonymous = false;
1563
-
1564
- window.toggleAnonymous = (btn) => {
1565
- isAnonymous = !isAnonymous;
1566
- btn.textContent = isAnonymous ? '🙈 顯示姓名' : '👀 隱藏姓名';
1567
- btn.classList.toggle('bg-gray-700');
1568
- btn.classList.toggle('bg-purple-700');
1569
-
1570
- // Update DOM
1571
- document.querySelectorAll('.comparison-author').forEach(el => {
1572
- if (isAnonymous) {
1573
- el.dataset.original = el.textContent;
1574
- el.textContent = '學員';
1575
- el.classList.add('blur-sm'); // Optional Effect
1576
- setTimeout(() => el.classList.remove('blur-sm'), 300);
1577
- } else {
1578
- if (el.dataset.original) el.textContent = el.dataset.original;
 
 
 
 
 
 
 
 
 
 
 
 
1579
  }
1580
  });
1581
- };
1582
 
1583
- window.openComparisonView = (items, initialAnonymous = false) => {
1584
- const modal = document.getElementById('comparison-modal');
1585
- const grid = document.getElementById('comparison-grid');
 
1586
 
1587
- // Apply Anonymous State
1588
- isAnonymous = initialAnonymous;
1589
- const anonBtn = document.getElementById('btn-anonymous-toggle');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1590
 
1591
- // Update Toggle UI to match state
1592
- if (anonBtn) {
1593
- if (isAnonymous) {
1594
- anonBtn.textContent = '🙈 顯示姓名';
1595
- anonBtn.classList.add('bg-purple-700');
1596
- anonBtn.classList.remove('bg-gray-700');
1597
- } else {
1598
- anonBtn.textContent = '👀 隱藏姓名';
1599
- anonBtn.classList.remove('bg-purple-700');
1600
- anonBtn.classList.add('bg-gray-700');
1601
- }
 
 
 
 
 
 
 
1602
  }
 
1603
 
1604
- // Setup Grid Rows (Vertical Stacking)
1605
- let rowClass = 'grid-rows-1';
1606
- if (items.length === 2) rowClass = 'grid-rows-2';
1607
- if (items.length === 3) rowClass = 'grid-rows-3';
1608
-
1609
- grid.className = `absolute inset-0 grid ${rowClass} gap-0 divide-y divide-gray-600`;
1610
- grid.innerHTML = '';
1611
-
1612
- items.forEach(item => {
1613
- const col = document.createElement('div');
1614
- // Check overflow-hidden to keep it contained, use flex-row for compact header + content
1615
- col.className = 'flex flex-row h-full bg-gray-900 p-4 overflow-hidden';
1616
-
1617
- // Logic for anonymous
1618
- let displayAuthor = item.author;
1619
- let blurClass = '';
1620
-
1621
- if (isAnonymous) {
1622
- displayAuthor = '學員';
1623
- blurClass = 'blur-sm'; // Initial blur
1624
- // Auto remove blur after delay if needed, or keep it?
1625
- // Toggle logic removes it after delay. But initial render should probably just be static '學員' or blurred.
1626
- // The toggle logic uses dataset.original. We need to set it here too.
1627
- }
1628
 
1629
- col.innerHTML = `
1630
  <div class="w-48 flex-shrink-0 border-r border-gray-700 pr-4 mr-4 flex flex-col justify-center">
1631
  <h3 class="text-xl font-bold text-cyan-400 mb-1 comparison-author ${blurClass}" data-original="${item.author}">${displayAuthor}</h3>
1632
  <p class="text-md text-gray-400 truncate" title="${item.title}">${item.title}</p>
@@ -1635,223 +1641,223 @@ export function setupInstructorEvents() {
1635
  <!-- Prompt Content: Larger Text (text-4xl) -->
1636
  <div class="flex-1 overflow-y-auto font-mono text-green-300 text-3xl leading-relaxed whitespace-pre-wrap p-2 hover:bg-white/5 transition-colors rounded custom-scrollbar text-left" style="text-align: left !important;">${cleanText(item.prompt)}</div>
1637
  `;
1638
- grid.appendChild(col);
1639
-
1640
- // If blurred, remove blur after animation purely for effect, or keep?
1641
- // User intention "Hidden Name" usually means "Replaced by generic name".
1642
- // The blur effect in toggle logic was transient.
1643
- // If we want persistent anonymity, just "學員" is enough.
1644
- // The existing toggle logic adds 'blur-sm' then removes it in 300ms.
1645
- // We should replicate that effect if we want consistency, or just skip blur on init.
1646
- if (isAnonymous) {
1647
- const el = col.querySelector('.comparison-author');
1648
- setTimeout(() => el.classList.remove('blur-sm'), 300);
1649
- }
1650
- });
1651
 
1652
- document.getElementById('prompt-list-modal').classList.add('hidden');
1653
- modal.classList.remove('hidden');
1654
 
1655
- // Init Canvas (Phase 3)
1656
- setTimeout(setupCanvas, 100);
1657
- };
 
 
 
 
 
1658
 
1659
- window.closeComparison = () => {
1660
- document.getElementById('comparison-modal').classList.add('hidden');
1661
- clearCanvas();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1662
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1663
 
1664
- // --- Phase 3 & 6: Annotation Tools ---
1665
- let canvas, ctx;
1666
- let isDrawing = false;
1667
- let currentPenColor = '#ef4444'; // Red default
1668
- let currentLineWidth = 3;
1669
- let currentMode = 'source-over'; // 'source-over' (Pen) or 'destination-out' (Eraser)
1670
-
1671
- window.setupCanvas = () => {
1672
- canvas = document.getElementById('annotation-canvas');
1673
- const container = document.getElementById('comparison-container');
1674
- if (!canvas || !container) return;
1675
-
1676
- ctx = canvas.getContext('2d');
1677
-
1678
- // Resize
1679
- const resize = () => {
1680
- canvas.width = container.clientWidth;
1681
- canvas.height = container.clientHeight;
1682
- ctx.lineCap = 'round';
1683
- ctx.lineJoin = 'round';
1684
- ctx.strokeStyle = currentPenColor;
1685
- ctx.lineWidth = currentLineWidth;
1686
- ctx.globalCompositeOperation = currentMode;
1687
- };
1688
- resize();
1689
- window.addEventListener('resize', resize);
1690
-
1691
- // Init Size UI & Cursor
1692
- updateSizeBtnUI();
1693
- updateCursorStyle();
1694
-
1695
- // Cursor Logic
1696
- const cursor = document.getElementById('tool-cursor');
1697
-
1698
- canvas.addEventListener('mouseenter', () => cursor.classList.remove('hidden'));
1699
- canvas.addEventListener('mouseleave', () => cursor.classList.add('hidden'));
1700
- canvas.addEventListener('mousemove', (e) => {
1701
- const { x, y } = getPos(e);
1702
- cursor.style.left = `${x}px`;
1703
- cursor.style.top = `${y}px`;
1704
- });
1705
 
1706
- // Drawing Events
1707
- const start = (e) => {
1708
- isDrawing = true;
1709
- ctx.beginPath();
1710
-
1711
- // Re-apply settings (state might change)
1712
- ctx.globalCompositeOperation = currentMode;
1713
- ctx.strokeStyle = currentPenColor;
1714
- ctx.lineWidth = currentLineWidth;
1715
-
1716
- const { x, y } = getPos(e);
1717
- ctx.moveTo(x, y);
1718
- };
1719
-
1720
- const move = (e) => {
1721
- if (!isDrawing) return;
1722
- const { x, y } = getPos(e);
1723
- ctx.lineTo(x, y);
1724
- ctx.stroke();
1725
- };
1726
-
1727
- const end = () => {
1728
- isDrawing = false;
1729
- };
1730
-
1731
- canvas.onmousedown = start;
1732
- canvas.onmousemove = move;
1733
- canvas.onmouseup = end;
1734
- canvas.onmouseleave = end;
1735
-
1736
- // Touch support
1737
- canvas.ontouchstart = (e) => { e.preventDefault(); start(e.touches[0]); };
1738
- canvas.ontouchmove = (e) => { e.preventDefault(); move(e.touches[0]); };
1739
- canvas.ontouchend = (e) => { e.preventDefault(); end(); };
1740
- };
1741
 
1742
- function getPos(e) {
1743
- const rect = canvas.getBoundingClientRect();
1744
- return {
1745
- x: e.clientX - rect.left,
1746
- y: e.clientY - rect.top
1747
- };
1748
- }
1749
 
1750
- // Unified Tool Handler
1751
- window.setPenTool = (tool, color, btn) => {
1752
- // UI Update
1753
- document.querySelectorAll('.annotation-tool').forEach(b => {
1754
- b.classList.remove('ring-white');
1755
- b.classList.add('ring-transparent');
1756
- });
1757
- btn.classList.remove('ring-transparent');
1758
- btn.classList.add('ring-white');
1759
-
1760
- if (tool === 'eraser') {
1761
- currentMode = 'destination-out';
1762
- // Force larger eraser size (e.g., 3x current size or fixed large)
1763
- // We'll multiply current selected size by 4 for better UX
1764
- const multiplier = 4;
1765
- // Store original explicitly if needed, but currentLineWidth is global.
1766
- // We should dynamically adjust context lineWidth during draw, or just hack it here.
1767
- // Hack: If we change currentLineWidth here, the UI size buttons might look wrong.
1768
- // Better: Update cursor style only? No, actual draw needs it.
1769
- // Let's set a separate 'actualWidth' used in draw, OR just change currentLineWidth temporarily?
1770
- // Simpler: Just change it. When user clicks size button, it resets.
1771
- // But if user clicks Pen back? We need to restore.
1772
- // Let's rely on setPenTool being called with color.
1773
- // When "Pen" is clicked, we usually don't call setPenTool with a saved size...
1774
- // Actually, let's just use a large default for eraser, and keep currentLineWidth for Pen.
1775
- // We need to change how draw() uses the width.
1776
- // BUT, since we don't want to touch draw() deep inside:
1777
- // We will hijack currentLineWidth.
1778
- if (!window.savedPenWidth) window.savedPenWidth = currentLineWidth;
1779
- currentLineWidth = window.savedPenWidth * 4;
1780
- } else {
1781
- currentMode = 'source-over';
1782
- currentPenColor = color;
1783
- // Restore pen width
1784
- if (window.savedPenWidth) {
1785
- currentLineWidth = window.savedPenWidth;
1786
- window.savedPenWidth = null;
1787
- }
1788
- }
1789
- updateCursorStyle();
1790
  };
1791
 
1792
- // Size Handler
1793
- window.setPenSize = (size, btn) => {
1794
- currentLineWidth = size;
1795
- updateSizeBtnUI();
1796
- updateCursorStyle();
1797
  };
1798
 
1799
- function updateCursorStyle() {
1800
- const cursor = document.getElementById('tool-cursor');
1801
- if (!cursor) return;
 
1802
 
1803
- // Size
1804
- cursor.style.width = `${currentLineWidth}px`;
1805
- cursor.style.height = `${currentLineWidth}px`;
 
 
1806
 
1807
- // Color
1808
- if (currentMode === 'destination-out') {
1809
- // Eraser: White solid
1810
- cursor.style.backgroundColor = 'white';
1811
- cursor.style.borderColor = '#999';
1812
- } else {
1813
- // Pen: Tool color
1814
- cursor.style.backgroundColor = currentPenColor;
1815
- cursor.style.borderColor = 'rgba(255,255,255,0.8)';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1816
  }
1817
  }
 
 
1818
 
1819
- function updateSizeBtnUI() {
1820
- document.querySelectorAll('.size-btn').forEach(b => {
1821
- if (parseInt(b.dataset.size) === currentLineWidth) {
1822
- b.classList.add('bg-gray-600', 'text-white');
1823
- b.classList.remove('text-gray-400', 'hover:bg-gray-700');
1824
- } else {
1825
- b.classList.remove('bg-gray-600', 'text-white');
1826
- b.classList.add('text-gray-400', 'hover:bg-gray-700');
1827
- }
1828
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1829
  }
 
1830
 
1831
- window.clearCanvas = () => {
1832
- if (canvas && ctx) {
1833
- ctx.clearRect(0, 0, canvas.width, canvas.height);
 
 
 
 
 
1834
  }
1835
- };
1836
  }
1837
 
1838
- /**
1839
- * Renders the Transposed Heatmap (Rows=Challenges, Cols=Students)
1840
- */
1841
- function renderTransposedHeatmap(students) {
1842
- const thead = document.getElementById('heatmap-header');
1843
- const tbody = document.getElementById('heatmap-body');
1844
-
1845
- if (students.length === 0) {
1846
- thead.innerHTML = '<th class="p-4 text-left">等待資料...</th>';
1847
- tbody.innerHTML = '<tr><td class="p-10 text-center text-gray-500">尚無學員加入</td></tr>';
1848
- return;
1849
  }
 
 
1850
 
1851
- // 1. Render Header (Students)
1852
- // Sticky Top for Header Row
1853
- // Sticky Left for the first cell ("Challenge/Student")
1854
- let headerHtml = `
 
 
 
 
 
 
 
 
 
 
 
 
 
1855
  <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">
1856
  <div class="flex justify-between items-end">
1857
  <span class="text-sm text-gray-400">題目</span>
@@ -1860,8 +1866,8 @@ export function setupInstructorEvents() {
1860
  </th>
1861
  `;
1862
 
1863
- students.forEach(student => {
1864
- headerHtml += `
1865
  <th class="p-2 text-center sticky top-0 bg-gray-800 z-20 border-b border-gray-700 min-w-[80px] group">
1866
  <div class="flex flex-col items-center space-y-2 py-2">
1867
  <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">
@@ -1880,59 +1886,59 @@ export function setupInstructorEvents() {
1880
  </div>
1881
  </th>
1882
  `;
1883
- });
1884
- thead.innerHTML = headerHtml;
1885
 
1886
- // 2. Render Body (Challenges as Rows)
1887
- if (cachedChallenges.length === 0) {
1888
- tbody.innerHTML = '<tr><td colspan="100" class="text-center py-4">沒有題目資料</td></tr>';
1889
- return;
1890
- }
1891
 
1892
- tbody.innerHTML = cachedChallenges.map((c, index) => {
1893
- const colors = { beginner: 'cyan', intermediate: 'blue', advanced: 'purple' };
1894
- const color = colors[c.level] || 'gray';
1895
-
1896
- // Build Row Cells (One per student)
1897
- const rowCells = students.map(student => {
1898
- const p = student.progress?.[c.id];
1899
- let statusClass = 'bg-gray-800/30 border-gray-800'; // Default
1900
- let content = '';
1901
- let action = '';
1902
-
1903
- if (p) {
1904
- if (p.status === 'completed') {
1905
- statusClass = 'bg-green-500/20 border-green-500/50 hover:bg-green-500/40 cursor-default shadow-[0_0_10px_rgba(34,197,94,0.1)]';
1906
- content = '✅';
1907
- // Action removed: Moved to prompt list view
1908
- action = `title="完成 - 請點擊標題查看詳情"`;
1909
- } else if (p.status === 'started') {
1910
- // Check stuck
1911
- const startedAt = p.timestamp ? p.timestamp.toDate() : new Date();
1912
- const now = new Date();
1913
- const diffMins = (now - startedAt) / 1000 / 60;
1914
-
1915
- if (diffMins > 5) {
1916
- statusClass = 'bg-red-900/50 border-red-500 animate-pulse cursor-help';
1917
- content = '🆘';
1918
- } else {
1919
- statusClass = 'bg-blue-600/20 border-blue-500';
1920
- content = '🔵';
1921
- }
1922
  }
1923
  }
 
1924
 
1925
- return `
1926
  <td class="p-1 border border-gray-800/50 text-center align-middle h-14 hover:bg-white/5 transition-colors">
1927
  <div class="mx-auto w-10 h-10 rounded-lg border flex items-center justify-center ${statusClass} transition-all duration-300" ${action}>
1928
  ${content}
1929
  </div>
1930
  </td>
1931
  `;
1932
- }).join('');
1933
 
1934
- // Row Header (Challenge Title)
1935
- return `
1936
  <tr class="hover:bg-gray-800/50 transition-colors">
1937
  <td class="p-3 sticky left-0 bg-gray-900 z-10 border-r border-b border-gray-700 shadow-md">
1938
  <div class="flex items-center justify-between">
@@ -1949,42 +1955,42 @@ export function setupInstructorEvents() {
1949
  ${rowCells}
1950
  </tr>
1951
  `;
1952
- }).join('');
1953
- }
1954
-
1955
- // Global scope for HTML access
1956
- // Global scope for HTML access
1957
- window.showBroadcastModal = (userId, challengeId) => {
1958
- const student = currentStudents.find(s => s.id === userId);
1959
- if (!student) return;
1960
-
1961
- const p = student.progress?.[challengeId];
1962
- if (!p) return;
1963
-
1964
- const challenge = cachedChallenges.find(c => c.id === challengeId);
1965
- const title = challenge ? challenge.title : 'Unknown Challenge'; // Fallback
1966
-
1967
- const modal = document.getElementById('broadcast-modal');
1968
- const content = document.getElementById('broadcast-content');
1969
-
1970
- document.getElementById('broadcast-avatar').textContent = student.nickname[0];
1971
- document.getElementById('broadcast-author').textContent = student.nickname;
1972
- document.getElementById('broadcast-challenge').textContent = title;
1973
- // content is already just text, but let's be safe
1974
- const rawText = p.prompt || p.code || '';
1975
- const isCode = !p.prompt && !!p.code; // If only code exists, treat as code
1976
- document.getElementById('broadcast-prompt').textContent = cleanText(rawText, isCode);
1977
- document.getElementById('broadcast-prompt').style.textAlign = isCode ? 'left' : 'left'; // Always left, but explicit
1978
-
1979
- // Store IDs for actions
1980
- modal.dataset.userId = userId;
1981
- modal.dataset.challengeId = challengeId;
1982
 
1983
- modal.classList.remove('hidden');
1984
- // Animation trigger
1985
- setTimeout(() => {
1986
- content.classList.remove('scale-95', 'opacity-0');
1987
- content.classList.add('opacity-100', 'scale-100');
1988
- }, 10);
1989
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1990
  }
 
720
 
721
  modal.classList.remove('hidden');
722
  });
723
+ }
724
 
725
+ // Add New Instructor
726
+ const addInstBtn = document.getElementById('btn-add-inst');
727
+ if (addInstBtn) {
728
+ addInstBtn.addEventListener('click', async () => {
729
+ const email = document.getElementById('new-inst-email').value.trim();
730
+ const name = document.getElementById('new-inst-name').value.trim();
731
 
732
+ if (!email || !name) return alert("請輸入完整資料");
733
 
734
+ const perms = [];
735
+ if (document.getElementById('perm-room').checked) perms.push('create_room');
736
+ if (document.getElementById('perm-q').checked) perms.push('add_question');
737
+ if (document.getElementById('perm-inst').checked) perms.push('manage_instructors');
738
 
739
+ try {
740
+ await addInstructor(email, name, perms);
741
+ alert("新增成功");
742
+ navInstBtn.click(); // Reload list
743
+ document.getElementById('new-inst-email').value = '';
744
+ document.getElementById('new-inst-name').value = '';
745
+ } catch (e) {
746
+ alert("新增失敗: " + e.message);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
747
  }
748
+ });
749
+ }
750
+
751
 
752
+ // Global helper for remove (hacky but works for simple onclick)
753
+ window.removeInst = async (email) => {
754
+ if (confirm(`確定移除 ${email}?`)) {
 
 
755
  try {
756
+ await removeInstructor(email);
757
+ navInstBtn.click(); // Reload
758
+ } catch (e) {
759
+ alert(e.message);
760
+ }
761
+ }
762
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
763
 
764
+ // Auto Check Auth (Persistence)
765
+ // We rely on Firebase Auth state observer instead of session storage for security?
766
+ // Or we can just check if user is already signed in.
767
+ import("../services/firebase.js").then(async ({ auth }) => {
768
+ // Handle Redirect Result first
769
+ try {
770
+ console.log("Initializing Auth Check...");
771
+ const { handleRedirectResult } = await import("../services/auth.js");
772
+ const redirectUser = await handleRedirectResult();
773
+ if (redirectUser) console.log("Redirect User Found:", redirectUser.email);
774
+ } catch (e) { console.warn("Redirect check failed", e); }
775
+
776
+ auth.onAuthStateChanged(async (user) => {
777
+ console.log("Auth State Changed to:", user ? user.email : "Logged Out");
778
+ if (user) {
779
+ try {
780
+ console.log("Checking permissions for:", user.email);
781
+ const instructorData = await checkInstructorPermission(user);
782
+ console.log("Permission Result:", instructorData);
783
+
784
+ if (instructorData) {
785
+ console.log("Hiding Modal and Setting Permissions...");
786
+ authModal.classList.add('hidden');
787
+ checkPermissions(instructorData);
788
+
789
+ // Auto-Restore Room View if exists
790
+ const savedRoomCode = localStorage.getItem('vibecoding_room_code');
791
+ if (savedRoomCode) {
792
+ console.log("Restoring Room Session:", savedRoomCode);
793
+ const roomInfo = document.getElementById('room-info');
794
+ const displayRoomCode = document.getElementById('display-room-code');
795
+ const createContainer = document.getElementById('create-room-container');
796
+ const dashboardContent = document.getElementById('dashboard-content');
797
+
798
+ // Restore UI
799
+ createContainer.classList.add('hidden');
800
+ roomInfo.classList.remove('hidden');
801
+ dashboardContent.classList.remove('hidden');
802
+ displayRoomCode.textContent = savedRoomCode;
803
+
804
+ // Re-subscribe locally using the existing updateDashboard logic if available,
805
+ // or we need to redefine the callback here.
806
+ // Since updateDashboard is inside createBtn scope, we can't mistakenly access it if it's not global.
807
+ // Wait, updateDashboard IS inside createBtn scope. That's a problem.
808
+ // We need to move updateDashboard out or duplicate the logic here.
809
+ // Duplicating logic for robustness:
810
+ subscribeToRoom(savedRoomCode, (data) => {
811
+ const users = Array.isArray(data) ? data : (data?.users ? Object.values(data.users) : []);
812
+ currentStudents = users;
813
+ renderTransposedHeatmap(users);
814
+ });
815
  }
816
+
817
+ } else {
818
+ console.warn("User logged in but not an instructor.");
819
+ // Show unauthorized message
820
+ authErrorMsg.textContent = "此帳號無講師權限";
821
  authErrorMsg.classList.remove('hidden');
822
+ authModal.classList.remove('hidden'); // Ensure modal stays up
823
  }
824
+ } catch (e) {
825
+ console.error("Permission Check Failed:", e);
826
+ authErrorMsg.textContent = "權限檢查失敗: " + e.message;
827
+ authErrorMsg.classList.remove('hidden');
828
  }
829
+ } else {
830
+ authModal.classList.remove('hidden');
831
+ }
832
  });
833
+ });
834
 
835
+ // Define Kick Function globally (robust against auth flow)
836
+ window.confirmKick = async (userId, nickname) => {
837
+ if (confirm(`確定要踢出 ${nickname} 嗎?此動作無法復原。`)) {
838
+ try {
839
+ const { removeUser } = await import("../services/classroom.js");
840
+ await removeUser(userId);
841
+ // UI will update automatically via subscribeToRoom
842
+ } catch (e) {
843
+ console.error("Kick failed:", e);
844
+ alert("移除失敗");
 
845
  }
846
+ }
847
+ };
848
 
849
 
850
+ // Snapshot Logic
851
+ // Snapshot Logic
852
+ if (snapshotBtn) {
853
  snapshotBtn.addEventListener('click', async () => {
854
  if (isSnapshotting || typeof htmlToImage === 'undefined') {
855
  if (typeof htmlToImage === 'undefined') alert("截圖元件尚未載入,請稍候再試");
 
950
  }
951
  }, 600); // Slight delay for emojis to appear
952
  });
953
+ }
954
 
955
+ // Group Photo Logic
956
+ if (groupPhotoBtn) {
957
  groupPhotoBtn.addEventListener('click', () => {
958
  const modal = document.getElementById('group-photo-modal');
959
  const container = document.getElementById('group-photo-container');
 
1135
 
1136
  modal.classList.remove('hidden');
1137
  });
1138
+ }
1139
 
1140
+ // Helper: Drag & Drop Logic
1141
+ function setupDraggable(el, container) {
1142
+ let isDragging = false;
1143
+ let startX, startY, initialLeft, initialTop;
1144
 
1145
+ el.addEventListener('mousedown', (e) => {
1146
+ isDragging = true;
1147
+ startX = e.clientX;
1148
+ startY = e.clientY;
1149
 
1150
+ // Disable transition during drag for responsiveness
1151
+ el.style.transition = 'none';
1152
+ el.style.zIndex = 100; // Bring to front
1153
 
1154
+ // Convert current computed position to fixed pixels if relying on calc
1155
+ const rect = el.getBoundingClientRect();
1156
+ const containerRect = container.getBoundingClientRect();
1157
 
1158
+ // Calculate position relative to container
1159
+ // The current transform is translate(-50%, -50%).
1160
+ // We want to set left/top such that the center remains under the mouse offset,
1161
+ // but for simplicity, let's just use current offsetLeft/Top if possible,
1162
+ // OR robustly recalculate from rects.
1163
 
1164
+ // Current center point relative to container:
1165
+ const centerX = rect.left - containerRect.left + rect.width / 2;
1166
+ const centerY = rect.top - containerRect.top + rect.height / 2;
1167
 
1168
+ // Set explicit pixel values replacing calc()
1169
+ el.style.left = `${centerX}px`;
1170
+ el.style.top = `${centerY}px`;
1171
 
1172
+ initialLeft = centerX;
1173
+ initialTop = centerY;
1174
+ });
1175
 
1176
+ window.addEventListener('mousemove', (e) => {
1177
+ if (!isDragging) return;
1178
+ e.preventDefault();
1179
 
1180
+ const dx = e.clientX - startX;
1181
+ const dy = e.clientY - startY;
1182
 
1183
+ el.style.left = `${initialLeft + dx}px`;
1184
+ el.style.top = `${initialTop + dy}px`;
1185
+ });
1186
 
1187
+ window.addEventListener('mouseup', () => {
1188
+ if (isDragging) {
1189
+ isDragging = false;
1190
+ el.style.transition = ''; // Re-enable hover effects
1191
+ el.style.zIndex = ''; // Restore z-index rule (or let hover take over)
1192
+ }
1193
+ });
1194
+ }
1195
 
1196
+ // Add float animation style if not exists
1197
+ if (!document.getElementById('anim-float')) {
1198
+ const style = document.createElement('style');
1199
+ style.id = 'anim-float';
1200
+ style.innerHTML = `
1201
  @keyframes float {
1202
 
1203
  0 %, 100 % { transform: translateY(0) scale(1); }
 
1205
  }
1206
  }
1207
  `;
1208
+ document.head.appendChild(style);
1209
+ }
 
 
 
 
 
1210
 
1211
+ // Gallery Logic
1212
+ document.getElementById('btn-open-gallery').addEventListener('click', () => {
1213
+ window.open('monster_preview.html', '_blank');
1214
+ });
1215
+
1216
+ // Logout Logic
1217
+ document.getElementById('logout-btn').addEventListener('click', async () => {
1218
+ if (confirm('確定要登出講師模式嗎? (將會回到首頁)')) {
1219
+ await signOutUser();
1220
+ sessionStorage.removeItem('vibecoding_instructor_in_room');
1221
+ sessionStorage.removeItem('vibecoding_admin_referer');
1222
+ window.location.hash = '';
1223
+ window.location.reload();
1224
+ }
1225
+ });
1226
 
1227
+ // Check Previous Session (Handled by onAuthStateChanged now)
1228
+ // if (sessionStorage.getItem('vibecoding_instructor_auth') === 'true') {
1229
+ // authModal.classList.add('hidden');
1230
+ // }
1231
 
1232
+ // Check Active Room State
1233
+ const activeRoom = sessionStorage.getItem('vibecoding_instructor_in_room');
1234
+ if (activeRoom === 'true' && savedRoomCode) {
1235
+ enterRoom(savedRoomCode);
1236
+ }
1237
 
1238
+ // Module-level variable to track subscription (Moved to top)
1239
 
1240
+ function enterRoom(roomCode) {
1241
+ createContainer.classList.add('hidden');
1242
+ roomInfo.classList.remove('hidden');
1243
+ dashboardContent.classList.remove('hidden');
1244
+ document.getElementById('group-photo-btn').classList.remove('hidden'); // Show photo button
1245
+ displayRoomCode.textContent = roomCode;
1246
+ localStorage.setItem('vibecoding_room_code', roomCode);
1247
+ sessionStorage.setItem('vibecoding_instructor_in_room', 'true');
1248
 
1249
+ // Unsubscribe previous if any
1250
+ if (roomUnsubscribe) roomUnsubscribe();
1251
 
1252
+ // Subscribe to updates
1253
+ roomUnsubscribe = subscribeToRoom(roomCode, (students) => {
1254
+ currentStudents = students;
1255
+ renderTransposedHeatmap(students);
1256
+ });
1257
+ }
1258
 
1259
+ // Leave Room Logic
1260
+ document.getElementById('leave-room-btn').addEventListener('click', () => {
1261
+ if (confirm('確定要離開目前教室嗎?(不會刪除教室資料,僅回到選擇介面)')) {
1262
+ // Unsubscribe
1263
+ if (roomUnsubscribe) {
1264
+ roomUnsubscribe();
1265
+ roomUnsubscribe = null;
1266
+ }
1267
 
1268
+ // UI Reset
1269
+ createContainer.classList.remove('hidden');
1270
+ roomInfo.classList.add('hidden');
1271
+ dashboardContent.classList.add('hidden');
1272
+ document.getElementById('group-photo-btn').classList.add('hidden'); // Hide photo button
1273
 
1274
+ // Clear Data Display
1275
+ document.getElementById('heatmap-body').innerHTML = '<tr><td colspan="100" class="text-center py-10 text-gray-500">等待資料載入...</td></tr>';
1276
+ document.getElementById('heatmap-header').innerHTML = '<th class="p-3 text-left sticky left-0 bg-gray-800 z-20 border-b border-gray-700 min-w-[150px]">學員 / 關卡</th>';
1277
 
1278
+ // State Clear
1279
+ sessionStorage.removeItem('vibecoding_instructor_in_room');
1280
+ localStorage.removeItem('vibecoding_room_code');
1281
+ }
1282
+ });
1283
 
1284
+ // Modal Events
1285
+ window.showBroadcastModal = (userId, challengeId) => {
1286
+ const modal = document.getElementById('broadcast-modal');
1287
+ const content = document.getElementById('broadcast-content');
1288
 
1289
+ // Find Data
1290
+ const student = currentStudents.find(s => s.id === userId);
1291
+ if (!student) return alert('找不到學員資料');
1292
 
1293
+ const p = student.progress ? student.progress[challengeId] : null;
1294
+ if (!p) return alert('找不到該作品資料');
1295
 
1296
+ const challenge = cachedChallenges.find(c => c.id === challengeId);
1297
+ const title = challenge ? challenge.title : '未知題目';
1298
 
1299
+ // Populate UI
1300
+ document.getElementById('broadcast-avatar').textContent = student.nickname[0] || '?';
1301
+ document.getElementById('broadcast-author').textContent = student.nickname;
1302
+ document.getElementById('broadcast-challenge').textContent = title;
1303
+ document.getElementById('broadcast-prompt').textContent = p.prompt || '(無內容)';
1304
 
1305
+ // Store IDs for Actions (Reject/BroadcastAll)
1306
+ modal.dataset.userId = userId;
1307
+ modal.dataset.challengeId = challengeId;
1308
 
1309
+ // Show
1310
+ modal.classList.remove('hidden');
1311
+ setTimeout(() => {
1312
+ content.classList.remove('scale-95', 'opacity-0');
1313
+ content.classList.add('opacity-100', 'scale-100');
1314
+ }, 10);
1315
+ };
1316
 
1317
+ window.closeBroadcast = () => {
1318
+ const modal = document.getElementById('broadcast-modal');
1319
+ const content = document.getElementById('broadcast-content');
1320
+ content.classList.remove('opacity-100', 'scale-100');
1321
+ content.classList.add('scale-95', 'opacity-0');
1322
+ setTimeout(() => modal.classList.add('hidden'), 300);
1323
+ };
1324
 
1325
+ window.openStage = (prompt, author) => {
1326
+ document.getElementById('broadcast-content').classList.add('hidden');
1327
+ const stage = document.getElementById('stage-view');
1328
+ stage.classList.remove('hidden');
1329
+ document.getElementById('stage-prompt').textContent = cleanText(prompt || '');
1330
+ document.getElementById('stage-author').textContent = author;
1331
+ };
1332
 
1333
+ window.closeStage = () => {
1334
+ document.getElementById('stage-view').classList.add('hidden');
1335
+ document.getElementById('broadcast-content').classList.remove('hidden');
1336
+ };
1337
 
1338
+ document.getElementById('btn-show-stage').addEventListener('click', () => {
1339
+ const prompt = document.getElementById('broadcast-prompt').textContent;
1340
+ const author = document.getElementById('broadcast-author').textContent;
1341
+ window.openStage(prompt, author);
1342
+ });
1343
 
1344
+ // Reject Logic
1345
+ document.getElementById('btn-reject-task').addEventListener('click', async () => {
1346
+ if (!confirm('確定要退回此題目讓學員重做嗎?')) return;
1347
 
1348
+ // We need student ID (userId) and Challenge ID.
1349
+ // Currently showBroadcastModal only receives nickname, title, prompt.
1350
+ // We need to attach data-userid and data-challengeid to the modal.
1351
+ const modal = document.getElementById('broadcast-modal');
1352
+ const userId = modal.dataset.userId;
1353
+ const challengeId = modal.dataset.challengeId;
1354
+ const roomCode = localStorage.getItem('vibecoding_room_code');
1355
 
1356
+ if (userId && challengeId && roomCode) {
1357
+ try {
1358
+ await resetProgress(userId, roomCode, challengeId);
1359
+ alert('已成功退回,學員可重新作答');
1360
+ // Close modal
1361
+ window.closeBroadcast();
1362
+ } catch (e) {
1363
+ console.error('退回失敗:', e);
1364
+ alert('退回失敗: ' + e.message);
 
1365
  }
1366
+ }
1367
+ });
1368
+ // Prompt Viewer Logic
1369
+ window.openPromptList = (type, id, title) => {
1370
+ const modal = document.getElementById('prompt-list-modal');
1371
+ const container = document.getElementById('prompt-list-container');
1372
+ const titleEl = document.getElementById('prompt-list-title');
1373
 
1374
+ titleEl.textContent = type === 'student' ? `${title} 的所有提示詞` : `題目:${title} 的所有作品`;
1375
 
1376
+ // Reset Anonymous Toggle in List View
1377
+ const anonCheck = document.getElementById('list-anonymous-toggle');
1378
+ if (anonCheck) anonCheck.checked = false;
1379
 
1380
+ container.innerHTML = '';
1381
+ modal.classList.remove('hidden');
1382
 
1383
+ // Collect Prompts
1384
+ let prompts = [];
1385
+ // Fix: Reset selection when opening new list to prevent cross-contamination
1386
+ selectedPrompts = [];
1387
+ updateCompareButton();
1388
+
1389
+ if (type === 'student') {
1390
+ const student = currentStudents.find(s => s.id === id);
1391
+ if (student && student.progress) {
1392
+ prompts = Object.entries(student.progress)
1393
+ .filter(([_, p]) => p.status === 'completed' && p.prompt)
1394
+ .map(([challengeId, p]) => {
1395
+ const challenge = cachedChallenges.find(c => c.id === challengeId);
1396
+ return {
1397
+ id: `${student.id}_${challengeId}`,
1398
+ title: challenge ? challenge.title : '未知題目',
1399
+ prompt: p.prompt,
1400
+ author: student.nickname,
1401
+ studentId: student.id,
1402
+ challengeId: challengeId,
1403
+ time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : ''
1404
+ };
1405
+ });
1406
+ }
1407
+ } else if (type === 'challenge') {
1408
+ currentStudents.forEach(student => {
1409
+ if (student.progress && student.progress[id]) {
1410
+ const p = student.progress[id];
1411
+ if (p.status === 'completed' && p.prompt) {
1412
+ prompts.push({
1413
+ id: `${student.id}_${id}`,
1414
+ title: student.nickname, // When viewing challenge, title is student name
1415
+ prompt: p.prompt,
1416
+ author: student.nickname,
1417
+ studentId: student.id,
1418
+ challengeId: id,
1419
+ time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : ''
1420
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1421
  }
1422
+ }
1423
+ });
1424
+ }
1425
 
1426
+ if (prompts.length === 0) {
1427
+ container.innerHTML = '<div class="col-span-full text-center text-gray-500 py-10">無資料</div>';
1428
+ return;
1429
+ }
1430
 
1431
+ prompts.forEach(p => {
1432
+ const card = document.createElement('div');
1433
+ // Reduced height (h-64 -> h-48) and padding, but larger text inside
1434
+ card.className = 'bg-gray-800 rounded-xl p-3 border border-gray-700 hover:border-cyan-500 transition-colors flex flex-col h-48 group';
1435
+ card.innerHTML = `
1436
  <div class="flex justify-between items-start mb-1.5">
1437
  <h3 class="font-bold text-white text-base truncate w-3/4" title="${p.title}">${p.title}</h3>
1438
  <!-- Checkbox -->
 
1453
  </div>
1454
  </div>
1455
  `;
1456
+ container.appendChild(card);
1457
+ });
1458
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1459
 
1460
+ // Helper Actions
1461
+ window.confirmReset = async (userId, challengeId, title) => {
1462
+ console.log('🔵 confirmReset called');
1463
+ console.log(' userId:', userId);
1464
+ console.log(' challengeId:', challengeId);
1465
+ console.log(' title:', title);
1466
+ console.log(' typeof resetProgress:', typeof resetProgress);
1467
 
1468
+ if (confirm(`確定要退回 ${title} 嗎?此動作將清除學員目前的進度。`)) {
1469
+ console.log('✅ User confirmed reset');
1470
+ const roomCode = localStorage.getItem('vibecoding_room_code');
1471
+ console.log(' roomCode:', roomCode);
1472
 
1473
+ if (userId && challengeId && roomCode) {
1474
+ console.log('✅ All parameters valid, calling resetProgress...');
1475
+ try {
1476
+ // Use already imported resetProgress function
1477
+ await resetProgress(userId, roomCode, challengeId);
1478
+ console.log('✅ resetProgress completed successfully');
1479
+ alert("已退回");
1480
+ // close modal to refresh data context
1481
+ document.getElementById('prompt-list-modal').classList.add('hidden');
1482
+ console.log('✅ Modal closed');
1483
+ } catch (e) {
1484
+ console.error("❌ 退回失敗:", e);
1485
+ console.error("Error stack:", e.stack);
1486
+ alert("退回失敗: " + e.message);
1487
  }
 
1488
  } else {
1489
+ console.error('❌ Missing required parameters:');
1490
+ console.error(' userId:', userId, '(valid:', !!userId, ')');
1491
+ console.error(' challengeId:', challengeId, '(valid:', !!challengeId, ')');
1492
+ console.error(' roomCode:', roomCode, '(valid:', !!roomCode, ')');
1493
+ alert('缺少必要參數,無法執行退回操作');
1494
  }
1495
+ } else {
1496
+ console.log('❌ User cancelled reset');
1497
+ }
1498
+ };
1499
 
1500
+ window.broadcastPrompt = (userId, challengeId) => {
1501
+ window.showBroadcastModal(userId, challengeId);
1502
+ };
1503
 
1504
+ // Selection Logic
1505
+ let selectedPrompts = []; // Stores IDs
 
1506
 
1507
+ window.handlePromptSelection = (checkbox) => {
1508
+ const id = checkbox.dataset.id;
1509
+
1510
+ if (checkbox.checked) {
1511
+ if (selectedPrompts.length >= 3) {
1512
+ checkbox.checked = false;
1513
+ alert('最多只能選擇 3 個提示詞進行比較');
1514
+ return;
1515
  }
1516
+ selectedPrompts.push(id);
1517
+ } else {
1518
+ selectedPrompts = selectedPrompts.filter(pid => pid !== id);
1519
  }
1520
+ updateCompareButton();
1521
+ };
 
 
 
 
 
 
 
 
 
 
 
 
1522
 
1523
+ function updateCompareButton() {
1524
+ const btn = document.getElementById('btn-compare-prompts');
1525
+ if (!btn) return;
 
 
 
 
 
1526
 
1527
+ const count = selectedPrompts.length;
1528
+ const span = btn.querySelector('span');
1529
+ if (span) span.textContent = `🔍 比較已選項目 (${count}/3)`;
 
1530
 
1531
+ if (count > 0) {
1532
+ btn.disabled = false;
1533
+ btn.classList.remove('opacity-50', 'cursor-not-allowed');
1534
+ } else {
1535
+ btn.disabled = true;
1536
+ btn.classList.add('opacity-50', 'cursor-not-allowed');
1537
+ }
1538
+ }
1539
+ // Comparison Logic
1540
+ const compareBtn = document.getElementById('btn-compare-prompts');
1541
+ if (compareBtn) {
1542
+ compareBtn.addEventListener('click', () => {
1543
+ const dataToCompare = [];
1544
+ selectedPrompts.forEach(fullId => {
1545
+ const lastUnderscore = fullId.lastIndexOf('_');
1546
+ const studentId = fullId.substring(0, lastUnderscore);
1547
+ const challengeId = fullId.substring(lastUnderscore + 1);
1548
+
1549
+ const student = currentStudents.find(s => s.id === studentId);
1550
+ if (student && student.progress && student.progress[challengeId]) {
1551
+ const p = student.progress[challengeId];
1552
+ const challenge = cachedChallenges.find(c => c.id === challengeId);
1553
+
1554
+ dataToCompare.push({
1555
+ title: challenge ? challenge.title : '未知',
1556
+ author: student.nickname,
1557
+ prompt: p.prompt,
1558
+ time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleTimeString() : ''
1559
+ });
1560
  }
1561
  });
 
1562
 
1563
+ const isAnon = document.getElementById('list-anonymous-toggle')?.checked || false;
1564
+ openComparisonView(dataToCompare, isAnon);
1565
+ });
1566
+ }
1567
 
1568
+ let isAnonymous = false;
1569
+
1570
+ window.toggleAnonymous = (btn) => {
1571
+ isAnonymous = !isAnonymous;
1572
+ btn.textContent = isAnonymous ? '🙈 顯示姓名' : '👀 隱藏姓名';
1573
+ btn.classList.toggle('bg-gray-700');
1574
+ btn.classList.toggle('bg-purple-700');
1575
+
1576
+ // Update DOM
1577
+ document.querySelectorAll('.comparison-author').forEach(el => {
1578
+ if (isAnonymous) {
1579
+ el.dataset.original = el.textContent;
1580
+ el.textContent = '學員';
1581
+ el.classList.add('blur-sm'); // Optional Effect
1582
+ setTimeout(() => el.classList.remove('blur-sm'), 300);
1583
+ } else {
1584
+ if (el.dataset.original) el.textContent = el.dataset.original;
1585
+ }
1586
+ });
1587
+ };
1588
 
1589
+ window.openComparisonView = (items, initialAnonymous = false) => {
1590
+ const modal = document.getElementById('comparison-modal');
1591
+ const grid = document.getElementById('comparison-grid');
1592
+
1593
+ // Apply Anonymous State
1594
+ isAnonymous = initialAnonymous;
1595
+ const anonBtn = document.getElementById('btn-anonymous-toggle');
1596
+
1597
+ // Update Toggle UI to match state
1598
+ if (anonBtn) {
1599
+ if (isAnonymous) {
1600
+ anonBtn.textContent = '🙈 顯示姓名';
1601
+ anonBtn.classList.add('bg-purple-700');
1602
+ anonBtn.classList.remove('bg-gray-700');
1603
+ } else {
1604
+ anonBtn.textContent = '👀 隱藏姓名';
1605
+ anonBtn.classList.remove('bg-purple-700');
1606
+ anonBtn.classList.add('bg-gray-700');
1607
  }
1608
+ }
1609
 
1610
+ // Setup Grid Rows (Vertical Stacking)
1611
+ let rowClass = 'grid-rows-1';
1612
+ if (items.length === 2) rowClass = 'grid-rows-2';
1613
+ if (items.length === 3) rowClass = 'grid-rows-3';
1614
+
1615
+ grid.className = `absolute inset-0 grid ${rowClass} gap-0 divide-y divide-gray-600`;
1616
+ grid.innerHTML = '';
1617
+
1618
+ items.forEach(item => {
1619
+ const col = document.createElement('div');
1620
+ // Check overflow-hidden to keep it contained, use flex-row for compact header + content
1621
+ col.className = 'flex flex-row h-full bg-gray-900 p-4 overflow-hidden';
1622
+
1623
+ // Logic for anonymous
1624
+ let displayAuthor = item.author;
1625
+ let blurClass = '';
1626
+
1627
+ if (isAnonymous) {
1628
+ displayAuthor = '學員';
1629
+ blurClass = 'blur-sm'; // Initial blur
1630
+ // Auto remove blur after delay if needed, or keep it?
1631
+ // Toggle logic removes it after delay. But initial render should probably just be static '學員' or blurred.
1632
+ // The toggle logic uses dataset.original. We need to set it here too.
1633
+ }
1634
 
1635
+ col.innerHTML = `
1636
  <div class="w-48 flex-shrink-0 border-r border-gray-700 pr-4 mr-4 flex flex-col justify-center">
1637
  <h3 class="text-xl font-bold text-cyan-400 mb-1 comparison-author ${blurClass}" data-original="${item.author}">${displayAuthor}</h3>
1638
  <p class="text-md text-gray-400 truncate" title="${item.title}">${item.title}</p>
 
1641
  <!-- Prompt Content: Larger Text (text-4xl) -->
1642
  <div class="flex-1 overflow-y-auto font-mono text-green-300 text-3xl leading-relaxed whitespace-pre-wrap p-2 hover:bg-white/5 transition-colors rounded custom-scrollbar text-left" style="text-align: left !important;">${cleanText(item.prompt)}</div>
1643
  `;
1644
+ grid.appendChild(col);
1645
+
1646
+ // If blurred, remove blur after animation purely for effect, or keep?
1647
+ // User intention "Hidden Name" usually means "Replaced by generic name".
1648
+ // The blur effect in toggle logic was transient.
1649
+ // If we want persistent anonymity, just "學員" is enough.
1650
+ // The existing toggle logic adds 'blur-sm' then removes it in 300ms.
1651
+ // We should replicate that effect if we want consistency, or just skip blur on init.
1652
+ if (isAnonymous) {
1653
+ const el = col.querySelector('.comparison-author');
1654
+ setTimeout(() => el.classList.remove('blur-sm'), 300);
1655
+ }
1656
+ });
1657
 
1658
+ document.getElementById('prompt-list-modal').classList.add('hidden');
1659
+ modal.classList.remove('hidden');
1660
 
1661
+ // Init Canvas (Phase 3)
1662
+ setTimeout(setupCanvas, 100);
1663
+ };
1664
+
1665
+ window.closeComparison = () => {
1666
+ document.getElementById('comparison-modal').classList.add('hidden');
1667
+ clearCanvas();
1668
+ };
1669
 
1670
+ // --- Phase 3 & 6: Annotation Tools ---
1671
+ let canvas, ctx;
1672
+ let isDrawing = false;
1673
+ let currentPenColor = '#ef4444'; // Red default
1674
+ let currentLineWidth = 3;
1675
+ let currentMode = 'source-over'; // 'source-over' (Pen) or 'destination-out' (Eraser)
1676
+
1677
+ window.setupCanvas = () => {
1678
+ canvas = document.getElementById('annotation-canvas');
1679
+ const container = document.getElementById('comparison-container');
1680
+ if (!canvas || !container) return;
1681
+
1682
+ ctx = canvas.getContext('2d');
1683
+
1684
+ // Resize
1685
+ const resize = () => {
1686
+ canvas.width = container.clientWidth;
1687
+ canvas.height = container.clientHeight;
1688
+ ctx.lineCap = 'round';
1689
+ ctx.lineJoin = 'round';
1690
+ ctx.strokeStyle = currentPenColor;
1691
+ ctx.lineWidth = currentLineWidth;
1692
+ ctx.globalCompositeOperation = currentMode;
1693
  };
1694
+ resize();
1695
+ window.addEventListener('resize', resize);
1696
+
1697
+ // Init Size UI & Cursor
1698
+ updateSizeBtnUI();
1699
+ updateCursorStyle();
1700
+
1701
+ // Cursor Logic
1702
+ const cursor = document.getElementById('tool-cursor');
1703
+
1704
+ canvas.addEventListener('mouseenter', () => cursor.classList.remove('hidden'));
1705
+ canvas.addEventListener('mouseleave', () => cursor.classList.add('hidden'));
1706
+ canvas.addEventListener('mousemove', (e) => {
1707
+ const { x, y } = getPos(e);
1708
+ cursor.style.left = `${x}px`;
1709
+ cursor.style.top = `${y}px`;
1710
+ });
1711
 
1712
+ // Drawing Events
1713
+ const start = (e) => {
1714
+ isDrawing = true;
1715
+ ctx.beginPath();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1716
 
1717
+ // Re-apply settings (state might change)
1718
+ ctx.globalCompositeOperation = currentMode;
1719
+ ctx.strokeStyle = currentPenColor;
1720
+ ctx.lineWidth = currentLineWidth;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1721
 
1722
+ const { x, y } = getPos(e);
1723
+ ctx.moveTo(x, y);
1724
+ };
 
 
 
 
1725
 
1726
+ const move = (e) => {
1727
+ if (!isDrawing) return;
1728
+ const { x, y } = getPos(e);
1729
+ ctx.lineTo(x, y);
1730
+ ctx.stroke();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1731
  };
1732
 
1733
+ const end = () => {
1734
+ isDrawing = false;
 
 
 
1735
  };
1736
 
1737
+ canvas.onmousedown = start;
1738
+ canvas.onmousemove = move;
1739
+ canvas.onmouseup = end;
1740
+ canvas.onmouseleave = end;
1741
 
1742
+ // Touch support
1743
+ canvas.ontouchstart = (e) => { e.preventDefault(); start(e.touches[0]); };
1744
+ canvas.ontouchmove = (e) => { e.preventDefault(); move(e.touches[0]); };
1745
+ canvas.ontouchend = (e) => { e.preventDefault(); end(); };
1746
+ };
1747
 
1748
+ function getPos(e) {
1749
+ const rect = canvas.getBoundingClientRect();
1750
+ return {
1751
+ x: e.clientX - rect.left,
1752
+ y: e.clientY - rect.top
1753
+ };
1754
+ }
1755
+
1756
+ // Unified Tool Handler
1757
+ window.setPenTool = (tool, color, btn) => {
1758
+ // UI Update
1759
+ document.querySelectorAll('.annotation-tool').forEach(b => {
1760
+ b.classList.remove('ring-white');
1761
+ b.classList.add('ring-transparent');
1762
+ });
1763
+ btn.classList.remove('ring-transparent');
1764
+ btn.classList.add('ring-white');
1765
+
1766
+ if (tool === 'eraser') {
1767
+ currentMode = 'destination-out';
1768
+ // Force larger eraser size (e.g., 3x current size or fixed large)
1769
+ // We'll multiply current selected size by 4 for better UX
1770
+ const multiplier = 4;
1771
+ // Store original explicitly if needed, but currentLineWidth is global.
1772
+ // We should dynamically adjust context lineWidth during draw, or just hack it here.
1773
+ // Hack: If we change currentLineWidth here, the UI size buttons might look wrong.
1774
+ // Better: Update cursor style only? No, actual draw needs it.
1775
+ // Let's set a separate 'actualWidth' used in draw, OR just change currentLineWidth temporarily?
1776
+ // Simpler: Just change it. When user clicks size button, it resets.
1777
+ // But if user clicks Pen back? We need to restore.
1778
+ // Let's rely on setPenTool being called with color.
1779
+ // When "Pen" is clicked, we usually don't call setPenTool with a saved size...
1780
+ // Actually, let's just use a large default for eraser, and keep currentLineWidth for Pen.
1781
+ // We need to change how draw() uses the width.
1782
+ // BUT, since we don't want to touch draw() deep inside:
1783
+ // We will hijack currentLineWidth.
1784
+ if (!window.savedPenWidth) window.savedPenWidth = currentLineWidth;
1785
+ currentLineWidth = window.savedPenWidth * 4;
1786
+ } else {
1787
+ currentMode = 'source-over';
1788
+ currentPenColor = color;
1789
+ // Restore pen width
1790
+ if (window.savedPenWidth) {
1791
+ currentLineWidth = window.savedPenWidth;
1792
+ window.savedPenWidth = null;
1793
  }
1794
  }
1795
+ updateCursorStyle();
1796
+ };
1797
 
1798
+ // Size Handler
1799
+ window.setPenSize = (size, btn) => {
1800
+ currentLineWidth = size;
1801
+ updateSizeBtnUI();
1802
+ updateCursorStyle();
1803
+ };
1804
+
1805
+ function updateCursorStyle() {
1806
+ const cursor = document.getElementById('tool-cursor');
1807
+ if (!cursor) return;
1808
+
1809
+ // Size
1810
+ cursor.style.width = `${currentLineWidth}px`;
1811
+ cursor.style.height = `${currentLineWidth}px`;
1812
+
1813
+ // Color
1814
+ if (currentMode === 'destination-out') {
1815
+ // Eraser: White solid
1816
+ cursor.style.backgroundColor = 'white';
1817
+ cursor.style.borderColor = '#999';
1818
+ } else {
1819
+ // Pen: Tool color
1820
+ cursor.style.backgroundColor = currentPenColor;
1821
+ cursor.style.borderColor = 'rgba(255,255,255,0.8)';
1822
  }
1823
+ }
1824
 
1825
+ function updateSizeBtnUI() {
1826
+ document.querySelectorAll('.size-btn').forEach(b => {
1827
+ if (parseInt(b.dataset.size) === currentLineWidth) {
1828
+ b.classList.add('bg-gray-600', 'text-white');
1829
+ b.classList.remove('text-gray-400', 'hover:bg-gray-700');
1830
+ } else {
1831
+ b.classList.remove('bg-gray-600', 'text-white');
1832
+ b.classList.add('text-gray-400', 'hover:bg-gray-700');
1833
  }
1834
+ });
1835
  }
1836
 
1837
+ window.clearCanvas = () => {
1838
+ if (canvas && ctx) {
1839
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
 
 
 
 
 
 
 
 
1840
  }
1841
+ };
1842
+ }
1843
 
1844
+ /**
1845
+ * Renders the Transposed Heatmap (Rows=Challenges, Cols=Students)
1846
+ */
1847
+ function renderTransposedHeatmap(students) {
1848
+ const thead = document.getElementById('heatmap-header');
1849
+ const tbody = document.getElementById('heatmap-body');
1850
+
1851
+ if (students.length === 0) {
1852
+ thead.innerHTML = '<th class="p-4 text-left">等待資料...</th>';
1853
+ tbody.innerHTML = '<tr><td class="p-10 text-center text-gray-500">尚無學員加入</td></tr>';
1854
+ return;
1855
+ }
1856
+
1857
+ // 1. Render Header (Students)
1858
+ // Sticky Top for Header Row
1859
+ // Sticky Left for the first cell ("Challenge/Student")
1860
+ let headerHtml = `
1861
  <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">
1862
  <div class="flex justify-between items-end">
1863
  <span class="text-sm text-gray-400">題目</span>
 
1866
  </th>
1867
  `;
1868
 
1869
+ students.forEach(student => {
1870
+ headerHtml += `
1871
  <th class="p-2 text-center sticky top-0 bg-gray-800 z-20 border-b border-gray-700 min-w-[80px] group">
1872
  <div class="flex flex-col items-center space-y-2 py-2">
1873
  <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">
 
1886
  </div>
1887
  </th>
1888
  `;
1889
+ });
1890
+ thead.innerHTML = headerHtml;
1891
 
1892
+ // 2. Render Body (Challenges as Rows)
1893
+ if (cachedChallenges.length === 0) {
1894
+ tbody.innerHTML = '<tr><td colspan="100" class="text-center py-4">沒有題目資料</td></tr>';
1895
+ return;
1896
+ }
1897
 
1898
+ tbody.innerHTML = cachedChallenges.map((c, index) => {
1899
+ const colors = { beginner: 'cyan', intermediate: 'blue', advanced: 'purple' };
1900
+ const color = colors[c.level] || 'gray';
1901
+
1902
+ // Build Row Cells (One per student)
1903
+ const rowCells = students.map(student => {
1904
+ const p = student.progress?.[c.id];
1905
+ let statusClass = 'bg-gray-800/30 border-gray-800'; // Default
1906
+ let content = '';
1907
+ let action = '';
1908
+
1909
+ if (p) {
1910
+ if (p.status === 'completed') {
1911
+ statusClass = 'bg-green-500/20 border-green-500/50 hover:bg-green-500/40 cursor-default shadow-[0_0_10px_rgba(34,197,94,0.1)]';
1912
+ content = '✅';
1913
+ // Action removed: Moved to prompt list view
1914
+ action = `title="完成 - 請點擊標題查看詳情"`;
1915
+ } else if (p.status === 'started') {
1916
+ // Check stuck
1917
+ const startedAt = p.timestamp ? p.timestamp.toDate() : new Date();
1918
+ const now = new Date();
1919
+ const diffMins = (now - startedAt) / 1000 / 60;
1920
+
1921
+ if (diffMins > 5) {
1922
+ statusClass = 'bg-red-900/50 border-red-500 animate-pulse cursor-help';
1923
+ content = '🆘';
1924
+ } else {
1925
+ statusClass = 'bg-blue-600/20 border-blue-500';
1926
+ content = '🔵';
 
1927
  }
1928
  }
1929
+ }
1930
 
1931
+ return `
1932
  <td class="p-1 border border-gray-800/50 text-center align-middle h-14 hover:bg-white/5 transition-colors">
1933
  <div class="mx-auto w-10 h-10 rounded-lg border flex items-center justify-center ${statusClass} transition-all duration-300" ${action}>
1934
  ${content}
1935
  </div>
1936
  </td>
1937
  `;
1938
+ }).join('');
1939
 
1940
+ // Row Header (Challenge Title)
1941
+ return `
1942
  <tr class="hover:bg-gray-800/50 transition-colors">
1943
  <td class="p-3 sticky left-0 bg-gray-900 z-10 border-r border-b border-gray-700 shadow-md">
1944
  <div class="flex items-center justify-between">
 
1955
  ${rowCells}
1956
  </tr>
1957
  `;
1958
+ }).join('');
1959
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1960
 
1961
+ // Global scope for HTML access
1962
+ // Global scope for HTML access
1963
+ window.showBroadcastModal = (userId, challengeId) => {
1964
+ const student = currentStudents.find(s => s.id === userId);
1965
+ if (!student) return;
1966
+
1967
+ const p = student.progress?.[challengeId];
1968
+ if (!p) return;
1969
+
1970
+ const challenge = cachedChallenges.find(c => c.id === challengeId);
1971
+ const title = challenge ? challenge.title : 'Unknown Challenge'; // Fallback
1972
+
1973
+ const modal = document.getElementById('broadcast-modal');
1974
+ const content = document.getElementById('broadcast-content');
1975
+
1976
+ document.getElementById('broadcast-avatar').textContent = student.nickname[0];
1977
+ document.getElementById('broadcast-author').textContent = student.nickname;
1978
+ document.getElementById('broadcast-challenge').textContent = title;
1979
+ // content is already just text, but let's be safe
1980
+ const rawText = p.prompt || p.code || '';
1981
+ const isCode = !p.prompt && !!p.code; // If only code exists, treat as code
1982
+ document.getElementById('broadcast-prompt').textContent = cleanText(rawText, isCode);
1983
+ document.getElementById('broadcast-prompt').style.textAlign = isCode ? 'left' : 'left'; // Always left, but explicit
1984
+
1985
+ // Store IDs for actions
1986
+ modal.dataset.userId = userId;
1987
+ modal.dataset.challengeId = challengeId;
1988
+
1989
+ modal.classList.remove('hidden');
1990
+ // Animation trigger
1991
+ setTimeout(() => {
1992
+ content.classList.remove('scale-95', 'opacity-0');
1993
+ content.classList.add('opacity-100', 'scale-100');
1994
+ }, 10);
1995
+ };
1996
  }