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

Upload 9 files

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