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

Upload 9 files

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