Lashtw commited on
Commit
abfdb2a
·
verified ·
1 Parent(s): f635766

Upload 9 files

Browse files
Files changed (2) hide show
  1. src/services/auth.js +15 -1
  2. src/views/InstructorView.js +943 -943
src/services/auth.js CHANGED
@@ -3,7 +3,8 @@ import {
3
  signInWithEmailAndPassword,
4
  createUserWithEmailAndPassword,
5
  signOut,
6
- sendPasswordResetEmail
 
7
  } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js";
8
  import {
9
  doc,
@@ -20,6 +21,19 @@ import {
20
  const INSTRUCTORS_COLLECTION = "instructors";
21
  const SUPER_ADMIN_EMAIL = "t92206@gmail.com";
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  /**
24
  * Sign in with Email/Password
25
  */
 
3
  signInWithEmailAndPassword,
4
  createUserWithEmailAndPassword,
5
  signOut,
6
+ sendPasswordResetEmail,
7
+ getRedirectResult
8
  } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js";
9
  import {
10
  doc,
 
21
  const INSTRUCTORS_COLLECTION = "instructors";
22
  const SUPER_ADMIN_EMAIL = "t92206@gmail.com";
23
 
24
+ /**
25
+ * Handle Redirect Result (for OAuth flows like Google Sign In)
26
+ */
27
+ export async function handleRedirectResult() {
28
+ try {
29
+ const result = await getRedirectResult(auth);
30
+ return result ? result.user : null;
31
+ } catch (error) {
32
+ console.error("Redirect Result Error:", error);
33
+ throw error;
34
+ }
35
+ }
36
+
37
  /**
38
  * Sign in with Email/Password
39
  */
src/views/InstructorView.js CHANGED
@@ -717,234 +717,234 @@ if (addInstBtn) {
717
  alert("新增失敗: " + e.message);
718
  }
719
  });
720
- }
721
 
722
 
723
- // Global helper for remove (hacky but works for simple onclick)
724
- window.removeInst = async (email) => {
725
- if (confirm(`確定移除 ${email}?`)) {
726
- try {
727
- await removeInstructor(email);
728
- navInstBtn.click(); // Reload
729
- } catch (e) {
730
- alert(e.message);
731
- }
732
- }
733
- };
734
 
735
- // Auto Check Auth (Persistence)
736
- // We rely on Firebase Auth state observer instead of session storage for security?
737
- // Or we can just check if user is already signed in.
738
- import("../services/firebase.js").then(async ({ auth }) => {
739
- // Handle Redirect Result first
740
- try {
741
- console.log("Initializing Auth Check...");
742
- const { handleRedirectResult } = await import("../services/auth.js");
743
- const redirectUser = await handleRedirectResult();
744
- if (redirectUser) console.log("Redirect User Found:", redirectUser.email);
745
- } catch (e) { console.warn("Redirect check failed", e); }
746
-
747
- auth.onAuthStateChanged(async (user) => {
748
- console.log("Auth State Changed to:", user ? user.email : "Logged Out");
749
- if (user) {
750
  try {
751
- console.log("Checking permissions for:", user.email);
752
- const instructorData = await checkInstructorPermission(user);
753
- console.log("Permission Result:", instructorData);
 
 
 
 
754
 
755
- if (instructorData) {
756
- console.log("Hiding Modal and Setting Permissions...");
757
- authModal.classList.add('hidden');
758
- checkPermissions(instructorData);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
759
 
760
- // Auto-Restore Room View if exists
761
- const savedRoomCode = localStorage.getItem('vibecoding_room_code');
762
- if (savedRoomCode) {
763
- console.log("Restoring Room Session:", savedRoomCode);
764
- // Restore Room Session using the unified function
765
- // This ensures all UI elements (including Group Photo button) are shown correctly
766
- console.log("Calling enterRoom to restore session...");
767
- enterRoom(savedRoomCode);
768
  }
769
-
770
- } else {
771
- console.warn("User logged in but not an instructor.");
772
- // Show unauthorized message
773
- authErrorMsg.textContent = "此帳號無講師權限";
774
  authErrorMsg.classList.remove('hidden');
775
- authModal.classList.remove('hidden'); // Ensure modal stays up
776
  }
777
- } catch (e) {
778
- console.error("Permission Check Failed:", e);
779
- authErrorMsg.textContent = "權限檢查失敗: " + e.message;
780
- authErrorMsg.classList.remove('hidden');
781
  }
782
- } else {
783
- authModal.classList.remove('hidden');
784
- }
785
  });
786
- });
787
 
788
- // Define Kick Function globally (robust against auth flow)
789
- window.confirmKick = async (userId, nickname) => {
790
- if (confirm(`確定要踢出 ${nickname} 嗎?此動作無法復原。`)) {
791
- try {
792
- const { removeUser } = await import("../services/classroom.js");
793
- await removeUser(userId);
794
- // UI will update automatically via subscribeToRoom
795
- } catch (e) {
796
- console.error("Kick failed:", e);
797
- alert("移除失敗");
 
798
  }
799
- }
800
- };
801
 
802
 
803
- // Snapshot Logic
804
- // Snapshot Logic
805
- if (snapshotBtn) {
806
- snapshotBtn.addEventListener('click', async () => {
807
- if (isSnapshotting || typeof htmlToImage === 'undefined') {
808
- if (typeof htmlToImage === 'undefined') alert("截圖元件尚未載入,請稍候再試");
809
- return;
810
- }
811
- isSnapshotting = true;
812
-
813
- const overlay = document.getElementById('snapshot-overlay');
814
- const countEl = document.getElementById('countdown-number');
815
- const container = document.getElementById('group-photo-container');
816
- const modal = document.getElementById('group-photo-modal');
817
-
818
- // Close button hide
819
- const closeBtn = modal.querySelector('button');
820
- if (closeBtn) closeBtn.style.opacity = '0';
821
- snapshotBtn.style.opacity = '0';
822
-
823
- overlay.classList.remove('hidden');
824
- overlay.classList.add('flex');
825
-
826
- // Countdown Sequence
827
- const runCountdown = (num) => new Promise(resolve => {
828
- countEl.textContent = num;
829
- countEl.style.transform = 'scale(1.5)';
830
- countEl.style.opacity = '1';
831
-
832
- // Animation reset
833
- requestAnimationFrame(() => {
834
- countEl.style.transition = 'all 0.5s ease-out';
835
- countEl.style.transform = 'scale(1)';
836
- countEl.style.opacity = '0.5';
837
- setTimeout(resolve, 1000);
 
838
  });
839
- });
840
 
841
- await runCountdown(3);
842
- await runCountdown(2);
843
- await runCountdown(1);
844
-
845
- // Action!
846
- countEl.textContent = '';
847
- overlay.classList.add('hidden');
848
-
849
- // 1. Emojis Explosion
850
- const emojis = ['🤘', '✌️', '👍', '🫶', '😎', '🔥'];
851
- const cards = container.querySelectorAll('.group\\/card');
852
-
853
- cards.forEach(card => {
854
- // Find the monster image container
855
- const imgContainer = card.querySelector('.monster-img-container');
856
- if (!imgContainer) return;
857
-
858
- // Random Emoji
859
- const emoji = emojis[Math.floor(Math.random() * emojis.length)];
860
- const emojiEl = document.createElement('div');
861
- emojiEl.textContent = emoji;
862
- // Position: Top-Right of the *Image*, slightly overlapping
863
- emojiEl.className = 'absolute -top-2 -right-2 text-2xl animate-bounce z-50 drop-shadow-md transform rotate-12';
864
- emojiEl.style.animationDuration = '0.6s';
865
- imgContainer.appendChild(emojiEl);
866
-
867
- // Remove after 3s
868
- setTimeout(() => emojiEl.remove(), 3000);
869
- });
870
-
871
- // 2. Capture using html-to-image
872
- setTimeout(async () => {
873
- try {
874
- // Flash Effect
875
- const flash = document.createElement('div');
876
- flash.className = 'fixed inset-0 bg-white z-[100] transition-opacity duration-300 pointer-events-none';
877
- document.body.appendChild(flash);
878
- setTimeout(() => flash.style.opacity = '0', 50);
879
- setTimeout(() => flash.remove(), 300);
880
-
881
- // Use htmlToImage.toPng
882
- const dataUrl = await htmlToImage.toPng(container, {
883
- backgroundColor: '#111827',
884
- pixelRatio: 2,
885
- cacheBust: true,
886
- });
887
 
888
- // Download
889
- const link = document.createElement('a');
890
- const dateStr = new Date().toISOString().slice(0, 10);
891
- link.download = `VIBE_Class_Photo_${dateStr}.png`;
892
- link.href = dataUrl;
893
- link.click();
 
 
 
 
 
 
 
 
 
 
894
 
895
- } catch (e) {
896
- console.error("Snapshot failed:", e);
897
- alert("截圖失敗 (請嘗試手動截圖/PrtSc)\n原因: " + e.message);
898
- } finally {
899
- // Restore UI
900
- if (closeBtn) closeBtn.style.opacity = '1';
901
- snapshotBtn.style.opacity = '1';
902
- isSnapshotting = false;
903
- }
904
- }, 600); // Slight delay for emojis to appear
905
- });
906
- }
 
 
 
 
 
 
 
907
 
908
- // Group Photo Logic
909
- if (groupPhotoBtn) {
910
- groupPhotoBtn.addEventListener('click', () => {
911
- const modal = document.getElementById('group-photo-modal');
912
- const container = document.getElementById('group-photo-container');
913
- const dateEl = document.getElementById('photo-date');
914
 
915
- // Update Date
916
- const now = new Date();
917
- dateEl.textContent = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} `;
918
 
919
- // Get saved name
920
- const savedName = localStorage.getItem('vibecoding_instructor_name') || '講師 (Instructor)';
921
 
922
- container.innerHTML = '';
923
 
924
- // 1. Container for Relative Positioning with Custom Background
925
- const relativeContainer = document.createElement('div');
926
- relativeContainer.className = 'relative w-full h-[600px] md:h-[700px] overflow-hidden rounded-3xl border border-gray-700/30 shadow-2xl flex items-center justify-center bg-cover bg-center';
927
- relativeContainer.style.backgroundImage = "url('assets/photobg.png')";
928
- container.appendChild(relativeContainer);
929
 
930
- // Watermark (Bottom Right, High Z-Index, Gradient Text, Dark Backdrop)
931
- const watermark = document.createElement('div');
932
- watermark.className = 'absolute bottom-2 right-2 md:bottom-4 md:right-4 z-[60] bg-black/70 backdrop-blur-sm rounded-lg px-4 py-2 pointer-events-none select-none border border-white/10 shadow-lg';
933
 
934
- const d = new Date();
935
- const dateStr = `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} `;
936
 
937
- watermark.innerHTML = `
938
  <span class="text-lg md:text-2xl font-black font-mono bg-clip-text text-transparent bg-gradient-to-r from-cyan-400 to-purple-400 tracking-wider">
939
  ${dateStr} VibeCoding 怪獸成長營
940
  </span>
941
  `;
942
- relativeContainer.appendChild(watermark);
943
 
944
- // 2. Instructor Section (Absolute Center)
945
- const instructorSection = document.createElement('div');
946
- instructorSection.className = 'absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center justify-center z-20 group cursor-pointer';
947
- instructorSection.innerHTML = `
948
  <div class="relative">
949
  <div class="absolute inset-0 bg-yellow-500/20 blur-3xl rounded-full animate-pulse"></div>
950
  <!--Pixel Art Avatar-->
@@ -963,98 +963,98 @@ if (groupPhotoBtn) {
963
  </div>
964
  </div>
965
  `;
966
- relativeContainer.appendChild(instructorSection);
967
-
968
- // Save name on change
969
- setTimeout(() => {
970
- const input = document.getElementById('instructor-name-input');
971
- if (input) {
972
- input.addEventListener('input', (e) => {
973
- localStorage.setItem('vibecoding_instructor_name', e.target.value);
974
- });
975
- }
976
- }, 100);
977
-
978
- // 3. Students Scatter
979
- if (currentStudents.length > 0) {
980
- // Randomize array to prevent fixed order bias
981
- const students = [...currentStudents].sort(() => Math.random() - 0.5);
982
- const total = students.length;
983
-
984
- // --- Dynamic Sizing Logic ---
985
- let sizeClass = 'w-20 h-20 md:w-24 md:h-24'; // Default (Size 100%)
986
- let scaleFactor = 1.0;
987
-
988
- if (total >= 40) {
989
- sizeClass = 'w-12 h-12 md:w-14 md:h-14'; // Size 60%
990
- scaleFactor = 0.6;
991
- } else if (total >= 20) {
992
- sizeClass = 'w-16 h-16 md:w-20 md:h-20'; // Size 80%
993
- scaleFactor = 0.8;
994
- }
995
 
996
- students.forEach((s, index) => {
997
- const progressMap = s.progress || {};
998
- const totalLikes = Object.values(progressMap).reduce((acc, p) => acc + (p.likes || 0), 0);
999
- const totalCompleted = Object.values(progressMap).filter(p => p.status === 'completed').length;
1000
-
1001
- // FIXED: Prioritize stored ID if valid (same as StudentView logic)
1002
- let monster;
1003
- if (s.monster_id && s.monster_id !== 'Egg' && s.monster_id !== 'Unknown') {
1004
- const stored = MONSTER_DEFS?.find(m => m.id === s.monster_id);
1005
- if (stored) {
1006
- monster = stored;
 
 
 
 
1007
  } else {
1008
- // Fallback if ID invalid
1009
  monster = getNextMonster(s.monster_stage || 0, totalLikes, total, s.monster_id);
1010
  }
1011
- } else {
1012
- monster = getNextMonster(s.monster_stage || 0, totalLikes, total, s.monster_id);
1013
- }
1014
 
1015
- // --- FIXED: Even Arc Distribution (Safe Zone: 135 deg to 405 deg) ---
1016
- // Avoids Bottom Right (0-90) and Bottom Center (90-120) entirely
1017
- const minR = 220;
1018
- // Avoids Bottom Right (0-90) and Bottom Center (90-120) entirely
1019
 
1020
- // Safe Arc Range: Starts at 135 deg (Bottom Left) -> Goes CW -> Ends at 405 deg (45 deg, Bottom Right)
1021
- // Total Span = 270 degrees
1022
- // If many students, use double ring
1023
 
1024
- const safeStartAngle = 135 * (Math.PI / 180);
1025
- const safeSpan = 270 * (Math.PI / 180);
1026
 
1027
- // Distribute evenly
1028
- // If only 1 student, put at top (270 deg / 4.71 rad)
1029
- let finalAngle;
1030
 
1031
- if (total === 1) {
1032
- finalAngle = 270 * (Math.PI / 180);
1033
- } else {
1034
- const step = safeSpan / (total - 1);
1035
- finalAngle = safeStartAngle + (step * index);
1036
- }
1037
 
1038
- // Radius: Fixed base + slight variation for "natural" look (but not overlap causing)
1039
- // Double ring logic if crowded
1040
- let radius = minR + (index % 2) * 40; // Zigzag radius (220, 260, 220...) to minimize overlap
1041
 
1042
- // Reduce zigzag if few students
1043
- if (total < 10) radius = minR + (index % 2) * 20;
1044
 
1045
- const xOff = Math.cos(finalAngle) * radius;
1046
- const yOff = Math.sin(finalAngle) * radius * 0.8;
1047
 
1048
- const card = document.createElement('div');
1049
- card.className = 'absolute flex flex-col items-center group/card z-10 hover:z-50 transition-all duration-500 cursor-move';
1050
 
1051
- card.style.left = `calc(50% + ${xOff}px)`;
1052
- card.style.top = `calc(50% + ${yOff}px)`;
1053
- card.style.transform = 'translate(-50%, -50%)';
1054
 
1055
- const floatDelay = Math.random() * 2;
1056
 
1057
- card.innerHTML = `
1058
  <!--Top Info: Monster Stats-->
1059
  <div class="mb-1 text-center bg-gray-900/60 backdrop-blur-sm rounded-lg px-2 py-1 border border-gray-600/30 group-hover/card:bg-gray-800 group-hover/card:border-cyan-500/50 transition-all opacity-80 group-hover/card:opacity-100 transform translate-y-2 group-hover/card:translate-y-0 duration-300">
1060
  <div class="text-[10px] text-gray-300 mb-0.5 whitespace-nowrap">${monster.name.split(' ')[1] || monster.name}</div>
@@ -1079,78 +1079,78 @@ if (groupPhotoBtn) {
1079
  <div class="text-xs font-bold text-white shadow-black drop-shadow-md whitespace-nowrap tracking-wide">${s.nickname}</div>
1080
  </div>
1081
  `;
1082
- relativeContainer.appendChild(card);
1083
 
1084
- // Enable Drag & Drop
1085
- setupDraggable(card, relativeContainer);
1086
- });
1087
- }
1088
 
1089
- modal.classList.remove('hidden');
1090
- });
1091
- }
1092
 
1093
- // Helper: Drag & Drop Logic
1094
- function setupDraggable(el, container) {
1095
- let isDragging = false;
1096
- let startX, startY, initialLeft, initialTop;
1097
 
1098
- el.addEventListener('mousedown', (e) => {
1099
- isDragging = true;
1100
- startX = e.clientX;
1101
- startY = e.clientY;
1102
 
1103
- // Disable transition during drag for responsiveness
1104
- el.style.transition = 'none';
1105
- el.style.zIndex = 100; // Bring to front
1106
 
1107
- // Convert current computed position to fixed pixels if relying on calc
1108
- const rect = el.getBoundingClientRect();
1109
- const containerRect = container.getBoundingClientRect();
1110
 
1111
- // Calculate position relative to container
1112
- // The current transform is translate(-50%, -50%).
1113
- // We want to set left/top such that the center remains under the mouse offset,
1114
- // but for simplicity, let's just use current offsetLeft/Top if possible,
1115
- // OR robustly recalculate from rects.
1116
 
1117
- // Current center point relative to container:
1118
- const centerX = rect.left - containerRect.left + rect.width / 2;
1119
- const centerY = rect.top - containerRect.top + rect.height / 2;
1120
 
1121
- // Set explicit pixel values replacing calc()
1122
- el.style.left = `${centerX}px`;
1123
- el.style.top = `${centerY}px`;
1124
 
1125
- initialLeft = centerX;
1126
- initialTop = centerY;
1127
- });
1128
 
1129
- window.addEventListener('mousemove', (e) => {
1130
- if (!isDragging) return;
1131
- e.preventDefault();
1132
 
1133
- const dx = e.clientX - startX;
1134
- const dy = e.clientY - startY;
1135
 
1136
- el.style.left = `${initialLeft + dx}px`;
1137
- el.style.top = `${initialTop + dy}px`;
1138
- });
1139
 
1140
- window.addEventListener('mouseup', () => {
1141
- if (isDragging) {
1142
- isDragging = false;
1143
- el.style.transition = ''; // Re-enable hover effects
1144
- el.style.zIndex = ''; // Restore z-index rule (or let hover take over)
1145
- }
1146
- });
1147
- }
1148
 
1149
- // Add float animation style if not exists
1150
- if (!document.getElementById('anim-float')) {
1151
- const style = document.createElement('style');
1152
- style.id = 'anim-float';
1153
- style.innerHTML = `
1154
  @keyframes float {
1155
 
1156
  0 %, 100 % { transform: translateY(0) scale(1); }
@@ -1158,234 +1158,234 @@ if (!document.getElementById('anim-float')) {
1158
  }
1159
  }
1160
  `;
1161
- document.head.appendChild(style);
1162
- }
1163
 
1164
- // Gallery Logic
1165
- document.getElementById('btn-open-gallery').addEventListener('click', () => {
1166
- window.open('monster_preview.html', '_blank');
1167
- });
1168
-
1169
- // Logout Logic
1170
- document.getElementById('logout-btn').addEventListener('click', async () => {
1171
- if (confirm('確定要登出講師模式嗎? (將會回到首頁)')) {
1172
- await signOutUser();
1173
- sessionStorage.removeItem('vibecoding_instructor_in_room');
1174
- sessionStorage.removeItem('vibecoding_admin_referer');
1175
- window.location.hash = '';
1176
- window.location.reload();
 
 
 
 
 
 
 
 
 
 
 
 
1177
  }
1178
- });
1179
 
1180
- // Check Previous Session (Handled by onAuthStateChanged now)
1181
- // if (sessionStorage.getItem('vibecoding_instructor_auth') === 'true') {
1182
- // authModal.classList.add('hidden');
1183
- // }
1184
 
1185
- // Check Active Room State
1186
- const activeRoom = sessionStorage.getItem('vibecoding_instructor_in_room');
1187
- if (activeRoom === 'true' && savedRoomCode) {
1188
- enterRoom(savedRoomCode);
1189
- }
 
 
 
1190
 
1191
- // Module-level variable to track subscription (Moved to top)
 
1192
 
1193
- function enterRoom(roomCode) {
1194
- createContainer.classList.add('hidden');
1195
- roomInfo.classList.remove('hidden');
1196
- dashboardContent.classList.remove('hidden');
1197
- document.getElementById('group-photo-btn').classList.remove('hidden'); // Show photo button
1198
- displayRoomCode.textContent = roomCode;
1199
- localStorage.setItem('vibecoding_room_code', roomCode);
1200
- sessionStorage.setItem('vibecoding_instructor_in_room', 'true');
1201
 
1202
- // Unsubscribe previous if any
1203
- if (roomUnsubscribe) roomUnsubscribe();
 
 
 
 
 
 
1204
 
1205
- // Subscribe to updates
1206
- roomUnsubscribe = subscribeToRoom(roomCode, (students) => {
1207
- currentStudents = students;
1208
- renderTransposedHeatmap(students);
1209
- });
1210
- }
1211
 
1212
- // Leave Room Logic
1213
- document.getElementById('leave-room-btn').addEventListener('click', () => {
1214
- if (confirm('確定要離開目前教室嗎?(不會刪除教室資料,僅回到選擇介面)')) {
1215
- // Unsubscribe
1216
- if (roomUnsubscribe) {
1217
- roomUnsubscribe();
1218
- roomUnsubscribe = null;
1219
  }
 
1220
 
1221
- // UI Reset
1222
- createContainer.classList.remove('hidden');
1223
- roomInfo.classList.add('hidden');
1224
- dashboardContent.classList.add('hidden');
1225
- document.getElementById('group-photo-btn').classList.add('hidden'); // Hide photo button
1226
 
1227
- // Clear Data Display
1228
- document.getElementById('heatmap-body').innerHTML = '<tr><td colspan="100" class="text-center py-10 text-gray-500">等待資料載入...</td></tr>';
1229
- 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>';
1230
 
1231
- // State Clear
1232
- sessionStorage.removeItem('vibecoding_instructor_in_room');
1233
- localStorage.removeItem('vibecoding_room_code');
1234
- }
1235
- });
1236
-
1237
- // Modal Events
1238
- window.showBroadcastModal = (userId, challengeId) => {
1239
- const modal = document.getElementById('broadcast-modal');
1240
- const content = document.getElementById('broadcast-content');
1241
-
1242
- // Find Data
1243
- const student = currentStudents.find(s => s.id === userId);
1244
- if (!student) return alert('找不到學員資料');
1245
-
1246
- const p = student.progress ? student.progress[challengeId] : null;
1247
- if (!p) return alert('找不到該作品資料');
1248
-
1249
- const challenge = cachedChallenges.find(c => c.id === challengeId);
1250
- const title = challenge ? challenge.title : '未知題目';
1251
-
1252
- // Populate UI
1253
- document.getElementById('broadcast-avatar').textContent = student.nickname[0] || '?';
1254
- document.getElementById('broadcast-author').textContent = student.nickname;
1255
- document.getElementById('broadcast-challenge').textContent = title;
1256
- document.getElementById('broadcast-prompt').textContent = p.prompt || '(無內容)';
1257
-
1258
- // Store IDs for Actions (Reject/BroadcastAll)
1259
- modal.dataset.userId = userId;
1260
- modal.dataset.challengeId = challengeId;
1261
-
1262
- // Show
1263
- modal.classList.remove('hidden');
1264
- setTimeout(() => {
1265
- content.classList.remove('scale-95', 'opacity-0');
1266
- content.classList.add('opacity-100', 'scale-100');
1267
- }, 10);
1268
- };
1269
-
1270
- window.closeBroadcast = () => {
1271
- const modal = document.getElementById('broadcast-modal');
1272
- const content = document.getElementById('broadcast-content');
1273
- content.classList.remove('opacity-100', 'scale-100');
1274
- content.classList.add('scale-95', 'opacity-0');
1275
- setTimeout(() => modal.classList.add('hidden'), 300);
1276
- };
1277
-
1278
- window.openStage = (prompt, author) => {
1279
- document.getElementById('broadcast-content').classList.add('hidden');
1280
- const stage = document.getElementById('stage-view');
1281
- stage.classList.remove('hidden');
1282
- document.getElementById('stage-prompt').textContent = cleanText(prompt || '');
1283
- document.getElementById('stage-author').textContent = author;
1284
- };
1285
-
1286
- window.closeStage = () => {
1287
- document.getElementById('stage-view').classList.add('hidden');
1288
- document.getElementById('broadcast-content').classList.remove('hidden');
1289
- };
1290
-
1291
- document.getElementById('btn-show-stage').addEventListener('click', () => {
1292
- const prompt = document.getElementById('broadcast-prompt').textContent;
1293
- const author = document.getElementById('broadcast-author').textContent;
1294
- window.openStage(prompt, author);
1295
- });
1296
-
1297
- // Reject Logic
1298
- document.getElementById('btn-reject-task').addEventListener('click', async () => {
1299
- if (!confirm('確定要退回此題目讓學員重做嗎?')) return;
1300
-
1301
- // We need student ID (userId) and Challenge ID.
1302
- // Currently showBroadcastModal only receives nickname, title, prompt.
1303
- // We need to attach data-userid and data-challengeid to the modal.
1304
- const modal = document.getElementById('broadcast-modal');
1305
- const userId = modal.dataset.userId;
1306
- const challengeId = modal.dataset.challengeId;
1307
- const roomCode = localStorage.getItem('vibecoding_room_code');
1308
-
1309
- if (userId && challengeId && roomCode) {
1310
- try {
1311
- await resetProgress(userId, roomCode, challengeId);
1312
- alert('已成功退回,學員可重新作答');
1313
- // Close modal
1314
- window.closeBroadcast();
1315
- } catch (e) {
1316
- console.error('退回失敗:', e);
1317
- alert('退回失敗: ' + e.message);
1318
- }
1319
- }
1320
- });
1321
- // Prompt Viewer Logic
1322
- window.openPromptList = (type, id, title) => {
1323
- const modal = document.getElementById('prompt-list-modal');
1324
- const container = document.getElementById('prompt-list-container');
1325
- const titleEl = document.getElementById('prompt-list-title');
1326
-
1327
- titleEl.textContent = type === 'student' ? `${title} 的所有提示詞` : `題目:${title} 的所有作品`;
1328
-
1329
- // Reset Anonymous Toggle in List View
1330
- const anonCheck = document.getElementById('list-anonymous-toggle');
1331
- if (anonCheck) anonCheck.checked = false;
1332
-
1333
- container.innerHTML = '';
1334
- modal.classList.remove('hidden');
1335
-
1336
- // Collect Prompts
1337
- let prompts = [];
1338
- // Fix: Reset selection when opening new list to prevent cross-contamination
1339
- selectedPrompts = [];
1340
- updateCompareButton();
1341
-
1342
- if (type === 'student') {
1343
- const student = currentStudents.find(s => s.id === id);
1344
- if (student && student.progress) {
1345
- prompts = Object.entries(student.progress)
1346
- .filter(([_, p]) => p.status === 'completed' && p.prompt)
1347
- .map(([challengeId, p]) => {
1348
- const challenge = cachedChallenges.find(c => c.id === challengeId);
1349
- return {
1350
- id: `${student.id}_${challengeId}`,
1351
- title: challenge ? challenge.title : '未知題目',
1352
- prompt: p.prompt,
1353
- author: student.nickname,
1354
- studentId: student.id,
1355
- challengeId: challengeId,
1356
- time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : ''
1357
- };
1358
- });
1359
  }
1360
- } else if (type === 'challenge') {
1361
- currentStudents.forEach(student => {
1362
- if (student.progress && student.progress[id]) {
1363
- const p = student.progress[id];
1364
- if (p.status === 'completed' && p.prompt) {
1365
- prompts.push({
1366
- id: `${student.id}_${id}`,
1367
- title: student.nickname, // When viewing challenge, title is student name
1368
- prompt: p.prompt,
1369
- author: student.nickname,
1370
- studentId: student.id,
1371
- challengeId: id,
1372
- time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : ''
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1373
  });
1374
- }
1375
  }
1376
- });
1377
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1378
 
1379
- if (prompts.length === 0) {
1380
- container.innerHTML = '<div class="col-span-full text-center text-gray-500 py-10">無資料</div>';
1381
- return;
1382
- }
1383
 
1384
- prompts.forEach(p => {
1385
- const card = document.createElement('div');
1386
- // Reduced height (h-64 -> h-48) and padding, but larger text inside
1387
- 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';
1388
- card.innerHTML = `
1389
  <div class="flex justify-between items-start mb-1.5">
1390
  <h3 class="font-bold text-white text-base truncate w-3/4" title="${p.title}">${p.title}</h3>
1391
  <!-- Checkbox -->
@@ -1406,186 +1406,186 @@ window.openPromptList = (type, id, title) => {
1406
  </div>
1407
  </div>
1408
  `;
1409
- container.appendChild(card);
1410
- });
1411
- };
1412
-
1413
- // Helper Actions
1414
- window.confirmReset = async (userId, challengeId, title) => {
1415
- console.log('🔵 confirmReset called');
1416
- console.log(' userId:', userId);
1417
- console.log(' challengeId:', challengeId);
1418
- console.log(' title:', title);
1419
- console.log(' typeof resetProgress:', typeof resetProgress);
1420
-
1421
- if (confirm(`確定要退回 ${title} 嗎?此動作將清除學員目前的進度。`)) {
1422
- console.log('✅ User confirmed reset');
1423
- const roomCode = localStorage.getItem('vibecoding_room_code');
1424
- console.log(' roomCode:', roomCode);
1425
 
1426
- if (userId && challengeId && roomCode) {
1427
- console.log('✅ All parameters valid, calling resetProgress...');
1428
- try {
1429
- // Use already imported resetProgress function
1430
- await resetProgress(userId, roomCode, challengeId);
1431
- console.log('✅ resetProgress completed successfully');
1432
- alert("已退回");
1433
- // close modal to refresh data context
1434
- document.getElementById('prompt-list-modal').classList.add('hidden');
1435
- console.log('✅ Modal closed');
1436
- } catch (e) {
1437
- console.error("❌ 退回失敗:", e);
1438
- console.error("Error stack:", e.stack);
1439
- alert("退回失敗: " + e.message);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1440
  }
1441
  } else {
1442
- console.error('❌ Missing required parameters:');
1443
- console.error(' userId:', userId, '(valid:', !!userId, ')');
1444
- console.error(' challengeId:', challengeId, '(valid:', !!challengeId, ')');
1445
- console.error(' roomCode:', roomCode, '(valid:', !!roomCode, ')');
1446
- alert('缺少必要參數,無法執行退回操作');
1447
  }
1448
- } else {
1449
- console.log('❌ User cancelled reset');
1450
- }
1451
- };
1452
 
1453
- window.broadcastPrompt = (userId, challengeId) => {
1454
- window.showBroadcastModal(userId, challengeId);
1455
- };
1456
 
1457
- // Selection Logic
1458
- let selectedPrompts = []; // Stores IDs
1459
 
1460
- window.handlePromptSelection = (checkbox) => {
1461
- const id = checkbox.dataset.id;
1462
 
1463
- if (checkbox.checked) {
1464
- if (selectedPrompts.length >= 3) {
1465
- checkbox.checked = false;
1466
- alert('最多只能選擇 3 個提示詞進行比較');
1467
- return;
1468
- }
1469
- selectedPrompts.push(id);
1470
- } else {
1471
- selectedPrompts = selectedPrompts.filter(pid => pid !== id);
1472
- }
1473
- updateCompareButton();
1474
- };
1475
-
1476
- function updateCompareButton() {
1477
- const btn = document.getElementById('btn-compare-prompts');
1478
- if (!btn) return;
1479
-
1480
- const count = selectedPrompts.length;
1481
- const span = btn.querySelector('span');
1482
- if (span) span.textContent = `🔍 比較已選項目 (${count}/3)`;
1483
-
1484
- if (count > 0) {
1485
- btn.disabled = false;
1486
- btn.classList.remove('opacity-50', 'cursor-not-allowed');
1487
- } else {
1488
- btn.disabled = true;
1489
- btn.classList.add('opacity-50', 'cursor-not-allowed');
1490
- }
1491
- }
1492
- // Comparison Logic
1493
- const compareBtn = document.getElementById('btn-compare-prompts');
1494
- if (compareBtn) {
1495
- compareBtn.addEventListener('click', () => {
1496
- const dataToCompare = [];
1497
- selectedPrompts.forEach(fullId => {
1498
- const lastUnderscore = fullId.lastIndexOf('_');
1499
- const studentId = fullId.substring(0, lastUnderscore);
1500
- const challengeId = fullId.substring(lastUnderscore + 1);
1501
-
1502
- const student = currentStudents.find(s => s.id === studentId);
1503
- if (student && student.progress && student.progress[challengeId]) {
1504
- const p = student.progress[challengeId];
1505
- const challenge = cachedChallenges.find(c => c.id === challengeId);
1506
-
1507
- dataToCompare.push({
1508
- title: challenge ? challenge.title : '未知',
1509
- author: student.nickname,
1510
- prompt: p.prompt,
1511
- time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleTimeString() : ''
1512
- });
1513
  }
1514
- });
1515
-
1516
- const isAnon = document.getElementById('list-anonymous-toggle')?.checked || false;
1517
- openComparisonView(dataToCompare, isAnon);
1518
- });
1519
- }
1520
-
1521
- let isAnonymous = false;
1522
-
1523
- window.toggleAnonymous = (btn) => {
1524
- isAnonymous = !isAnonymous;
1525
- btn.textContent = isAnonymous ? '🙈 顯示姓名' : '👀 隱藏姓名';
1526
- btn.classList.toggle('bg-gray-700');
1527
- btn.classList.toggle('bg-purple-700');
1528
-
1529
- // Update DOM
1530
- document.querySelectorAll('.comparison-author').forEach(el => {
1531
- if (isAnonymous) {
1532
- el.dataset.original = el.textContent;
1533
- el.textContent = '學員';
1534
- el.classList.add('blur-sm'); // Optional Effect
1535
- setTimeout(() => el.classList.remove('blur-sm'), 300);
1536
  } else {
1537
- if (el.dataset.original) el.textContent = el.dataset.original;
1538
  }
1539
- });
1540
- };
1541
-
1542
- window.openComparisonView = (items, initialAnonymous = false) => {
1543
- const modal = document.getElementById('comparison-modal');
1544
- const grid = document.getElementById('comparison-grid');
1545
-
1546
- // Apply Anonymous State
1547
- isAnonymous = initialAnonymous;
1548
- const anonBtn = document.getElementById('btn-anonymous-toggle');
1549
-
1550
- // Update Toggle UI to match state
1551
- if (anonBtn) {
1552
- if (isAnonymous) {
1553
- anonBtn.textContent = '🙈 顯示姓名';
1554
- anonBtn.classList.add('bg-purple-700');
1555
- anonBtn.classList.remove('bg-gray-700');
1556
  } else {
1557
- anonBtn.textContent = '👀 隱藏姓名';
1558
- anonBtn.classList.remove('bg-purple-700');
1559
- anonBtn.classList.add('bg-gray-700');
1560
  }
1561
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1562
 
1563
- // Setup Grid Rows (Vertical Stacking)
1564
- let rowClass = 'grid-rows-1';
1565
- if (items.length === 2) rowClass = 'grid-rows-2';
1566
- if (items.length === 3) rowClass = 'grid-rows-3';
1567
-
1568
- grid.className = `absolute inset-0 grid ${rowClass} gap-0 divide-y divide-gray-600`;
1569
- grid.innerHTML = '';
1570
-
1571
- items.forEach(item => {
1572
- const col = document.createElement('div');
1573
- // Check overflow-hidden to keep it contained, use flex-row for compact header + content
1574
- col.className = 'flex flex-row h-full bg-gray-900 p-4 overflow-hidden';
1575
-
1576
- // Logic for anonymous
1577
- let displayAuthor = item.author;
1578
- let blurClass = '';
1579
-
1580
- if (isAnonymous) {
1581
- displayAuthor = '學員';
1582
- blurClass = 'blur-sm'; // Initial blur
1583
- // Auto remove blur after delay if needed, or keep it?
1584
- // Toggle logic removes it after delay. But initial render should probably just be static '學員' or blurred.
1585
- // The toggle logic uses dataset.original. We need to set it here too.
1586
  }
1587
 
1588
- col.innerHTML = `
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1589
  <div class="w-48 flex-shrink-0 border-r border-gray-700 pr-4 mr-4 flex flex-col justify-center">
1590
  <h3 class="text-xl font-bold text-cyan-400 mb-1 comparison-author ${blurClass}" data-original="${item.author}">${displayAuthor}</h3>
1591
  <p class="text-md text-gray-400 truncate" title="${item.title}">${item.title}</p>
@@ -1594,223 +1594,223 @@ window.openComparisonView = (items, initialAnonymous = false) => {
1594
  <!-- Prompt Content: Larger Text (text-4xl) -->
1595
  <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>
1596
  `;
1597
- grid.appendChild(col);
1598
-
1599
- // If blurred, remove blur after animation purely for effect, or keep?
1600
- // User intention "Hidden Name" usually means "Replaced by generic name".
1601
- // The blur effect in toggle logic was transient.
1602
- // If we want persistent anonymity, just "學員" is enough.
1603
- // The existing toggle logic adds 'blur-sm' then removes it in 300ms.
1604
- // We should replicate that effect if we want consistency, or just skip blur on init.
1605
- if (isAnonymous) {
1606
- const el = col.querySelector('.comparison-author');
1607
- setTimeout(() => el.classList.remove('blur-sm'), 300);
1608
- }
1609
- });
1610
 
1611
- document.getElementById('prompt-list-modal').classList.add('hidden');
1612
- modal.classList.remove('hidden');
1613
-
1614
- // Init Canvas (Phase 3)
1615
- setTimeout(setupCanvas, 100);
1616
- };
1617
-
1618
- window.closeComparison = () => {
1619
- document.getElementById('comparison-modal').classList.add('hidden');
1620
- clearCanvas();
1621
- };
1622
-
1623
- // --- Phase 3 & 6: Annotation Tools ---
1624
- let canvas, ctx;
1625
- let isDrawing = false;
1626
- let currentPenColor = '#ef4444'; // Red default
1627
- let currentLineWidth = 3;
1628
- let currentMode = 'source-over'; // 'source-over' (Pen) or 'destination-out' (Eraser)
1629
-
1630
- window.setupCanvas = () => {
1631
- canvas = document.getElementById('annotation-canvas');
1632
- const container = document.getElementById('comparison-container');
1633
- if (!canvas || !container) return;
1634
-
1635
- ctx = canvas.getContext('2d');
1636
-
1637
- // Resize
1638
- const resize = () => {
1639
- canvas.width = container.clientWidth;
1640
- canvas.height = container.clientHeight;
1641
- ctx.lineCap = 'round';
1642
- ctx.lineJoin = 'round';
1643
- ctx.strokeStyle = currentPenColor;
1644
- ctx.lineWidth = currentLineWidth;
1645
- ctx.globalCompositeOperation = currentMode;
1646
  };
1647
- resize();
1648
- window.addEventListener('resize', resize);
1649
-
1650
- // Init Size UI & Cursor
1651
- updateSizeBtnUI();
1652
- updateCursorStyle();
1653
-
1654
- // Cursor Logic
1655
- const cursor = document.getElementById('tool-cursor');
1656
-
1657
- canvas.addEventListener('mouseenter', () => cursor.classList.remove('hidden'));
1658
- canvas.addEventListener('mouseleave', () => cursor.classList.add('hidden'));
1659
- canvas.addEventListener('mousemove', (e) => {
1660
- const { x, y } = getPos(e);
1661
- cursor.style.left = `${x}px`;
1662
- cursor.style.top = `${y}px`;
1663
- });
1664
 
1665
- // Drawing Events
1666
- const start = (e) => {
1667
- isDrawing = true;
1668
- ctx.beginPath();
1669
 
1670
- // Re-apply settings (state might change)
1671
- ctx.globalCompositeOperation = currentMode;
1672
- ctx.strokeStyle = currentPenColor;
1673
- ctx.lineWidth = currentLineWidth;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1674
 
1675
- const { x, y } = getPos(e);
1676
- ctx.moveTo(x, y);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1677
  };
1678
 
1679
- const move = (e) => {
1680
- if (!isDrawing) return;
1681
- const { x, y } = getPos(e);
1682
- ctx.lineTo(x, y);
1683
- ctx.stroke();
1684
- };
 
1685
 
1686
- const end = () => {
1687
- isDrawing = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1688
  };
1689
 
1690
- canvas.onmousedown = start;
1691
- canvas.onmousemove = move;
1692
- canvas.onmouseup = end;
1693
- canvas.onmouseleave = end;
1694
-
1695
- // Touch support
1696
- canvas.ontouchstart = (e) => { e.preventDefault(); start(e.touches[0]); };
1697
- canvas.ontouchmove = (e) => { e.preventDefault(); move(e.touches[0]); };
1698
- canvas.ontouchend = (e) => { e.preventDefault(); end(); };
1699
- };
1700
-
1701
- function getPos(e) {
1702
- const rect = canvas.getBoundingClientRect();
1703
- return {
1704
- x: e.clientX - rect.left,
1705
- y: e.clientY - rect.top
1706
  };
1707
- }
1708
 
1709
- // Unified Tool Handler
1710
- window.setPenTool = (tool, color, btn) => {
1711
- // UI Update
1712
- document.querySelectorAll('.annotation-tool').forEach(b => {
1713
- b.classList.remove('ring-white');
1714
- b.classList.add('ring-transparent');
1715
- });
1716
- btn.classList.remove('ring-transparent');
1717
- btn.classList.add('ring-white');
1718
-
1719
- if (tool === 'eraser') {
1720
- currentMode = 'destination-out';
1721
- // Force larger eraser size (e.g., 3x current size or fixed large)
1722
- // We'll multiply current selected size by 4 for better UX
1723
- const multiplier = 4;
1724
- // Store original explicitly if needed, but currentLineWidth is global.
1725
- // We should dynamically adjust context lineWidth during draw, or just hack it here.
1726
- // Hack: If we change currentLineWidth here, the UI size buttons might look wrong.
1727
- // Better: Update cursor style only? No, actual draw needs it.
1728
- // Let's set a separate 'actualWidth' used in draw, OR just change currentLineWidth temporarily?
1729
- // Simpler: Just change it. When user clicks size button, it resets.
1730
- // But if user clicks Pen back? We need to restore.
1731
- // Let's rely on setPenTool being called with color.
1732
- // When "Pen" is clicked, we usually don't call setPenTool with a saved size...
1733
- // Actually, let's just use a large default for eraser, and keep currentLineWidth for Pen.
1734
- // We need to change how draw() uses the width.
1735
- // BUT, since we don't want to touch draw() deep inside:
1736
- // We will hijack currentLineWidth.
1737
- if (!window.savedPenWidth) window.savedPenWidth = currentLineWidth;
1738
- currentLineWidth = window.savedPenWidth * 4;
1739
- } else {
1740
- currentMode = 'source-over';
1741
- currentPenColor = color;
1742
- // Restore pen width
1743
- if (window.savedPenWidth) {
1744
- currentLineWidth = window.savedPenWidth;
1745
- window.savedPenWidth = null;
1746
- }
1747
- }
1748
- updateCursorStyle();
1749
- };
1750
-
1751
- // Size Handler
1752
- window.setPenSize = (size, btn) => {
1753
- currentLineWidth = size;
1754
- updateSizeBtnUI();
1755
- updateCursorStyle();
1756
- };
1757
-
1758
- function updateCursorStyle() {
1759
- const cursor = document.getElementById('tool-cursor');
1760
- if (!cursor) return;
1761
-
1762
- // Size
1763
- cursor.style.width = `${currentLineWidth}px`;
1764
- cursor.style.height = `${currentLineWidth}px`;
1765
-
1766
- // Color
1767
- if (currentMode === 'destination-out') {
1768
- // Eraser: White solid
1769
- cursor.style.backgroundColor = 'white';
1770
- cursor.style.borderColor = '#999';
1771
- } else {
1772
- // Pen: Tool color
1773
- cursor.style.backgroundColor = currentPenColor;
1774
- cursor.style.borderColor = 'rgba(255,255,255,0.8)';
1775
- }
1776
- }
1777
 
1778
- function updateSizeBtnUI() {
1779
- document.querySelectorAll('.size-btn').forEach(b => {
1780
- if (parseInt(b.dataset.size) === currentLineWidth) {
1781
- b.classList.add('bg-gray-600', 'text-white');
1782
- b.classList.remove('text-gray-400', 'hover:bg-gray-700');
 
 
 
 
1783
  } else {
1784
- b.classList.remove('bg-gray-600', 'text-white');
1785
- b.classList.add('text-gray-400', 'hover:bg-gray-700');
 
1786
  }
1787
- });
1788
- }
1789
 
1790
- window.clearCanvas = () => {
1791
- if (canvas && ctx) {
1792
- ctx.clearRect(0, 0, canvas.width, canvas.height);
 
 
 
 
 
 
 
1793
  }
1794
- };
1795
 
 
 
 
 
 
1796
 
1797
- /**
1798
- * Renders the Transposed Heatmap (Rows=Challenges, Cols=Students)
1799
- */
1800
- function renderTransposedHeatmap(students) {
1801
- const thead = document.getElementById('heatmap-header');
1802
- const tbody = document.getElementById('heatmap-body');
1803
 
1804
- if (students.length === 0) {
1805
- thead.innerHTML = '<th class="p-4 text-left">等待資料...</th>';
1806
- tbody.innerHTML = '<tr><td class="p-10 text-center text-gray-500">尚無學員加入</td></tr>';
1807
- return;
1808
- }
 
1809
 
1810
- // 1. Render Header (Students)
1811
- // Sticky Top for Header Row
1812
- // Sticky Left for the first cell ("Challenge/Student")
1813
- let headerHtml = `
 
 
 
 
 
 
1814
  <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">
1815
  <div class="flex justify-between items-end">
1816
  <span class="text-sm text-gray-400">題目</span>
@@ -1819,8 +1819,8 @@ function renderTransposedHeatmap(students) {
1819
  </th>
1820
  `;
1821
 
1822
- students.forEach(student => {
1823
- headerHtml += `
1824
  <th class="p-2 text-center sticky top-0 bg-gray-800 z-20 border-b border-gray-700 min-w-[80px] group">
1825
  <div class="flex flex-col items-center space-y-2 py-2">
1826
  <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">
@@ -1839,59 +1839,59 @@ function renderTransposedHeatmap(students) {
1839
  </div>
1840
  </th>
1841
  `;
1842
- });
1843
- thead.innerHTML = headerHtml;
1844
 
1845
- // 2. Render Body (Challenges as Rows)
1846
- if (cachedChallenges.length === 0) {
1847
- tbody.innerHTML = '<tr><td colspan="100" class="text-center py-4">沒有題目資料</td></tr>';
1848
- return;
1849
- }
1850
 
1851
- tbody.innerHTML = cachedChallenges.map((c, index) => {
1852
- const colors = { beginner: 'cyan', intermediate: 'blue', advanced: 'purple' };
1853
- const color = colors[c.level] || 'gray';
1854
-
1855
- // Build Row Cells (One per student)
1856
- const rowCells = students.map(student => {
1857
- const p = student.progress?.[c.id];
1858
- let statusClass = 'bg-gray-800/30 border-gray-800'; // Default
1859
- let content = '';
1860
- let action = '';
1861
-
1862
- if (p) {
1863
- if (p.status === 'completed') {
1864
- 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)]';
1865
- content = '✅';
1866
- // Action removed: Moved to prompt list view
1867
- action = `title="完成 - 請點擊標題查看詳情"`;
1868
- } else if (p.status === 'started') {
1869
- // Check stuck
1870
- const startedAt = p.timestamp ? p.timestamp.toDate() : new Date();
1871
- const now = new Date();
1872
- const diffMins = (now - startedAt) / 1000 / 60;
1873
-
1874
- if (diffMins > 5) {
1875
- statusClass = 'bg-red-900/50 border-red-500 animate-pulse cursor-help';
1876
- content = '🆘';
1877
- } else {
1878
- statusClass = 'bg-blue-600/20 border-blue-500';
1879
- content = '🔵';
 
1880
  }
1881
  }
1882
- }
1883
 
1884
- return `
1885
  <td class="p-1 border border-gray-800/50 text-center align-middle h-14 hover:bg-white/5 transition-colors">
1886
  <div class="mx-auto w-10 h-10 rounded-lg border flex items-center justify-center ${statusClass} transition-all duration-300" ${action}>
1887
  ${content}
1888
  </div>
1889
  </td>
1890
  `;
1891
- }).join('');
1892
 
1893
- // Row Header (Challenge Title)
1894
- return `
1895
  <tr class="hover:bg-gray-800/50 transition-colors">
1896
  <td class="p-3 sticky left-0 bg-gray-900 z-10 border-r border-b border-gray-700 shadow-md">
1897
  <div class="flex items-center justify-between">
@@ -1908,7 +1908,7 @@ function renderTransposedHeatmap(students) {
1908
  ${rowCells}
1909
  </tr>
1910
  `;
1911
- }).join('');
1912
- }
1913
 
1914
 
 
717
  alert("新增失敗: " + e.message);
718
  }
719
  });
 
720
 
721
 
 
 
 
 
 
 
 
 
 
 
 
722
 
723
+ // Global helper for remove (hacky but works for simple onclick)
724
+ window.removeInst = async (email) => {
725
+ if (confirm(`確定移除 ${email}?`)) {
 
 
 
 
 
 
 
 
 
 
 
 
726
  try {
727
+ await removeInstructor(email);
728
+ navInstBtn.click(); // Reload
729
+ } catch (e) {
730
+ alert(e.message);
731
+ }
732
+ }
733
+ };
734
 
735
+ // Auto Check Auth (Persistence)
736
+ // We rely on Firebase Auth state observer instead of session storage for security?
737
+ // Or we can just check if user is already signed in.
738
+ import("../services/firebase.js").then(async ({ auth }) => {
739
+ // Handle Redirect Result first
740
+ try {
741
+ console.log("Initializing Auth Check...");
742
+ const { handleRedirectResult } = await import("../services/auth.js");
743
+ const redirectUser = await handleRedirectResult();
744
+ if (redirectUser) console.log("Redirect User Found:", redirectUser.email);
745
+ } catch (e) { console.warn("Redirect check failed", e); }
746
+
747
+ auth.onAuthStateChanged(async (user) => {
748
+ console.log("Auth State Changed to:", user ? user.email : "Logged Out");
749
+ if (user) {
750
+ try {
751
+ console.log("Checking permissions for:", user.email);
752
+ const instructorData = await checkInstructorPermission(user);
753
+ console.log("Permission Result:", instructorData);
754
+
755
+ if (instructorData) {
756
+ console.log("Hiding Modal and Setting Permissions...");
757
+ authModal.classList.add('hidden');
758
+ checkPermissions(instructorData);
759
+
760
+ // Auto-Restore Room View if exists
761
+ const savedRoomCode = localStorage.getItem('vibecoding_room_code');
762
+ if (savedRoomCode) {
763
+ console.log("Restoring Room Session:", savedRoomCode);
764
+ // Restore Room Session using the unified function
765
+ // This ensures all UI elements (including Group Photo button) are shown correctly
766
+ console.log("Calling enterRoom to restore session...");
767
+ enterRoom(savedRoomCode);
768
+ }
769
 
770
+ } else {
771
+ console.warn("User logged in but not an instructor.");
772
+ // Show unauthorized message
773
+ authErrorMsg.textContent = "此帳號無講師權限";
774
+ authErrorMsg.classList.remove('hidden');
775
+ authModal.classList.remove('hidden'); // Ensure modal stays up
 
 
776
  }
777
+ } catch (e) {
778
+ console.error("Permission Check Failed:", e);
779
+ authErrorMsg.textContent = "權限檢查失敗: " + e.message;
 
 
780
  authErrorMsg.classList.remove('hidden');
 
781
  }
782
+ } else {
783
+ authModal.classList.remove('hidden');
 
 
784
  }
785
+ });
 
 
786
  });
 
787
 
788
+ // Define Kick Function globally (robust against auth flow)
789
+ window.confirmKick = async (userId, nickname) => {
790
+ if (confirm(`確定要踢出 ${nickname} 嗎?此動作無法復原。`)) {
791
+ try {
792
+ const { removeUser } = await import("../services/classroom.js");
793
+ await removeUser(userId);
794
+ // UI will update automatically via subscribeToRoom
795
+ } catch (e) {
796
+ console.error("Kick failed:", e);
797
+ alert("移除失敗");
798
+ }
799
  }
800
+ };
 
801
 
802
 
803
+ // Snapshot Logic
804
+ // Snapshot Logic
805
+ if (snapshotBtn) {
806
+ snapshotBtn.addEventListener('click', async () => {
807
+ if (isSnapshotting || typeof htmlToImage === 'undefined') {
808
+ if (typeof htmlToImage === 'undefined') alert("截圖元件尚未載入,請稍候再試");
809
+ return;
810
+ }
811
+ isSnapshotting = true;
812
+
813
+ const overlay = document.getElementById('snapshot-overlay');
814
+ const countEl = document.getElementById('countdown-number');
815
+ const container = document.getElementById('group-photo-container');
816
+ const modal = document.getElementById('group-photo-modal');
817
+
818
+ // Close button hide
819
+ const closeBtn = modal.querySelector('button');
820
+ if (closeBtn) closeBtn.style.opacity = '0';
821
+ snapshotBtn.style.opacity = '0';
822
+
823
+ overlay.classList.remove('hidden');
824
+ overlay.classList.add('flex');
825
+
826
+ // Countdown Sequence
827
+ const runCountdown = (num) => new Promise(resolve => {
828
+ countEl.textContent = num;
829
+ countEl.style.transform = 'scale(1.5)';
830
+ countEl.style.opacity = '1';
831
+
832
+ // Animation reset
833
+ requestAnimationFrame(() => {
834
+ countEl.style.transition = 'all 0.5s ease-out';
835
+ countEl.style.transform = 'scale(1)';
836
+ countEl.style.opacity = '0.5';
837
+ setTimeout(resolve, 1000);
838
+ });
839
  });
 
840
 
841
+ await runCountdown(3);
842
+ await runCountdown(2);
843
+ await runCountdown(1);
844
+
845
+ // Action!
846
+ countEl.textContent = '';
847
+ overlay.classList.add('hidden');
848
+
849
+ // 1. Emojis Explosion
850
+ const emojis = ['🤘', '✌️', '👍', '🫶', '😎', '🔥'];
851
+ const cards = container.querySelectorAll('.group\\/card');
852
+
853
+ cards.forEach(card => {
854
+ // Find the monster image container
855
+ const imgContainer = card.querySelector('.monster-img-container');
856
+ if (!imgContainer) return;
857
+
858
+ // Random Emoji
859
+ const emoji = emojis[Math.floor(Math.random() * emojis.length)];
860
+ const emojiEl = document.createElement('div');
861
+ emojiEl.textContent = emoji;
862
+ // Position: Top-Right of the *Image*, slightly overlapping
863
+ emojiEl.className = 'absolute -top-2 -right-2 text-2xl animate-bounce z-50 drop-shadow-md transform rotate-12';
864
+ emojiEl.style.animationDuration = '0.6s';
865
+ imgContainer.appendChild(emojiEl);
866
+
867
+ // Remove after 3s
868
+ setTimeout(() => emojiEl.remove(), 3000);
869
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
870
 
871
+ // 2. Capture using html-to-image
872
+ setTimeout(async () => {
873
+ try {
874
+ // Flash Effect
875
+ const flash = document.createElement('div');
876
+ flash.className = 'fixed inset-0 bg-white z-[100] transition-opacity duration-300 pointer-events-none';
877
+ document.body.appendChild(flash);
878
+ setTimeout(() => flash.style.opacity = '0', 50);
879
+ setTimeout(() => flash.remove(), 300);
880
+
881
+ // Use htmlToImage.toPng
882
+ const dataUrl = await htmlToImage.toPng(container, {
883
+ backgroundColor: '#111827',
884
+ pixelRatio: 2,
885
+ cacheBust: true,
886
+ });
887
 
888
+ // Download
889
+ const link = document.createElement('a');
890
+ const dateStr = new Date().toISOString().slice(0, 10);
891
+ link.download = `VIBE_Class_Photo_${dateStr}.png`;
892
+ link.href = dataUrl;
893
+ link.click();
894
+
895
+ } catch (e) {
896
+ console.error("Snapshot failed:", e);
897
+ alert("截圖失敗 (請嘗試手動截圖/PrtSc)\n原因: " + e.message);
898
+ } finally {
899
+ // Restore UI
900
+ if (closeBtn) closeBtn.style.opacity = '1';
901
+ snapshotBtn.style.opacity = '1';
902
+ isSnapshotting = false;
903
+ }
904
+ }, 600); // Slight delay for emojis to appear
905
+ });
906
+ }
907
 
908
+ // Group Photo Logic
909
+ if (groupPhotoBtn) {
910
+ groupPhotoBtn.addEventListener('click', () => {
911
+ const modal = document.getElementById('group-photo-modal');
912
+ const container = document.getElementById('group-photo-container');
913
+ const dateEl = document.getElementById('photo-date');
914
 
915
+ // Update Date
916
+ const now = new Date();
917
+ dateEl.textContent = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} `;
918
 
919
+ // Get saved name
920
+ const savedName = localStorage.getItem('vibecoding_instructor_name') || '講師 (Instructor)';
921
 
922
+ container.innerHTML = '';
923
 
924
+ // 1. Container for Relative Positioning with Custom Background
925
+ const relativeContainer = document.createElement('div');
926
+ relativeContainer.className = 'relative w-full h-[600px] md:h-[700px] overflow-hidden rounded-3xl border border-gray-700/30 shadow-2xl flex items-center justify-center bg-cover bg-center';
927
+ relativeContainer.style.backgroundImage = "url('assets/photobg.png')";
928
+ container.appendChild(relativeContainer);
929
 
930
+ // Watermark (Bottom Right, High Z-Index, Gradient Text, Dark Backdrop)
931
+ const watermark = document.createElement('div');
932
+ watermark.className = 'absolute bottom-2 right-2 md:bottom-4 md:right-4 z-[60] bg-black/70 backdrop-blur-sm rounded-lg px-4 py-2 pointer-events-none select-none border border-white/10 shadow-lg';
933
 
934
+ const d = new Date();
935
+ const dateStr = `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} `;
936
 
937
+ watermark.innerHTML = `
938
  <span class="text-lg md:text-2xl font-black font-mono bg-clip-text text-transparent bg-gradient-to-r from-cyan-400 to-purple-400 tracking-wider">
939
  ${dateStr} VibeCoding 怪獸成長營
940
  </span>
941
  `;
942
+ relativeContainer.appendChild(watermark);
943
 
944
+ // 2. Instructor Section (Absolute Center)
945
+ const instructorSection = document.createElement('div');
946
+ instructorSection.className = 'absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center justify-center z-20 group cursor-pointer';
947
+ instructorSection.innerHTML = `
948
  <div class="relative">
949
  <div class="absolute inset-0 bg-yellow-500/20 blur-3xl rounded-full animate-pulse"></div>
950
  <!--Pixel Art Avatar-->
 
963
  </div>
964
  </div>
965
  `;
966
+ relativeContainer.appendChild(instructorSection);
967
+
968
+ // Save name on change
969
+ setTimeout(() => {
970
+ const input = document.getElementById('instructor-name-input');
971
+ if (input) {
972
+ input.addEventListener('input', (e) => {
973
+ localStorage.setItem('vibecoding_instructor_name', e.target.value);
974
+ });
975
+ }
976
+ }, 100);
977
+
978
+ // 3. Students Scatter
979
+ if (currentStudents.length > 0) {
980
+ // Randomize array to prevent fixed order bias
981
+ const students = [...currentStudents].sort(() => Math.random() - 0.5);
982
+ const total = students.length;
983
+
984
+ // --- Dynamic Sizing Logic ---
985
+ let sizeClass = 'w-20 h-20 md:w-24 md:h-24'; // Default (Size 100%)
986
+ let scaleFactor = 1.0;
987
+
988
+ if (total >= 40) {
989
+ sizeClass = 'w-12 h-12 md:w-14 md:h-14'; // Size 60%
990
+ scaleFactor = 0.6;
991
+ } else if (total >= 20) {
992
+ sizeClass = 'w-16 h-16 md:w-20 md:h-20'; // Size 80%
993
+ scaleFactor = 0.8;
994
+ }
995
 
996
+ students.forEach((s, index) => {
997
+ const progressMap = s.progress || {};
998
+ const totalLikes = Object.values(progressMap).reduce((acc, p) => acc + (p.likes || 0), 0);
999
+ const totalCompleted = Object.values(progressMap).filter(p => p.status === 'completed').length;
1000
+
1001
+ // FIXED: Prioritize stored ID if valid (same as StudentView logic)
1002
+ let monster;
1003
+ if (s.monster_id && s.monster_id !== 'Egg' && s.monster_id !== 'Unknown') {
1004
+ const stored = MONSTER_DEFS?.find(m => m.id === s.monster_id);
1005
+ if (stored) {
1006
+ monster = stored;
1007
+ } else {
1008
+ // Fallback if ID invalid
1009
+ monster = getNextMonster(s.monster_stage || 0, totalLikes, total, s.monster_id);
1010
+ }
1011
  } else {
 
1012
  monster = getNextMonster(s.monster_stage || 0, totalLikes, total, s.monster_id);
1013
  }
 
 
 
1014
 
1015
+ // --- FIXED: Even Arc Distribution (Safe Zone: 135 deg to 405 deg) ---
1016
+ // Avoids Bottom Right (0-90) and Bottom Center (90-120) entirely
1017
+ const minR = 220;
1018
+ // Avoids Bottom Right (0-90) and Bottom Center (90-120) entirely
1019
 
1020
+ // Safe Arc Range: Starts at 135 deg (Bottom Left) -> Goes CW -> Ends at 405 deg (45 deg, Bottom Right)
1021
+ // Total Span = 270 degrees
1022
+ // If many students, use double ring
1023
 
1024
+ const safeStartAngle = 135 * (Math.PI / 180);
1025
+ const safeSpan = 270 * (Math.PI / 180);
1026
 
1027
+ // Distribute evenly
1028
+ // If only 1 student, put at top (270 deg / 4.71 rad)
1029
+ let finalAngle;
1030
 
1031
+ if (total === 1) {
1032
+ finalAngle = 270 * (Math.PI / 180);
1033
+ } else {
1034
+ const step = safeSpan / (total - 1);
1035
+ finalAngle = safeStartAngle + (step * index);
1036
+ }
1037
 
1038
+ // Radius: Fixed base + slight variation for "natural" look (but not overlap causing)
1039
+ // Double ring logic if crowded
1040
+ let radius = minR + (index % 2) * 40; // Zigzag radius (220, 260, 220...) to minimize overlap
1041
 
1042
+ // Reduce zigzag if few students
1043
+ if (total < 10) radius = minR + (index % 2) * 20;
1044
 
1045
+ const xOff = Math.cos(finalAngle) * radius;
1046
+ const yOff = Math.sin(finalAngle) * radius * 0.8;
1047
 
1048
+ const card = document.createElement('div');
1049
+ card.className = 'absolute flex flex-col items-center group/card z-10 hover:z-50 transition-all duration-500 cursor-move';
1050
 
1051
+ card.style.left = `calc(50% + ${xOff}px)`;
1052
+ card.style.top = `calc(50% + ${yOff}px)`;
1053
+ card.style.transform = 'translate(-50%, -50%)';
1054
 
1055
+ const floatDelay = Math.random() * 2;
1056
 
1057
+ card.innerHTML = `
1058
  <!--Top Info: Monster Stats-->
1059
  <div class="mb-1 text-center bg-gray-900/60 backdrop-blur-sm rounded-lg px-2 py-1 border border-gray-600/30 group-hover/card:bg-gray-800 group-hover/card:border-cyan-500/50 transition-all opacity-80 group-hover/card:opacity-100 transform translate-y-2 group-hover/card:translate-y-0 duration-300">
1060
  <div class="text-[10px] text-gray-300 mb-0.5 whitespace-nowrap">${monster.name.split(' ')[1] || monster.name}</div>
 
1079
  <div class="text-xs font-bold text-white shadow-black drop-shadow-md whitespace-nowrap tracking-wide">${s.nickname}</div>
1080
  </div>
1081
  `;
1082
+ relativeContainer.appendChild(card);
1083
 
1084
+ // Enable Drag & Drop
1085
+ setupDraggable(card, relativeContainer);
1086
+ });
1087
+ }
1088
 
1089
+ modal.classList.remove('hidden');
1090
+ });
1091
+ }
1092
 
1093
+ // Helper: Drag & Drop Logic
1094
+ function setupDraggable(el, container) {
1095
+ let isDragging = false;
1096
+ let startX, startY, initialLeft, initialTop;
1097
 
1098
+ el.addEventListener('mousedown', (e) => {
1099
+ isDragging = true;
1100
+ startX = e.clientX;
1101
+ startY = e.clientY;
1102
 
1103
+ // Disable transition during drag for responsiveness
1104
+ el.style.transition = 'none';
1105
+ el.style.zIndex = 100; // Bring to front
1106
 
1107
+ // Convert current computed position to fixed pixels if relying on calc
1108
+ const rect = el.getBoundingClientRect();
1109
+ const containerRect = container.getBoundingClientRect();
1110
 
1111
+ // Calculate position relative to container
1112
+ // The current transform is translate(-50%, -50%).
1113
+ // We want to set left/top such that the center remains under the mouse offset,
1114
+ // but for simplicity, let's just use current offsetLeft/Top if possible,
1115
+ // OR robustly recalculate from rects.
1116
 
1117
+ // Current center point relative to container:
1118
+ const centerX = rect.left - containerRect.left + rect.width / 2;
1119
+ const centerY = rect.top - containerRect.top + rect.height / 2;
1120
 
1121
+ // Set explicit pixel values replacing calc()
1122
+ el.style.left = `${centerX}px`;
1123
+ el.style.top = `${centerY}px`;
1124
 
1125
+ initialLeft = centerX;
1126
+ initialTop = centerY;
1127
+ });
1128
 
1129
+ window.addEventListener('mousemove', (e) => {
1130
+ if (!isDragging) return;
1131
+ e.preventDefault();
1132
 
1133
+ const dx = e.clientX - startX;
1134
+ const dy = e.clientY - startY;
1135
 
1136
+ el.style.left = `${initialLeft + dx}px`;
1137
+ el.style.top = `${initialTop + dy}px`;
1138
+ });
1139
 
1140
+ window.addEventListener('mouseup', () => {
1141
+ if (isDragging) {
1142
+ isDragging = false;
1143
+ el.style.transition = ''; // Re-enable hover effects
1144
+ el.style.zIndex = ''; // Restore z-index rule (or let hover take over)
1145
+ }
1146
+ });
1147
+ }
1148
 
1149
+ // Add float animation style if not exists
1150
+ if (!document.getElementById('anim-float')) {
1151
+ const style = document.createElement('style');
1152
+ style.id = 'anim-float';
1153
+ style.innerHTML = `
1154
  @keyframes float {
1155
 
1156
  0 %, 100 % { transform: translateY(0) scale(1); }
 
1158
  }
1159
  }
1160
  `;
1161
+ document.head.appendChild(style);
1162
+ }
1163
 
1164
+ // Gallery Logic
1165
+ document.getElementById('btn-open-gallery').addEventListener('click', () => {
1166
+ window.open('monster_preview.html', '_blank');
1167
+ });
1168
+
1169
+ // Logout Logic
1170
+ document.getElementById('logout-btn').addEventListener('click', async () => {
1171
+ if (confirm('確定要登出講師模式嗎? (將會回到首頁)')) {
1172
+ await signOutUser();
1173
+ sessionStorage.removeItem('vibecoding_instructor_in_room');
1174
+ sessionStorage.removeItem('vibecoding_admin_referer');
1175
+ window.location.hash = '';
1176
+ window.location.reload();
1177
+ }
1178
+ });
1179
+
1180
+ // Check Previous Session (Handled by onAuthStateChanged now)
1181
+ // if (sessionStorage.getItem('vibecoding_instructor_auth') === 'true') {
1182
+ // authModal.classList.add('hidden');
1183
+ // }
1184
+
1185
+ // Check Active Room State
1186
+ const activeRoom = sessionStorage.getItem('vibecoding_instructor_in_room');
1187
+ if (activeRoom === 'true' && savedRoomCode) {
1188
+ enterRoom(savedRoomCode);
1189
  }
 
1190
 
1191
+ // Module-level variable to track subscription (Moved to top)
 
 
 
1192
 
1193
+ function enterRoom(roomCode) {
1194
+ createContainer.classList.add('hidden');
1195
+ roomInfo.classList.remove('hidden');
1196
+ dashboardContent.classList.remove('hidden');
1197
+ document.getElementById('group-photo-btn').classList.remove('hidden'); // Show photo button
1198
+ displayRoomCode.textContent = roomCode;
1199
+ localStorage.setItem('vibecoding_room_code', roomCode);
1200
+ sessionStorage.setItem('vibecoding_instructor_in_room', 'true');
1201
 
1202
+ // Unsubscribe previous if any
1203
+ if (roomUnsubscribe) roomUnsubscribe();
1204
 
1205
+ // Subscribe to updates
1206
+ roomUnsubscribe = subscribeToRoom(roomCode, (students) => {
1207
+ currentStudents = students;
1208
+ renderTransposedHeatmap(students);
1209
+ });
1210
+ }
 
 
1211
 
1212
+ // Leave Room Logic
1213
+ document.getElementById('leave-room-btn').addEventListener('click', () => {
1214
+ if (confirm('確定要離開目前教室嗎?(不會刪除教室資料,僅回到選擇介面)')) {
1215
+ // Unsubscribe
1216
+ if (roomUnsubscribe) {
1217
+ roomUnsubscribe();
1218
+ roomUnsubscribe = null;
1219
+ }
1220
 
1221
+ // UI Reset
1222
+ createContainer.classList.remove('hidden');
1223
+ roomInfo.classList.add('hidden');
1224
+ dashboardContent.classList.add('hidden');
1225
+ document.getElementById('group-photo-btn').classList.add('hidden'); // Hide photo button
 
1226
 
1227
+ // Clear Data Display
1228
+ document.getElementById('heatmap-body').innerHTML = '<tr><td colspan="100" class="text-center py-10 text-gray-500">等待資料載入...</td></tr>';
1229
+ 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>';
1230
+
1231
+ // State Clear
1232
+ sessionStorage.removeItem('vibecoding_instructor_in_room');
1233
+ localStorage.removeItem('vibecoding_room_code');
1234
  }
1235
+ });
1236
 
1237
+ // Modal Events
1238
+ window.showBroadcastModal = (userId, challengeId) => {
1239
+ const modal = document.getElementById('broadcast-modal');
1240
+ const content = document.getElementById('broadcast-content');
 
1241
 
1242
+ // Find Data
1243
+ const student = currentStudents.find(s => s.id === userId);
1244
+ if (!student) return alert('找不到學員資料');
1245
 
1246
+ const p = student.progress ? student.progress[challengeId] : null;
1247
+ if (!p) return alert('找不到該作品資料');
1248
+
1249
+ const challenge = cachedChallenges.find(c => c.id === challengeId);
1250
+ const title = challenge ? challenge.title : '未知題目';
1251
+
1252
+ // Populate UI
1253
+ document.getElementById('broadcast-avatar').textContent = student.nickname[0] || '?';
1254
+ document.getElementById('broadcast-author').textContent = student.nickname;
1255
+ document.getElementById('broadcast-challenge').textContent = title;
1256
+ document.getElementById('broadcast-prompt').textContent = p.prompt || '(無內容)';
1257
+
1258
+ // Store IDs for Actions (Reject/BroadcastAll)
1259
+ modal.dataset.userId = userId;
1260
+ modal.dataset.challengeId = challengeId;
1261
+
1262
+ // Show
1263
+ modal.classList.remove('hidden');
1264
+ setTimeout(() => {
1265
+ content.classList.remove('scale-95', 'opacity-0');
1266
+ content.classList.add('opacity-100', 'scale-100');
1267
+ }, 10);
1268
+ };
1269
+
1270
+ window.closeBroadcast = () => {
1271
+ const modal = document.getElementById('broadcast-modal');
1272
+ const content = document.getElementById('broadcast-content');
1273
+ content.classList.remove('opacity-100', 'scale-100');
1274
+ content.classList.add('scale-95', 'opacity-0');
1275
+ setTimeout(() => modal.classList.add('hidden'), 300);
1276
+ };
1277
+
1278
+ window.openStage = (prompt, author) => {
1279
+ document.getElementById('broadcast-content').classList.add('hidden');
1280
+ const stage = document.getElementById('stage-view');
1281
+ stage.classList.remove('hidden');
1282
+ document.getElementById('stage-prompt').textContent = cleanText(prompt || '');
1283
+ document.getElementById('stage-author').textContent = author;
1284
+ };
1285
+
1286
+ window.closeStage = () => {
1287
+ document.getElementById('stage-view').classList.add('hidden');
1288
+ document.getElementById('broadcast-content').classList.remove('hidden');
1289
+ };
1290
+
1291
+ document.getElementById('btn-show-stage').addEventListener('click', () => {
1292
+ const prompt = document.getElementById('broadcast-prompt').textContent;
1293
+ const author = document.getElementById('broadcast-author').textContent;
1294
+ window.openStage(prompt, author);
1295
+ });
1296
+
1297
+ // Reject Logic
1298
+ document.getElementById('btn-reject-task').addEventListener('click', async () => {
1299
+ if (!confirm('確定要退回此題目讓學員重做嗎?')) return;
1300
+
1301
+ // We need student ID (userId) and Challenge ID.
1302
+ // Currently showBroadcastModal only receives nickname, title, prompt.
1303
+ // We need to attach data-userid and data-challengeid to the modal.
1304
+ const modal = document.getElementById('broadcast-modal');
1305
+ const userId = modal.dataset.userId;
1306
+ const challengeId = modal.dataset.challengeId;
1307
+ const roomCode = localStorage.getItem('vibecoding_room_code');
1308
+
1309
+ if (userId && challengeId && roomCode) {
1310
+ try {
1311
+ await resetProgress(userId, roomCode, challengeId);
1312
+ alert('已成功退回,學員可重新作答');
1313
+ // Close modal
1314
+ window.closeBroadcast();
1315
+ } catch (e) {
1316
+ console.error('退回失敗:', e);
1317
+ alert('退回失敗: ' + e.message);
1318
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1319
  }
1320
+ });
1321
+ // Prompt Viewer Logic
1322
+ window.openPromptList = (type, id, title) => {
1323
+ const modal = document.getElementById('prompt-list-modal');
1324
+ const container = document.getElementById('prompt-list-container');
1325
+ const titleEl = document.getElementById('prompt-list-title');
1326
+
1327
+ titleEl.textContent = type === 'student' ? `${title} 的所有提示詞` : `題目:${title} 的所有作品`;
1328
+
1329
+ // Reset Anonymous Toggle in List View
1330
+ const anonCheck = document.getElementById('list-anonymous-toggle');
1331
+ if (anonCheck) anonCheck.checked = false;
1332
+
1333
+ container.innerHTML = '';
1334
+ modal.classList.remove('hidden');
1335
+
1336
+ // Collect Prompts
1337
+ let prompts = [];
1338
+ // Fix: Reset selection when opening new list to prevent cross-contamination
1339
+ selectedPrompts = [];
1340
+ updateCompareButton();
1341
+
1342
+ if (type === 'student') {
1343
+ const student = currentStudents.find(s => s.id === id);
1344
+ if (student && student.progress) {
1345
+ prompts = Object.entries(student.progress)
1346
+ .filter(([_, p]) => p.status === 'completed' && p.prompt)
1347
+ .map(([challengeId, p]) => {
1348
+ const challenge = cachedChallenges.find(c => c.id === challengeId);
1349
+ return {
1350
+ id: `${student.id}_${challengeId}`,
1351
+ title: challenge ? challenge.title : '未知題目',
1352
+ prompt: p.prompt,
1353
+ author: student.nickname,
1354
+ studentId: student.id,
1355
+ challengeId: challengeId,
1356
+ time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : ''
1357
+ };
1358
  });
 
1359
  }
1360
+ } else if (type === 'challenge') {
1361
+ currentStudents.forEach(student => {
1362
+ if (student.progress && student.progress[id]) {
1363
+ const p = student.progress[id];
1364
+ if (p.status === 'completed' && p.prompt) {
1365
+ prompts.push({
1366
+ id: `${student.id}_${id}`,
1367
+ title: student.nickname, // When viewing challenge, title is student name
1368
+ prompt: p.prompt,
1369
+ author: student.nickname,
1370
+ studentId: student.id,
1371
+ challengeId: id,
1372
+ time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : ''
1373
+ });
1374
+ }
1375
+ }
1376
+ });
1377
+ }
1378
 
1379
+ if (prompts.length === 0) {
1380
+ container.innerHTML = '<div class="col-span-full text-center text-gray-500 py-10">無資料</div>';
1381
+ return;
1382
+ }
1383
 
1384
+ prompts.forEach(p => {
1385
+ const card = document.createElement('div');
1386
+ // Reduced height (h-64 -> h-48) and padding, but larger text inside
1387
+ 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';
1388
+ card.innerHTML = `
1389
  <div class="flex justify-between items-start mb-1.5">
1390
  <h3 class="font-bold text-white text-base truncate w-3/4" title="${p.title}">${p.title}</h3>
1391
  <!-- Checkbox -->
 
1406
  </div>
1407
  </div>
1408
  `;
1409
+ container.appendChild(card);
1410
+ });
1411
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
1412
 
1413
+ // Helper Actions
1414
+ window.confirmReset = async (userId, challengeId, title) => {
1415
+ console.log('🔵 confirmReset called');
1416
+ console.log(' userId:', userId);
1417
+ console.log(' challengeId:', challengeId);
1418
+ console.log(' title:', title);
1419
+ console.log(' typeof resetProgress:', typeof resetProgress);
1420
+
1421
+ if (confirm(`確定要退回 ${title} 嗎?此動作將清除學員目前的進度。`)) {
1422
+ console.log('✅ User confirmed reset');
1423
+ const roomCode = localStorage.getItem('vibecoding_room_code');
1424
+ console.log(' roomCode:', roomCode);
1425
+
1426
+ if (userId && challengeId && roomCode) {
1427
+ console.log('✅ All parameters valid, calling resetProgress...');
1428
+ try {
1429
+ // Use already imported resetProgress function
1430
+ await resetProgress(userId, roomCode, challengeId);
1431
+ console.log('✅ resetProgress completed successfully');
1432
+ alert("已退回");
1433
+ // close modal to refresh data context
1434
+ document.getElementById('prompt-list-modal').classList.add('hidden');
1435
+ console.log('✅ Modal closed');
1436
+ } catch (e) {
1437
+ console.error("❌ 退回失敗:", e);
1438
+ console.error("Error stack:", e.stack);
1439
+ alert("退回失敗: " + e.message);
1440
+ }
1441
+ } else {
1442
+ console.error('❌ Missing required parameters:');
1443
+ console.error(' userId:', userId, '(valid:', !!userId, ')');
1444
+ console.error(' challengeId:', challengeId, '(valid:', !!challengeId, ')');
1445
+ console.error(' roomCode:', roomCode, '(valid:', !!roomCode, ')');
1446
+ alert('缺少必要參數,無法執行退回操作');
1447
  }
1448
  } else {
1449
+ console.log('❌ User cancelled reset');
 
 
 
 
1450
  }
1451
+ };
 
 
 
1452
 
1453
+ window.broadcastPrompt = (userId, challengeId) => {
1454
+ window.showBroadcastModal(userId, challengeId);
1455
+ };
1456
 
1457
+ // Selection Logic
1458
+ let selectedPrompts = []; // Stores IDs
1459
 
1460
+ window.handlePromptSelection = (checkbox) => {
1461
+ const id = checkbox.dataset.id;
1462
 
1463
+ if (checkbox.checked) {
1464
+ if (selectedPrompts.length >= 3) {
1465
+ checkbox.checked = false;
1466
+ alert('最多只能選擇 3 個提示詞進行比較');
1467
+ return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1468
  }
1469
+ selectedPrompts.push(id);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1470
  } else {
1471
+ selectedPrompts = selectedPrompts.filter(pid => pid !== id);
1472
  }
1473
+ updateCompareButton();
1474
+ };
1475
+
1476
+ function updateCompareButton() {
1477
+ const btn = document.getElementById('btn-compare-prompts');
1478
+ if (!btn) return;
1479
+
1480
+ const count = selectedPrompts.length;
1481
+ const span = btn.querySelector('span');
1482
+ if (span) span.textContent = `🔍 比較已選項目 (${count}/3)`;
1483
+
1484
+ if (count > 0) {
1485
+ btn.disabled = false;
1486
+ btn.classList.remove('opacity-50', 'cursor-not-allowed');
 
 
 
1487
  } else {
1488
+ btn.disabled = true;
1489
+ btn.classList.add('opacity-50', 'cursor-not-allowed');
 
1490
  }
1491
  }
1492
+ // Comparison Logic
1493
+ const compareBtn = document.getElementById('btn-compare-prompts');
1494
+ if (compareBtn) {
1495
+ compareBtn.addEventListener('click', () => {
1496
+ const dataToCompare = [];
1497
+ selectedPrompts.forEach(fullId => {
1498
+ const lastUnderscore = fullId.lastIndexOf('_');
1499
+ const studentId = fullId.substring(0, lastUnderscore);
1500
+ const challengeId = fullId.substring(lastUnderscore + 1);
1501
+
1502
+ const student = currentStudents.find(s => s.id === studentId);
1503
+ if (student && student.progress && student.progress[challengeId]) {
1504
+ const p = student.progress[challengeId];
1505
+ const challenge = cachedChallenges.find(c => c.id === challengeId);
1506
+
1507
+ dataToCompare.push({
1508
+ title: challenge ? challenge.title : '未知',
1509
+ author: student.nickname,
1510
+ prompt: p.prompt,
1511
+ time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleTimeString() : ''
1512
+ });
1513
+ }
1514
+ });
1515
+
1516
+ const isAnon = document.getElementById('list-anonymous-toggle')?.checked || false;
1517
+ openComparisonView(dataToCompare, isAnon);
1518
+ });
1519
+ }
1520
+
1521
+ let isAnonymous = false;
1522
+
1523
+ window.toggleAnonymous = (btn) => {
1524
+ isAnonymous = !isAnonymous;
1525
+ btn.textContent = isAnonymous ? '🙈 顯示姓名' : '👀 隱藏姓名';
1526
+ btn.classList.toggle('bg-gray-700');
1527
+ btn.classList.toggle('bg-purple-700');
1528
+
1529
+ // Update DOM
1530
+ document.querySelectorAll('.comparison-author').forEach(el => {
1531
+ if (isAnonymous) {
1532
+ el.dataset.original = el.textContent;
1533
+ el.textContent = '學員';
1534
+ el.classList.add('blur-sm'); // Optional Effect
1535
+ setTimeout(() => el.classList.remove('blur-sm'), 300);
1536
+ } else {
1537
+ if (el.dataset.original) el.textContent = el.dataset.original;
1538
+ }
1539
+ });
1540
+ };
1541
 
1542
+ window.openComparisonView = (items, initialAnonymous = false) => {
1543
+ const modal = document.getElementById('comparison-modal');
1544
+ const grid = document.getElementById('comparison-grid');
1545
+
1546
+ // Apply Anonymous State
1547
+ isAnonymous = initialAnonymous;
1548
+ const anonBtn = document.getElementById('btn-anonymous-toggle');
1549
+
1550
+ // Update Toggle UI to match state
1551
+ if (anonBtn) {
1552
+ if (isAnonymous) {
1553
+ anonBtn.textContent = '🙈 顯示姓名';
1554
+ anonBtn.classList.add('bg-purple-700');
1555
+ anonBtn.classList.remove('bg-gray-700');
1556
+ } else {
1557
+ anonBtn.textContent = '👀 隱藏姓名';
1558
+ anonBtn.classList.remove('bg-purple-700');
1559
+ anonBtn.classList.add('bg-gray-700');
1560
+ }
 
 
 
 
1561
  }
1562
 
1563
+ // Setup Grid Rows (Vertical Stacking)
1564
+ let rowClass = 'grid-rows-1';
1565
+ if (items.length === 2) rowClass = 'grid-rows-2';
1566
+ if (items.length === 3) rowClass = 'grid-rows-3';
1567
+
1568
+ grid.className = `absolute inset-0 grid ${rowClass} gap-0 divide-y divide-gray-600`;
1569
+ grid.innerHTML = '';
1570
+
1571
+ items.forEach(item => {
1572
+ const col = document.createElement('div');
1573
+ // Check overflow-hidden to keep it contained, use flex-row for compact header + content
1574
+ col.className = 'flex flex-row h-full bg-gray-900 p-4 overflow-hidden';
1575
+
1576
+ // Logic for anonymous
1577
+ let displayAuthor = item.author;
1578
+ let blurClass = '';
1579
+
1580
+ if (isAnonymous) {
1581
+ displayAuthor = '學員';
1582
+ blurClass = 'blur-sm'; // Initial blur
1583
+ // Auto remove blur after delay if needed, or keep it?
1584
+ // Toggle logic removes it after delay. But initial render should probably just be static '學員' or blurred.
1585
+ // The toggle logic uses dataset.original. We need to set it here too.
1586
+ }
1587
+
1588
+ col.innerHTML = `
1589
  <div class="w-48 flex-shrink-0 border-r border-gray-700 pr-4 mr-4 flex flex-col justify-center">
1590
  <h3 class="text-xl font-bold text-cyan-400 mb-1 comparison-author ${blurClass}" data-original="${item.author}">${displayAuthor}</h3>
1591
  <p class="text-md text-gray-400 truncate" title="${item.title}">${item.title}</p>
 
1594
  <!-- Prompt Content: Larger Text (text-4xl) -->
1595
  <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>
1596
  `;
1597
+ grid.appendChild(col);
1598
+
1599
+ // If blurred, remove blur after animation purely for effect, or keep?
1600
+ // User intention "Hidden Name" usually means "Replaced by generic name".
1601
+ // The blur effect in toggle logic was transient.
1602
+ // If we want persistent anonymity, just "學員" is enough.
1603
+ // The existing toggle logic adds 'blur-sm' then removes it in 300ms.
1604
+ // We should replicate that effect if we want consistency, or just skip blur on init.
1605
+ if (isAnonymous) {
1606
+ const el = col.querySelector('.comparison-author');
1607
+ setTimeout(() => el.classList.remove('blur-sm'), 300);
1608
+ }
1609
+ });
1610
 
1611
+ document.getElementById('prompt-list-modal').classList.add('hidden');
1612
+ modal.classList.remove('hidden');
1613
+
1614
+ // Init Canvas (Phase 3)
1615
+ setTimeout(setupCanvas, 100);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1616
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1617
 
1618
+ window.closeComparison = () => {
1619
+ document.getElementById('comparison-modal').classList.add('hidden');
1620
+ clearCanvas();
1621
+ };
1622
 
1623
+ // --- Phase 3 & 6: Annotation Tools ---
1624
+ let canvas, ctx;
1625
+ let isDrawing = false;
1626
+ let currentPenColor = '#ef4444'; // Red default
1627
+ let currentLineWidth = 3;
1628
+ let currentMode = 'source-over'; // 'source-over' (Pen) or 'destination-out' (Eraser)
1629
+
1630
+ window.setupCanvas = () => {
1631
+ canvas = document.getElementById('annotation-canvas');
1632
+ const container = document.getElementById('comparison-container');
1633
+ if (!canvas || !container) return;
1634
+
1635
+ ctx = canvas.getContext('2d');
1636
+
1637
+ // Resize
1638
+ const resize = () => {
1639
+ canvas.width = container.clientWidth;
1640
+ canvas.height = container.clientHeight;
1641
+ ctx.lineCap = 'round';
1642
+ ctx.lineJoin = 'round';
1643
+ ctx.strokeStyle = currentPenColor;
1644
+ ctx.lineWidth = currentLineWidth;
1645
+ ctx.globalCompositeOperation = currentMode;
1646
+ };
1647
+ resize();
1648
+ window.addEventListener('resize', resize);
1649
+
1650
+ // Init Size UI & Cursor
1651
+ updateSizeBtnUI();
1652
+ updateCursorStyle();
1653
+
1654
+ // Cursor Logic
1655
+ const cursor = document.getElementById('tool-cursor');
1656
+
1657
+ canvas.addEventListener('mouseenter', () => cursor.classList.remove('hidden'));
1658
+ canvas.addEventListener('mouseleave', () => cursor.classList.add('hidden'));
1659
+ canvas.addEventListener('mousemove', (e) => {
1660
+ const { x, y } = getPos(e);
1661
+ cursor.style.left = `${x}px`;
1662
+ cursor.style.top = `${y}px`;
1663
+ });
1664
 
1665
+ // Drawing Events
1666
+ const start = (e) => {
1667
+ isDrawing = true;
1668
+ ctx.beginPath();
1669
+
1670
+ // Re-apply settings (state might change)
1671
+ ctx.globalCompositeOperation = currentMode;
1672
+ ctx.strokeStyle = currentPenColor;
1673
+ ctx.lineWidth = currentLineWidth;
1674
+
1675
+ const { x, y } = getPos(e);
1676
+ ctx.moveTo(x, y);
1677
+ };
1678
+
1679
+ const move = (e) => {
1680
+ if (!isDrawing) return;
1681
+ const { x, y } = getPos(e);
1682
+ ctx.lineTo(x, y);
1683
+ ctx.stroke();
1684
+ };
1685
+
1686
+ const end = () => {
1687
+ isDrawing = false;
1688
+ };
1689
+
1690
+ canvas.onmousedown = start;
1691
+ canvas.onmousemove = move;
1692
+ canvas.onmouseup = end;
1693
+ canvas.onmouseleave = end;
1694
+
1695
+ // Touch support
1696
+ canvas.ontouchstart = (e) => { e.preventDefault(); start(e.touches[0]); };
1697
+ canvas.ontouchmove = (e) => { e.preventDefault(); move(e.touches[0]); };
1698
+ canvas.ontouchend = (e) => { e.preventDefault(); end(); };
1699
  };
1700
 
1701
+ function getPos(e) {
1702
+ const rect = canvas.getBoundingClientRect();
1703
+ return {
1704
+ x: e.clientX - rect.left,
1705
+ y: e.clientY - rect.top
1706
+ };
1707
+ }
1708
 
1709
+ // Unified Tool Handler
1710
+ window.setPenTool = (tool, color, btn) => {
1711
+ // UI Update
1712
+ document.querySelectorAll('.annotation-tool').forEach(b => {
1713
+ b.classList.remove('ring-white');
1714
+ b.classList.add('ring-transparent');
1715
+ });
1716
+ btn.classList.remove('ring-transparent');
1717
+ btn.classList.add('ring-white');
1718
+
1719
+ if (tool === 'eraser') {
1720
+ currentMode = 'destination-out';
1721
+ // Force larger eraser size (e.g., 3x current size or fixed large)
1722
+ // We'll multiply current selected size by 4 for better UX
1723
+ const multiplier = 4;
1724
+ // Store original explicitly if needed, but currentLineWidth is global.
1725
+ // We should dynamically adjust context lineWidth during draw, or just hack it here.
1726
+ // Hack: If we change currentLineWidth here, the UI size buttons might look wrong.
1727
+ // Better: Update cursor style only? No, actual draw needs it.
1728
+ // Let's set a separate 'actualWidth' used in draw, OR just change currentLineWidth temporarily?
1729
+ // Simpler: Just change it. When user clicks size button, it resets.
1730
+ // But if user clicks Pen back? We need to restore.
1731
+ // Let's rely on setPenTool being called with color.
1732
+ // When "Pen" is clicked, we usually don't call setPenTool with a saved size...
1733
+ // Actually, let's just use a large default for eraser, and keep currentLineWidth for Pen.
1734
+ // We need to change how draw() uses the width.
1735
+ // BUT, since we don't want to touch draw() deep inside:
1736
+ // We will hijack currentLineWidth.
1737
+ if (!window.savedPenWidth) window.savedPenWidth = currentLineWidth;
1738
+ currentLineWidth = window.savedPenWidth * 4;
1739
+ } else {
1740
+ currentMode = 'source-over';
1741
+ currentPenColor = color;
1742
+ // Restore pen width
1743
+ if (window.savedPenWidth) {
1744
+ currentLineWidth = window.savedPenWidth;
1745
+ window.savedPenWidth = null;
1746
+ }
1747
+ }
1748
+ updateCursorStyle();
1749
  };
1750
 
1751
+ // Size Handler
1752
+ window.setPenSize = (size, btn) => {
1753
+ currentLineWidth = size;
1754
+ updateSizeBtnUI();
1755
+ updateCursorStyle();
 
 
 
 
 
 
 
 
 
 
 
1756
  };
 
1757
 
1758
+ function updateCursorStyle() {
1759
+ const cursor = document.getElementById('tool-cursor');
1760
+ if (!cursor) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1761
 
1762
+ // Size
1763
+ cursor.style.width = `${currentLineWidth}px`;
1764
+ cursor.style.height = `${currentLineWidth}px`;
1765
+
1766
+ // Color
1767
+ if (currentMode === 'destination-out') {
1768
+ // Eraser: White solid
1769
+ cursor.style.backgroundColor = 'white';
1770
+ cursor.style.borderColor = '#999';
1771
  } else {
1772
+ // Pen: Tool color
1773
+ cursor.style.backgroundColor = currentPenColor;
1774
+ cursor.style.borderColor = 'rgba(255,255,255,0.8)';
1775
  }
1776
+ }
 
1777
 
1778
+ function updateSizeBtnUI() {
1779
+ document.querySelectorAll('.size-btn').forEach(b => {
1780
+ if (parseInt(b.dataset.size) === currentLineWidth) {
1781
+ b.classList.add('bg-gray-600', 'text-white');
1782
+ b.classList.remove('text-gray-400', 'hover:bg-gray-700');
1783
+ } else {
1784
+ b.classList.remove('bg-gray-600', 'text-white');
1785
+ b.classList.add('text-gray-400', 'hover:bg-gray-700');
1786
+ }
1787
+ });
1788
  }
 
1789
 
1790
+ window.clearCanvas = () => {
1791
+ if (canvas && ctx) {
1792
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
1793
+ }
1794
+ };
1795
 
 
 
 
 
 
 
1796
 
1797
+ /**
1798
+ * Renders the Transposed Heatmap (Rows=Challenges, Cols=Students)
1799
+ */
1800
+ function renderTransposedHeatmap(students) {
1801
+ const thead = document.getElementById('heatmap-header');
1802
+ const tbody = document.getElementById('heatmap-body');
1803
 
1804
+ if (students.length === 0) {
1805
+ thead.innerHTML = '<th class="p-4 text-left">等待資料...</th>';
1806
+ tbody.innerHTML = '<tr><td class="p-10 text-center text-gray-500">尚無學員加入</td></tr>';
1807
+ return;
1808
+ }
1809
+
1810
+ // 1. Render Header (Students)
1811
+ // Sticky Top for Header Row
1812
+ // Sticky Left for the first cell ("Challenge/Student")
1813
+ let headerHtml = `
1814
  <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">
1815
  <div class="flex justify-between items-end">
1816
  <span class="text-sm text-gray-400">題目</span>
 
1819
  </th>
1820
  `;
1821
 
1822
+ students.forEach(student => {
1823
+ headerHtml += `
1824
  <th class="p-2 text-center sticky top-0 bg-gray-800 z-20 border-b border-gray-700 min-w-[80px] group">
1825
  <div class="flex flex-col items-center space-y-2 py-2">
1826
  <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">
 
1839
  </div>
1840
  </th>
1841
  `;
1842
+ });
1843
+ thead.innerHTML = headerHtml;
1844
 
1845
+ // 2. Render Body (Challenges as Rows)
1846
+ if (cachedChallenges.length === 0) {
1847
+ tbody.innerHTML = '<tr><td colspan="100" class="text-center py-4">沒有題目資料</td></tr>';
1848
+ return;
1849
+ }
1850
 
1851
+ tbody.innerHTML = cachedChallenges.map((c, index) => {
1852
+ const colors = { beginner: 'cyan', intermediate: 'blue', advanced: 'purple' };
1853
+ const color = colors[c.level] || 'gray';
1854
+
1855
+ // Build Row Cells (One per student)
1856
+ const rowCells = students.map(student => {
1857
+ const p = student.progress?.[c.id];
1858
+ let statusClass = 'bg-gray-800/30 border-gray-800'; // Default
1859
+ let content = '';
1860
+ let action = '';
1861
+
1862
+ if (p) {
1863
+ if (p.status === 'completed') {
1864
+ 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)]';
1865
+ content = '✅';
1866
+ // Action removed: Moved to prompt list view
1867
+ action = `title="完成 - 請點擊標題查看詳情"`;
1868
+ } else if (p.status === 'started') {
1869
+ // Check stuck
1870
+ const startedAt = p.timestamp ? p.timestamp.toDate() : new Date();
1871
+ const now = new Date();
1872
+ const diffMins = (now - startedAt) / 1000 / 60;
1873
+
1874
+ if (diffMins > 5) {
1875
+ statusClass = 'bg-red-900/50 border-red-500 animate-pulse cursor-help';
1876
+ content = '🆘';
1877
+ } else {
1878
+ statusClass = 'bg-blue-600/20 border-blue-500';
1879
+ content = '🔵';
1880
+ }
1881
  }
1882
  }
 
1883
 
1884
+ return `
1885
  <td class="p-1 border border-gray-800/50 text-center align-middle h-14 hover:bg-white/5 transition-colors">
1886
  <div class="mx-auto w-10 h-10 rounded-lg border flex items-center justify-center ${statusClass} transition-all duration-300" ${action}>
1887
  ${content}
1888
  </div>
1889
  </td>
1890
  `;
1891
+ }).join('');
1892
 
1893
+ // Row Header (Challenge Title)
1894
+ return `
1895
  <tr class="hover:bg-gray-800/50 transition-colors">
1896
  <td class="p-3 sticky left-0 bg-gray-900 z-10 border-r border-b border-gray-700 shadow-md">
1897
  <div class="flex items-center justify-between">
 
1908
  ${rowCells}
1909
  </tr>
1910
  `;
1911
+ }).join('');
1912
+ }
1913
 
1914