Lashtw commited on
Commit
cdcbf63
·
verified ·
1 Parent(s): 80651c8

Upload 9 files

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