Lashtw commited on
Commit
4e944e4
·
verified ·
1 Parent(s): 90f7646

Upload 9 files

Browse files
Files changed (1) hide show
  1. src/views/InstructorView.js +571 -570
src/views/InstructorView.js CHANGED
@@ -18,7 +18,8 @@ export async function renderInstructorView() {
18
  console.error("Failed header load", e);
19
  }
20
 
21
- return <div id="auth-modal" class="fixed inset-0 bg-gray-900 bg-opacity-95 flex items-center justify-center z-50">
 
22
  <div class="bg-gray-800 p-8 rounded-lg shadow-2xl text-center max-w-sm w-full border border-gray-700">
23
  <div class="text-6xl mb-4 animate-bounce">🔒</div>
24
  <h2 class="text-2xl font-bold text-white mb-2">講師登入</h2>
@@ -323,109 +324,109 @@ export async function renderInstructorView() {
323
  `;
324
  }
325
 
326
- export function setupInstructorEvents() {
327
- let roomUnsubscribe = null;
328
- let currentInstructor = null;
329
-
330
- // UI References
331
- const authModal = document.getElementById('auth-modal');
332
- // New Auth Elements
333
- const loginEmailInput = document.getElementById('login-email');
334
- const loginPasswordInput = document.getElementById('login-password');
335
- const loginBtn = document.getElementById('login-btn');
336
- const registerBtn = document.getElementById('register-btn');
337
- const authErrorMsg = document.getElementById('auth-error');
338
-
339
- // Remove old authBtn reference if present
340
- // const authBtn = document.getElementById('auth-btn');
341
-
342
- const navAdminBtn = document.getElementById('nav-admin-btn');
343
- const navInstBtn = document.getElementById('nav-instructors-btn');
344
- const createBtn = document.getElementById('create-room-btn');
345
-
346
- // Other UI
347
- const roomInfo = document.getElementById('room-info');
348
- const createContainer = document.getElementById('create-room-container');
349
- const dashboardContent = document.getElementById('dashboard-content');
350
- const displayRoomCode = document.getElementById('display-room-code');
351
- const groupPhotoBtn = document.getElementById('group-photo-btn');
352
- const snapshotBtn = document.getElementById('snapshot-btn');
353
- let isSnapshotting = false;
354
 
355
  // Permission Check Helper
356
  const checkPermissions = (instructor) => {
357
  if (!instructor) return;
358
 
359
- currentInstructor = instructor;
360
 
361
- // 1. Create Room Permission
362
- if (instructor.permissions?.includes('create_room')) {
363
- createBtn.classList.remove('hidden', 'opacity-50', 'cursor-not-allowed');
364
  createBtn.disabled = false;
365
  } else {
366
- createBtn.classList.add('opacity-50', 'cursor-not-allowed');
367
  createBtn.disabled = true;
368
  createBtn.title = "無此權限";
369
  }
370
 
371
- // 2. Add Question Permission (Admin Button)
372
- if (instructor.permissions?.includes('add_question')) {
373
- navAdminBtn.classList.remove('hidden');
374
  } else {
375
- navAdminBtn.classList.add('hidden');
376
  }
377
 
378
- // 3. Manage Instructors Permission
379
- if (instructor.permissions?.includes('manage_instructors')) {
380
- navInstBtn.classList.remove('hidden');
381
  } else {
382
- navInstBtn.classList.add('hidden');
383
  }
384
  };
385
 
386
- // Email/Password Auth Logic
387
- if (loginBtn && registerBtn) {
388
- // Login Handler
389
- loginBtn.addEventListener('click', async () => {
390
- const email = loginEmailInput.value;
391
- const password = loginPasswordInput.value;
392
 
393
- if (!email || !password) {
394
- authErrorMsg.textContent = "請輸入 Email 和密碼";
395
- authErrorMsg.classList.remove('hidden');
396
- return;
397
- }
398
 
399
- try {
400
- loginBtn.disabled = true;
401
- loginBtn.classList.add('opacity-50');
402
- authErrorMsg.classList.add('hidden');
403
-
404
- const user = await loginWithEmail(email, password);
405
- const instructorData = await checkInstructorPermission(user);
406
-
407
- if (instructorData) {
408
- authModal.classList.add('hidden');
409
- checkPermissions(instructorData);
410
- localStorage.setItem('vibecoding_instructor_name', instructorData.name);
411
- } else {
412
- authErrorMsg.textContent = "未授權的帳號 (Unauthorized Account)";
413
- authErrorMsg.classList.remove('hidden');
414
- await signOutUser();
415
- }
416
- } catch (error) {
417
- console.error(error);
418
- let msg = error.code || error.message;
419
- if (error.code === 'auth/invalid-credential' || error.code === 'auth/wrong-password' || error.code === 'auth/user-not-found') {
420
- msg = "帳號或密碼錯誤。";
421
- }
422
- authErrorMsg.textContent = "登入失敗: " + msg;
423
- authErrorMsg.classList.remove('hidden');
424
- } finally {
425
- loginBtn.disabled = false;
426
- loginBtn.classList.remove('opacity-50');
427
- }
428
- });
429
 
430
  // Register Handler
431
  registerBtn.addEventListener('click', async () => {
@@ -434,42 +435,42 @@ export async function renderInstructorView() {
434
 
435
  if (!email || !password) {
436
  authErrorMsg.textContent = "請輸入 Email 和密碼";
437
- authErrorMsg.classList.remove('hidden');
438
- return;
439
  }
440
 
441
  try {
442
  registerBtn.disabled = true;
443
- registerBtn.classList.add('opacity-50');
444
- authErrorMsg.classList.add('hidden');
445
-
446
- // Try to create auth account
447
- const user = await registerWithEmail(email, password);
448
- // Check if this email is in our whitelist
449
- const instructorData = await checkInstructorPermission(user);
450
-
451
- if (instructorData) {
452
- authModal.classList.add('hidden');
453
- checkPermissions(instructorData);
454
- localStorage.setItem('vibecoding_instructor_name', instructorData.name);
455
- alert("註冊成功!");
456
  } else {
457
- // Auth created but not in whitelist
458
- authErrorMsg.textContent = "註冊成功,但您的 Email 未被列入講師名單。請聯繫管理員。";
459
- authErrorMsg.classList.remove('hidden');
460
- await signOutUser();
461
  }
462
  } catch (error) {
463
  console.error(error);
464
- let msg = error.code || error.message;
465
- if (error.code === 'auth/email-already-in-use') {
466
- msg = "此 Email 已被註冊,請直接登入。";
467
  }
468
- authErrorMsg.textContent = "註冊失敗: " + msg;
469
- authErrorMsg.classList.remove('hidden');
470
  } finally {
471
  registerBtn.disabled = false;
472
- registerBtn.classList.remove('opacity-50');
473
  }
474
  });
475
  }
@@ -477,50 +478,50 @@ export async function renderInstructorView() {
477
  // Handle Instructor Management
478
  navInstBtn.addEventListener('click', async () => {
479
  const modal = document.getElementById('instructor-modal');
480
- const listBody = document.getElementById('instructor-list-body');
481
 
482
- // Load list
483
- const instructors = await getInstructors();
484
  listBody.innerHTML = instructors.map(inst => `
485
  <tr class="border-b border-gray-700 hover:bg-gray-800">
486
  <td class="p-3">${inst.name}</td>
487
  <td class="p-3 font-mono text-sm text-cyan-400">${inst.email}</td>
488
  <td class="p-3 text-xs">
489
  ${inst.permissions?.map(p => {
490
- const map = { create_room: '開房', add_question: '題目', manage_instructors: '管理' };
491
- return `<span class="bg-gray-700 px-2 py-1 rounded mr-1">${map[p] || p}</span>`;
492
- }).join('')}
493
  </td>
494
  <td class="p-3">
495
  ${inst.role === 'admin' ? '<span class="text-gray-500 italic">不可移除</span>' :
496
- `<button class="text-red-400 hover:text-red-300" onclick="window.removeInst('${inst.email}')">移除</button>`}
497
  </td>
498
  </tr>
499
  `).join('');
500
 
501
- modal.classList.remove('hidden');
502
  });
503
 
504
  // Add New Instructor
505
  document.getElementById('btn-add-inst').addEventListener('click', async () => {
506
  const email = document.getElementById('new-inst-email').value.trim();
507
- const name = document.getElementById('new-inst-name').value.trim();
508
 
509
- if (!email || !name) return alert("請輸入完整資料");
510
 
511
- const perms = [];
512
- if (document.getElementById('perm-room').checked) perms.push('create_room');
513
- if (document.getElementById('perm-q').checked) perms.push('add_question');
514
- if (document.getElementById('perm-inst').checked) perms.push('manage_instructors');
515
 
516
- try {
517
- await addInstructor(email, name, perms);
518
  alert("新增成功");
519
  navInstBtn.click(); // Reload list
520
  document.getElementById('new-inst-email').value = '';
521
  document.getElementById('new-inst-name').value = '';
522
  } catch (e) {
523
- alert("新增失敗: " + e.message);
524
  }
525
  });
526
 
@@ -529,48 +530,48 @@ export async function renderInstructorView() {
529
  if (confirm(`確定移除 ${email}?`)) {
530
  try {
531
  await removeInstructor(email);
532
- navInstBtn.click(); // Reload
533
  } catch (e) {
534
  alert(e.message);
535
  }
536
  }
537
  };
538
 
539
- // Auto Check Auth (Persistence)
540
- // We rely on Firebase Auth state observer instead of session storage for security?
541
- // Or we can just check if user is already signed in.
542
- import("../services/firebase.js").then(async ({auth}) => {
543
  // Handle Redirect Result first
544
  try {
545
- console.log("Initializing Auth Check...");
546
- const {handleRedirectResult} = await import("../services/auth.js");
547
  const redirectUser = await handleRedirectResult();
548
  if (redirectUser) console.log("Redirect User Found:", redirectUser.email);
549
- } catch (e) {console.warn("Redirect check failed", e); }
550
 
551
  auth.onAuthStateChanged(async (user) => {
552
- console.log("Auth State Changed to:", user ? user.email : "Logged Out");
553
  if (user) {
554
  try {
555
- console.log("Checking permissions for:", user.email);
556
- const instructorData = await checkInstructorPermission(user);
557
- console.log("Permission Result:", instructorData);
558
-
559
- if (instructorData) {
560
- console.log("Hiding Modal and Setting Permissions...");
561
- authModal.classList.add('hidden');
562
- checkPermissions(instructorData);
563
  } else {
564
- console.warn("User logged in but not an instructor.");
565
- // Show unauthorized message
566
- authErrorMsg.textContent = "此帳號無講師權限";
567
- authErrorMsg.classList.remove('hidden');
568
- authModal.classList.remove('hidden'); // Ensure modal stays up
569
  }
570
  } catch (e) {
571
- console.error("Permission Check Failed:", e);
572
- authErrorMsg.textContent = "權限檢查失敗: " + e.message;
573
- authErrorMsg.classList.remove('hidden');
574
  }
575
  } else {
576
  authModal.classList.remove('hidden');
@@ -582,12 +583,12 @@ export async function renderInstructorView() {
582
  window.confirmKick = async (userId, nickname) => {
583
  if (confirm(`確定要踢出 ${nickname} 嗎?此動作無法復原。`)) {
584
  try {
585
- const {removeUser} = await import("../services/classroom.js");
586
- await removeUser(userId);
587
  // UI will update automatically via subscribeToRoom
588
  } catch (e) {
589
  console.error("Kick failed:", e);
590
- alert("移除失敗");
591
  }
592
  }
593
  };
@@ -599,47 +600,47 @@ export async function renderInstructorView() {
599
  if (typeof htmlToImage === 'undefined') alert("截圖元件尚未載入,請稍候再試");
600
  return;
601
  }
602
- isSnapshotting = true;
603
 
604
- const overlay = document.getElementById('snapshot-overlay');
605
- const countEl = document.getElementById('countdown-number');
606
- const container = document.getElementById('group-photo-container');
607
- const modal = document.getElementById('group-photo-modal');
608
 
609
- // Close button hide
610
- const closeBtn = modal.querySelector('button');
611
- if (closeBtn) closeBtn.style.opacity = '0';
612
- snapshotBtn.style.opacity = '0';
613
 
614
- overlay.classList.remove('hidden');
615
- overlay.classList.add('flex');
616
 
617
  // Countdown Sequence
618
  const runCountdown = (num) => new Promise(resolve => {
619
- countEl.textContent = num;
620
  countEl.style.transform = 'scale(1.5)';
621
  countEl.style.opacity = '1';
622
 
623
  // Animation reset
624
  requestAnimationFrame(() => {
625
  countEl.style.transition = 'all 0.5s ease-out';
626
- countEl.style.transform = 'scale(1)';
627
- countEl.style.opacity = '0.5';
628
- setTimeout(resolve, 1000);
629
  });
630
  });
631
 
632
- await runCountdown(3);
633
- await runCountdown(2);
634
- await runCountdown(1);
635
 
636
- // Action!
637
- countEl.textContent = '';
638
- overlay.classList.add('hidden');
639
 
640
- // 1. Emojis Explosion
641
- const emojis = ['🤘', '✌️', '👍', '🫶', '😎', '🔥'];
642
- const cards = container.querySelectorAll('.group\\/card');
643
 
644
  cards.forEach(card => {
645
  // Find the monster image container
@@ -664,33 +665,33 @@ export async function renderInstructorView() {
664
  try {
665
  // Flash Effect
666
  const flash = document.createElement('div');
667
- flash.className = 'fixed inset-0 bg-white z-[100] transition-opacity duration-300 pointer-events-none';
668
- document.body.appendChild(flash);
669
  setTimeout(() => flash.style.opacity = '0', 50);
670
  setTimeout(() => flash.remove(), 300);
671
 
672
- // Use htmlToImage.toPng
673
- const dataUrl = await htmlToImage.toPng(container, {
674
- backgroundColor: '#111827',
675
- pixelRatio: 2,
676
- cacheBust: true,
677
  });
678
 
679
- // Download
680
- const link = document.createElement('a');
681
- const dateStr = new Date().toISOString().slice(0, 10);
682
- link.download = `VIBE_Class_Photo_${dateStr}.png`;
683
- link.href = dataUrl;
684
- link.click();
685
 
686
  } catch (e) {
687
  console.error("Snapshot failed:", e);
688
- alert("截圖失敗 (請嘗試手動截圖/PrtSc)\n原因: " + e.message);
689
  } finally {
690
  // Restore UI
691
  if (closeBtn) closeBtn.style.opacity = '1';
692
- snapshotBtn.style.opacity = '1';
693
- isSnapshotting = false;
694
  }
695
  }, 600); // Slight delay for emojis to appear
696
  });
@@ -698,42 +699,42 @@ export async function renderInstructorView() {
698
  // Group Photo Logic
699
  groupPhotoBtn.addEventListener('click', () => {
700
  const modal = document.getElementById('group-photo-modal');
701
- const container = document.getElementById('group-photo-container');
702
- const dateEl = document.getElementById('photo-date');
703
 
704
- // Update Date
705
- const now = new Date();
706
- dateEl.textContent = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} `;
707
 
708
- // Get saved name
709
- const savedName = localStorage.getItem('vibecoding_instructor_name') || '講師 (Instructor)';
710
 
711
- container.innerHTML = '';
712
 
713
- // 1. Container for Relative Positioning with Custom Background
714
- const relativeContainer = document.createElement('div');
715
- 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';
716
- relativeContainer.style.backgroundImage = "url('assets/photobg.png')";
717
- container.appendChild(relativeContainer);
718
 
719
- // Watermark (Bottom Right, High Z-Index, Gradient Text, Dark Backdrop)
720
- const watermark = document.createElement('div');
721
- 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';
722
 
723
- const d = new Date();
724
- const dateStr = `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} `;
725
 
726
- watermark.innerHTML = `
727
  <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">
728
  ${dateStr} VibeCoding 怪獸成長營
729
  </span>
730
  `;
731
- relativeContainer.appendChild(watermark);
732
 
733
- // 2. Instructor Section (Absolute Center)
734
- const instructorSection = document.createElement('div');
735
- 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';
736
- instructorSection.innerHTML = `
737
  <div class="relative">
738
  <div class="absolute inset-0 bg-yellow-500/20 blur-3xl rounded-full animate-pulse"></div>
739
  <!--Pixel Art Avatar-->
@@ -752,7 +753,7 @@ export async function renderInstructorView() {
752
  </div>
753
  </div>
754
  `;
755
- relativeContainer.appendChild(instructorSection);
756
 
757
  // Save name on change
758
  setTimeout(() => {
@@ -776,74 +777,74 @@ export async function renderInstructorView() {
776
 
777
  if (total >= 40) {
778
  sizeClass = 'w-12 h-12 md:w-14 md:h-14'; // Size 60%
779
- scaleFactor = 0.6;
780
  } else if (total >= 20) {
781
  sizeClass = 'w-16 h-16 md:w-20 md:h-20'; // Size 80%
782
- scaleFactor = 0.8;
783
  }
784
 
785
  students.forEach((s, index) => {
786
- const progressMap = s.progress || { };
787
  const totalLikes = Object.values(progressMap).reduce((acc, p) => acc + (p.likes || 0), 0);
788
  const totalCompleted = Object.values(progressMap).filter(p => p.status === 'completed').length;
789
 
790
- // FIXED: Prioritize stored ID if valid (same as StudentView logic)
791
- let monster;
792
- if (s.monster_id && s.monster_id !== 'Egg' && s.monster_id !== 'Unknown') {
793
  const stored = MONSTER_DEFS?.find(m => m.id === s.monster_id);
794
- if (stored) {
795
- monster = stored;
796
  } else {
797
- // Fallback if ID invalid
798
- monster = getNextMonster(s.monster_stage || 0, totalLikes, total, s.monster_id);
799
  }
800
  } else {
801
- monster = getNextMonster(s.monster_stage || 0, totalLikes, total, s.monster_id);
802
  }
803
 
804
- // --- FIXED: Even Arc Distribution (Safe Zone: 135 deg to 405 deg) ---
805
- // Avoids Bottom Right (0-90) and Bottom Center (90-120) entirely
806
- const minR = 220;
807
- // Avoids Bottom Right (0-90) and Bottom Center (90-120) entirely
808
 
809
- // Safe Arc Range: Starts at 135 deg (Bottom Left) -> Goes CW -> Ends at 405 deg (45 deg, Bottom Right)
810
- // Total Span = 270 degrees
811
- // If many students, use double ring
812
 
813
- const safeStartAngle = 135 * (Math.PI / 180);
814
- const safeSpan = 270 * (Math.PI / 180);
815
 
816
- // Distribute evenly
817
- // If only 1 student, put at top (270 deg / 4.71 rad)
818
- let finalAngle;
819
 
820
- if (total === 1) {
821
- finalAngle = 270 * (Math.PI / 180);
822
  } else {
823
  const step = safeSpan / (total - 1);
824
- finalAngle = safeStartAngle + (step * index);
825
  }
826
 
827
- // Radius: Fixed base + slight variation for "natural" look (but not overlap causing)
828
- // Double ring logic if crowded
829
- let radius = minR + (index % 2) * 40; // Zigzag radius (220, 260, 220...) to minimize overlap
830
 
831
- // Reduce zigzag if few students
832
- if (total < 10) radius = minR + (index % 2) * 20;
833
 
834
- const xOff = Math.cos(finalAngle) * radius;
835
- const yOff = Math.sin(finalAngle) * radius * 0.8;
836
 
837
- const card = document.createElement('div');
838
- card.className = 'absolute flex flex-col items-center group/card z-10 hover:z-50 transition-all duration-500 cursor-move';
839
 
840
- card.style.left = `calc(50% + ${xOff}px)`;
841
- card.style.top = `calc(50% + ${yOff}px)`;
842
- card.style.transform = 'translate(-50%, -50%)';
843
 
844
- const floatDelay = Math.random() * 2;
845
 
846
- card.innerHTML = `
847
  <!--Top Info: Monster Stats-->
848
  <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">
849
  <div class="text-[10px] text-gray-300 mb-0.5 whitespace-nowrap">${monster.name.split(' ')[1] || monster.name}</div>
@@ -868,23 +869,23 @@ export async function renderInstructorView() {
868
  <div class="text-xs font-bold text-white shadow-black drop-shadow-md whitespace-nowrap tracking-wide">${s.nickname}</div>
869
  </div>
870
  `;
871
- relativeContainer.appendChild(card);
872
 
873
- // Enable Drag & Drop
874
- setupDraggable(card, relativeContainer);
875
  });
876
  }
877
 
878
- modal.classList.remove('hidden');
879
  });
880
 
881
- // Helper: Drag & Drop Logic
882
- function setupDraggable(el, container) {
883
- let isDragging = false;
884
- let startX, startY, initialLeft, initialTop;
885
 
886
  el.addEventListener('mousedown', (e) => {
887
- isDragging = true;
888
  startX = e.clientX;
889
  startY = e.clientY;
890
 
@@ -928,17 +929,17 @@ export async function renderInstructorView() {
928
  window.addEventListener('mouseup', () => {
929
  if (isDragging) {
930
  isDragging = false;
931
- el.style.transition = ''; // Re-enable hover effects
932
- el.style.zIndex = ''; // Restore z-index rule (or let hover take over)
933
  }
934
  });
935
  }
936
 
937
- // Add float animation style if not exists
938
- if (!document.getElementById('anim-float')) {
939
  const style = document.createElement('style');
940
- style.id = 'anim-float';
941
- style.innerHTML = `
942
  @keyframes float {
943
 
944
  0 %, 100 % { transform: translateY(0) scale(1); }
@@ -946,38 +947,38 @@ export async function renderInstructorView() {
946
  }
947
  }
948
  `;
949
- document.head.appendChild(style);
950
  }
951
 
952
  navAdminBtn.addEventListener('click', () => {
953
  // Save current room to return later
954
  const currentRoom = localStorage.getItem('vibecoding_instructor_room');
955
- localStorage.setItem('vibecoding_admin_referer', 'instructor'); // track entry source
956
- window.location.hash = 'admin';
957
  });
958
 
959
- // Auto-fill code
960
- const savedRoomCode = localStorage.getItem('vibecoding_instructor_room');
961
- if (savedRoomCode) {
962
- document.getElementById('rejoin-room-code').value = savedRoomCode;
963
  }
964
 
965
- const rejoinBtn = document.getElementById('rejoin-room-btn');
966
  rejoinBtn.addEventListener('click', () => {
967
  const code = document.getElementById('rejoin-room-code').value.trim();
968
- if (!code) return alert('請輸入教室代碼');
969
- enterRoom(code);
970
  });
971
 
972
  // Gallery Logic
973
  document.getElementById('btn-open-gallery').addEventListener('click', () => {
974
- window.open('monster_preview.html', '_blank');
975
  });
976
 
977
  // Logout Logic
978
  document.getElementById('logout-btn').addEventListener('click', async () => {
979
  if (confirm('確定要登出講師模式嗎? (將會回到首頁)')) {
980
- await signOutUser();
981
  sessionStorage.removeItem('vibecoding_instructor_in_room');
982
  sessionStorage.removeItem('vibecoding_admin_referer');
983
  window.location.hash = '';
@@ -987,12 +988,12 @@ export async function renderInstructorView() {
987
 
988
  createBtn.addEventListener('click', async () => {
989
  try {
990
- createBtn.disabled = true;
991
  createBtn.textContent = "...";
992
  const roomCode = await createRoom();
993
  enterRoom(roomCode);
994
  } catch (error) {
995
- console.error(error);
996
  alert("建立失敗");
997
  createBtn.disabled = false;
998
  }
@@ -1005,27 +1006,27 @@ export async function renderInstructorView() {
1005
 
1006
  // Check Active Room State
1007
  const activeRoom = sessionStorage.getItem('vibecoding_instructor_in_room');
1008
- if (activeRoom === 'true' && savedRoomCode) {
1009
- enterRoom(savedRoomCode);
1010
  }
1011
 
1012
- // Module-level variable to track subscription (Moved to top)
1013
 
1014
- function enterRoom(roomCode) {
1015
- createContainer.classList.add('hidden');
1016
- roomInfo.classList.remove('hidden');
1017
- dashboardContent.classList.remove('hidden');
1018
- document.getElementById('group-photo-btn').classList.remove('hidden'); // Show photo button
1019
- displayRoomCode.textContent = roomCode;
1020
- localStorage.setItem('vibecoding_instructor_room', roomCode);
1021
- sessionStorage.setItem('vibecoding_instructor_in_room', 'true');
1022
 
1023
- // Unsubscribe previous if any
1024
- if (roomUnsubscribe) roomUnsubscribe();
1025
 
1026
  // Subscribe to updates
1027
  roomUnsubscribe = subscribeToRoom(roomCode, (students) => {
1028
- currentStudents = students;
1029
  renderTransposedHeatmap(students);
1030
  });
1031
  }
@@ -1036,7 +1037,7 @@ export async function renderInstructorView() {
1036
  // Unsubscribe
1037
  if (roomUnsubscribe) {
1038
  roomUnsubscribe();
1039
- roomUnsubscribe = null;
1040
  }
1041
 
1042
  // UI Reset
@@ -1058,108 +1059,108 @@ export async function renderInstructorView() {
1058
  // Modal Events
1059
  window.showBroadcastModal = (userId, challengeId) => {
1060
  const modal = document.getElementById('broadcast-modal');
1061
- const content = document.getElementById('broadcast-content');
1062
 
1063
  // Find Data
1064
  const student = currentStudents.find(s => s.id === userId);
1065
- if (!student) return alert('找不到學員資料');
1066
 
1067
- const p = student.progress ? student.progress[challengeId] : null;
1068
- if (!p) return alert('找不到該作品資料');
1069
 
1070
  const challenge = cachedChallenges.find(c => c.id === challengeId);
1071
- const title = challenge ? challenge.title : '未知題目';
1072
 
1073
- // Populate UI
1074
- document.getElementById('broadcast-avatar').textContent = student.nickname[0] || '?';
1075
- document.getElementById('broadcast-author').textContent = student.nickname;
1076
- document.getElementById('broadcast-challenge').textContent = title;
1077
- document.getElementById('broadcast-prompt').textContent = p.prompt || '(無內容)';
1078
 
1079
- // Store IDs for Actions (Reject/BroadcastAll)
1080
- modal.dataset.userId = userId;
1081
- modal.dataset.challengeId = challengeId;
1082
 
1083
- // Show
1084
- modal.classList.remove('hidden');
1085
  setTimeout(() => {
1086
- content.classList.remove('scale-95', 'opacity-0');
1087
  content.classList.add('opacity-100', 'scale-100');
1088
  }, 10);
1089
  };
1090
 
1091
  window.closeBroadcast = () => {
1092
  const modal = document.getElementById('broadcast-modal');
1093
- const content = document.getElementById('broadcast-content');
1094
- content.classList.remove('opacity-100', 'scale-100');
1095
- content.classList.add('scale-95', 'opacity-0');
1096
  setTimeout(() => modal.classList.add('hidden'), 300);
1097
  };
1098
 
1099
  window.openStage = (prompt, author) => {
1100
- document.getElementById('broadcast-content').classList.add('hidden');
1101
- const stage = document.getElementById('stage-view');
1102
- stage.classList.remove('hidden');
1103
- document.getElementById('stage-prompt').textContent = prompt;
1104
- document.getElementById('stage-author').textContent = author;
1105
  };
1106
 
1107
  window.closeStage = () => {
1108
- document.getElementById('stage-view').classList.add('hidden');
1109
- document.getElementById('broadcast-content').classList.remove('hidden');
1110
  };
1111
 
1112
  document.getElementById('btn-show-stage').addEventListener('click', () => {
1113
  const prompt = document.getElementById('broadcast-prompt').textContent;
1114
- const author = document.getElementById('broadcast-author').textContent;
1115
- window.openStage(prompt, author);
1116
  });
1117
 
1118
  // Reject Logic
1119
  document.getElementById('btn-reject-task').addEventListener('click', async () => {
1120
  if (!confirm('確定要退回此題目讓學員重做嗎?')) return;
1121
 
1122
- // We need student ID (userId) and Challenge ID.
1123
- // Currently showBroadcastModal only receives nickname, title, prompt.
1124
- // We need to attach data-userid and data-challengeid to the modal.
1125
- const modal = document.getElementById('broadcast-modal');
1126
- const userId = modal.dataset.userId;
1127
- const challengeId = modal.dataset.challengeId;
1128
- const roomCode = localStorage.getItem('vibecoding_instructor_room');
1129
 
1130
- if (userId && challengeId && roomCode) {
1131
  try {
1132
  await resetProgress(userId, roomCode, challengeId);
1133
- // Close modal
1134
- window.closeBroadcast();
1135
  } catch (e) {
1136
  console.error(e);
1137
- alert('退回失敗');
1138
  }
1139
  }
1140
  });
1141
  // Prompt Viewer Logic
1142
  window.openPromptList = (type, id, title) => {
1143
  const modal = document.getElementById('prompt-list-modal');
1144
- const container = document.getElementById('prompt-list-container');
1145
- const titleEl = document.getElementById('prompt-list-title');
1146
 
1147
- titleEl.textContent = type === 'student' ? `${title} 的所有提示詞` : `題目:${title} 的所有作品`;
1148
 
1149
- // Reset Anonymous Toggle in List View
1150
- const anonCheck = document.getElementById('list-anonymous-toggle');
1151
- if (anonCheck) anonCheck.checked = false;
1152
 
1153
- container.innerHTML = '';
1154
- modal.classList.remove('hidden');
1155
 
1156
- // Collect Prompts
1157
- let prompts = [];
1158
- // Fix: Reset selection when opening new list to prevent cross-contamination
1159
- selectedPrompts = [];
1160
- updateCompareButton();
1161
 
1162
- if (type === 'student') {
1163
  const student = currentStudents.find(s => s.id === id);
1164
  if (student && student.progress) {
1165
  prompts = Object.entries(student.progress)
@@ -1178,26 +1179,26 @@ export async function renderInstructorView() {
1178
  });
1179
  }
1180
  } else if (type === 'challenge') {
1181
- currentStudents.forEach(student => {
1182
- if (student.progress && student.progress[id]) {
1183
- const p = student.progress[id];
1184
- if (p.status === 'completed' && p.prompt) {
1185
- prompts.push({
1186
- id: `${student.id}_${id}`,
1187
- title: student.nickname, // When viewing challenge, title is student name
1188
- prompt: p.prompt,
1189
- author: student.nickname,
1190
- studentId: student.id,
1191
- challengeId: id,
1192
- time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : ''
1193
- });
1194
- }
1195
  }
1196
- });
 
1197
  }
1198
 
1199
- if (prompts.length === 0) {
1200
- container.innerHTML = '<div class="col-span-full text-center text-gray-500 py-10">無資料</div>';
1201
  return;
1202
  }
1203
 
@@ -1236,103 +1237,103 @@ export async function renderInstructorView() {
1236
  const roomCode = localStorage.getItem('vibecoding_instructor_room');
1237
  if (userId && challengeId && roomCode) {
1238
  try {
1239
- const {resetProgress} = await import("../services/classroom.js");
1240
- await resetProgress(userId, roomCode, challengeId);
1241
- // 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)
1242
- // For now, simple alert or auto-close
1243
- alert("已退回");
1244
- // close modal to refresh data context
1245
- document.getElementById('prompt-list-modal').classList.add('hidden');
1246
  } catch (e) {
1247
- console.error(e);
1248
- alert("退回失敗");
1249
  }
1250
  }
1251
  }
1252
  };
1253
 
1254
  window.broadcastPrompt = (userId, challengeId) => {
1255
- window.showBroadcastModal(userId, challengeId);
1256
  };
1257
 
1258
- // Selection Logic
1259
- let selectedPrompts = []; // Stores IDs
1260
 
1261
  window.handlePromptSelection = (checkbox) => {
1262
  const id = checkbox.dataset.id;
1263
 
1264
- if (checkbox.checked) {
1265
  if (selectedPrompts.length >= 3) {
1266
  checkbox.checked = false;
1267
- alert('最多只能選擇 3 個提示詞進行比較');
1268
- return;
1269
  }
1270
  selectedPrompts.push(id);
1271
  } else {
1272
- selectedPrompts = selectedPrompts.filter(pid => pid !== id);
1273
  }
1274
- updateCompareButton();
1275
  };
1276
 
1277
- function updateCompareButton() {
1278
  const btn = document.getElementById('btn-compare-prompts');
1279
- if (!btn) return;
1280
 
1281
- const count = selectedPrompts.length;
1282
- const span = btn.querySelector('span');
1283
- if (span) span.textContent = `🔍 比較已選項目 (${count}/3)`;
1284
 
1285
  if (count > 0) {
1286
- btn.disabled = false;
1287
  btn.classList.remove('opacity-50', 'cursor-not-allowed');
1288
  } else {
1289
- btn.disabled = true;
1290
  btn.classList.add('opacity-50', 'cursor-not-allowed');
1291
  }
1292
  }
1293
- // Comparison Logic
1294
- const compareBtn = document.getElementById('btn-compare-prompts');
1295
- if (compareBtn) {
1296
- compareBtn.addEventListener('click', () => {
1297
- const dataToCompare = [];
1298
- selectedPrompts.forEach(fullId => {
1299
- const lastUnderscore = fullId.lastIndexOf('_');
1300
- const studentId = fullId.substring(0, lastUnderscore);
1301
- const challengeId = fullId.substring(lastUnderscore + 1);
1302
-
1303
- const student = currentStudents.find(s => s.id === studentId);
1304
- if (student && student.progress && student.progress[challengeId]) {
1305
- const p = student.progress[challengeId];
1306
- const challenge = cachedChallenges.find(c => c.id === challengeId);
1307
-
1308
- dataToCompare.push({
1309
- title: challenge ? challenge.title : '未知',
1310
- author: student.nickname,
1311
- prompt: p.prompt,
1312
- time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleTimeString() : ''
1313
- });
1314
- }
1315
  });
 
 
1316
 
1317
- const isAnon = document.getElementById('list-anonymous-toggle')?.checked || false;
1318
- openComparisonView(dataToCompare, isAnon);
1319
- });
1320
  }
1321
 
1322
- let isAnonymous = false;
1323
 
1324
  window.toggleAnonymous = (btn) => {
1325
- isAnonymous = !isAnonymous;
1326
- btn.textContent = isAnonymous ? '🙈 顯示姓名' : '👀 隱藏姓名';
1327
- btn.classList.toggle('bg-gray-700');
1328
- btn.classList.toggle('bg-purple-700');
1329
 
1330
  // Update DOM
1331
  document.querySelectorAll('.comparison-author').forEach(el => {
1332
  if (isAnonymous) {
1333
  el.dataset.original = el.textContent;
1334
- el.textContent = '學員';
1335
- el.classList.add('blur-sm'); // Optional Effect
1336
  setTimeout(() => el.classList.remove('blur-sm'), 300);
1337
  } else {
1338
  if (el.dataset.original) el.textContent = el.dataset.original;
@@ -1342,32 +1343,32 @@ export async function renderInstructorView() {
1342
 
1343
  window.openComparisonView = (items, initialAnonymous = false) => {
1344
  const modal = document.getElementById('comparison-modal');
1345
- const grid = document.getElementById('comparison-grid');
1346
 
1347
- // Apply Anonymous State
1348
- isAnonymous = initialAnonymous;
1349
- const anonBtn = document.getElementById('btn-anonymous-toggle');
1350
 
1351
- // Update Toggle UI to match state
1352
- if (anonBtn) {
1353
  if (isAnonymous) {
1354
  anonBtn.textContent = '🙈 顯示姓名';
1355
- anonBtn.classList.add('bg-purple-700');
1356
- anonBtn.classList.remove('bg-gray-700');
1357
  } else {
1358
  anonBtn.textContent = '👀 隱藏姓名';
1359
- anonBtn.classList.remove('bg-purple-700');
1360
- anonBtn.classList.add('bg-gray-700');
1361
  }
1362
  }
1363
 
1364
- // Setup Grid Rows (Vertical Stacking)
1365
- let rowClass = 'grid-rows-1';
1366
- if (items.length === 2) rowClass = 'grid-rows-2';
1367
- if (items.length === 3) rowClass = 'grid-rows-3';
1368
 
1369
- grid.className = `absolute inset-0 grid ${rowClass} gap-0 divide-y divide-gray-600`;
1370
- grid.innerHTML = '';
1371
 
1372
  items.forEach(item => {
1373
  const col = document.createElement('div');
@@ -1380,7 +1381,7 @@ export async function renderInstructorView() {
1380
 
1381
  if (isAnonymous) {
1382
  displayAuthor = '學員';
1383
- blurClass = 'blur-sm'; // Initial blur
1384
  // Auto remove blur after delay if needed, or keep it?
1385
  // Toggle logic removes it after delay. But initial render should probably just be static '學員' or blurred.
1386
  // The toggle logic uses dataset.original. We need to set it here too.
@@ -1410,35 +1411,35 @@ export async function renderInstructorView() {
1410
  }
1411
  });
1412
 
1413
- document.getElementById('prompt-list-modal').classList.add('hidden');
1414
- modal.classList.remove('hidden');
1415
 
1416
- // Init Canvas (Phase 3)
1417
- setTimeout(setupCanvas, 100);
1418
  };
1419
 
1420
  window.closeComparison = () => {
1421
- document.getElementById('comparison-modal').classList.add('hidden');
1422
- clearCanvas();
1423
  };
1424
 
1425
- // --- Phase 3 & 6: Annotation Tools ---
1426
- let canvas, ctx;
1427
- let isDrawing = false;
1428
- let currentPenColor = '#ef4444'; // Red default
1429
- let currentLineWidth = 3;
1430
- let currentMode = 'source-over'; // 'source-over' (Pen) or 'destination-out' (Eraser)
1431
 
1432
  window.setupCanvas = () => {
1433
- canvas = document.getElementById('annotation-canvas');
1434
- const container = document.getElementById('comparison-container');
1435
- if (!canvas || !container) return;
1436
 
1437
- ctx = canvas.getContext('2d');
1438
 
1439
  // Resize
1440
  const resize = () => {
1441
- canvas.width = container.clientWidth;
1442
  canvas.height = container.clientHeight;
1443
  ctx.lineCap = 'round';
1444
  ctx.lineJoin = 'round';
@@ -1446,27 +1447,27 @@ export async function renderInstructorView() {
1446
  ctx.lineWidth = currentLineWidth;
1447
  ctx.globalCompositeOperation = currentMode;
1448
  };
1449
- resize();
1450
- window.addEventListener('resize', resize);
1451
 
1452
- // Init Size UI & Cursor
1453
- updateSizeBtnUI();
1454
- updateCursorStyle();
1455
 
1456
- // Cursor Logic
1457
- const cursor = document.getElementById('tool-cursor');
1458
 
1459
  canvas.addEventListener('mouseenter', () => cursor.classList.remove('hidden'));
1460
  canvas.addEventListener('mouseleave', () => cursor.classList.add('hidden'));
1461
  canvas.addEventListener('mousemove', (e) => {
1462
- const {x, y} = getPos(e);
1463
  cursor.style.left = `${x}px`;
1464
  cursor.style.top = `${y}px`;
1465
  });
1466
 
1467
  // Drawing Events
1468
  const start = (e) => {
1469
- isDrawing = true;
1470
  ctx.beginPath();
1471
 
1472
  // Re-apply settings (state might change)
@@ -1474,122 +1475,122 @@ export async function renderInstructorView() {
1474
  ctx.strokeStyle = currentPenColor;
1475
  ctx.lineWidth = currentLineWidth;
1476
 
1477
- const {x, y} = getPos(e);
1478
  ctx.moveTo(x, y);
1479
  };
1480
 
1481
  const move = (e) => {
1482
  if (!isDrawing) return;
1483
- const {x, y} = getPos(e);
1484
  ctx.lineTo(x, y);
1485
  ctx.stroke();
1486
  };
1487
 
1488
  const end = () => {
1489
- isDrawing = false;
1490
  };
1491
 
1492
- canvas.onmousedown = start;
1493
- canvas.onmousemove = move;
1494
- canvas.onmouseup = end;
1495
- canvas.onmouseleave = end;
1496
 
1497
  // Touch support
1498
- canvas.ontouchstart = (e) => {e.preventDefault(); start(e.touches[0]); };
1499
- canvas.ontouchmove = (e) => {e.preventDefault(); move(e.touches[0]); };
1500
- canvas.ontouchend = (e) => {e.preventDefault(); end(); };
1501
  };
1502
 
1503
- function getPos(e) {
1504
  const rect = canvas.getBoundingClientRect();
1505
- return {
1506
- x: e.clientX - rect.left,
1507
  y: e.clientY - rect.top
1508
  };
1509
  }
1510
 
1511
  // Unified Tool Handler
1512
  window.setPenTool = (tool, color, btn) => {
1513
- // UI Update
1514
- document.querySelectorAll('.annotation-tool').forEach(b => {
1515
- b.classList.remove('ring-white');
1516
- b.classList.add('ring-transparent');
1517
- });
1518
- btn.classList.remove('ring-transparent');
1519
- btn.classList.add('ring-white');
1520
 
1521
- if (tool === 'eraser') {
1522
- currentMode = 'destination-out';
1523
  } else {
1524
- currentMode = 'source-over';
1525
  currentPenColor = color;
1526
  }
1527
- updateCursorStyle();
1528
  };
1529
 
1530
  // Size Handler
1531
  window.setPenSize = (size, btn) => {
1532
- currentLineWidth = size;
1533
- updateSizeBtnUI();
1534
- updateCursorStyle();
1535
  };
1536
 
1537
- function updateCursorStyle() {
1538
  const cursor = document.getElementById('tool-cursor');
1539
- if (!cursor) return;
1540
 
1541
- // Size
1542
- cursor.style.width = `${currentLineWidth}px`;
1543
- cursor.style.height = `${currentLineWidth}px`;
1544
 
1545
- // Color
1546
- if (currentMode === 'destination-out') {
1547
- // Eraser: White solid
1548
- cursor.style.backgroundColor = 'white';
1549
  cursor.style.borderColor = '#999';
1550
  } else {
1551
- // Pen: Tool color
1552
- cursor.style.backgroundColor = currentPenColor;
1553
  cursor.style.borderColor = 'rgba(255,255,255,0.8)';
1554
  }
1555
  }
1556
 
1557
- function updateSizeBtnUI() {
1558
- document.querySelectorAll('.size-btn').forEach(b => {
1559
- if (parseInt(b.dataset.size) === currentLineWidth) {
1560
- b.classList.add('bg-gray-600', 'text-white');
1561
- b.classList.remove('text-gray-400', 'hover:bg-gray-700');
1562
- } else {
1563
- b.classList.remove('bg-gray-600', 'text-white');
1564
- b.classList.add('text-gray-400', 'hover:bg-gray-700');
1565
- }
1566
- });
1567
  }
1568
 
1569
  window.clearCanvas = () => {
1570
  if (canvas && ctx) {
1571
- ctx.clearRect(0, 0, canvas.width, canvas.height);
1572
  }
1573
  };
1574
  }
1575
 
1576
- /**
1577
- * Renders the Transposed Heatmap (Rows=Challenges, Cols=Students)
1578
- */
1579
- function renderTransposedHeatmap(students) {
1580
  const thead = document.getElementById('heatmap-header');
1581
- const tbody = document.getElementById('heatmap-body');
1582
 
1583
- if (students.length === 0) {
1584
- thead.innerHTML = '<th class="p-4 text-left">等待資料...</th>';
1585
- tbody.innerHTML = '<tr><td class="p-10 text-center text-gray-500">尚無學員加入</td></tr>';
1586
- return;
1587
  }
1588
 
1589
- // 1. Render Header (Students)
1590
- // Sticky Top for Header Row
1591
- // Sticky Left for the first cell ("Challenge/Student")
1592
- let headerHtml = `
1593
  <th class="p-3 text-left sticky left-0 top-0 bg-gray-800 z-30 border-b border-gray-600 min-w-[200px] border-r border-gray-700 shadow-md">
1594
  <div class="flex justify-between items-end">
1595
  <span class="text-sm text-gray-400">題目</span>
@@ -1599,7 +1600,7 @@ export async function renderInstructorView() {
1599
  `;
1600
 
1601
  students.forEach(student => {
1602
- headerHtml += `
1603
  <th class="p-2 text-center sticky top-0 bg-gray-800 z-20 border-b border-gray-700 min-w-[80px] group">
1604
  <div class="flex flex-col items-center space-y-2 py-2">
1605
  <div class="w-8 h-8 rounded-full bg-gradient-to-br from-gray-700 to-gray-600 flex items-center justify-center text-xs font-bold text-white uppercase border border-gray-500 shadow-sm relative">
@@ -1619,17 +1620,17 @@ export async function renderInstructorView() {
1619
  </th>
1620
  `;
1621
  });
1622
- thead.innerHTML = headerHtml;
1623
 
1624
- // 2. Render Body (Challenges as Rows)
1625
- if (cachedChallenges.length === 0) {
1626
- tbody.innerHTML = '<tr><td colspan="100" class="text-center py-4">沒有題目資料</td></tr>';
1627
- return;
1628
  }
1629
 
1630
  tbody.innerHTML = cachedChallenges.map((c, index) => {
1631
- const colors = {beginner: 'cyan', intermediate: 'blue', advanced: 'purple' };
1632
- const color = colors[c.level] || 'gray';
1633
 
1634
  // Build Row Cells (One per student)
1635
  const rowCells = students.map(student => {
@@ -1640,22 +1641,22 @@ export async function renderInstructorView() {
1640
 
1641
  if (p) {
1642
  if (p.status === 'completed') {
1643
- statusClass = 'bg-green-500/20 border-green-500/50 hover:bg-green-500/40 cursor-default shadow-[0_0_10px_rgba(34,197,94,0.1)]';
1644
- content = '✅';
1645
- // Action removed: Moved to prompt list view
1646
- action = `title="完成 - 請點擊標題查看詳情"`;
1647
  } else if (p.status === 'started') {
1648
  // Check stuck
1649
  const startedAt = p.timestamp ? p.timestamp.toDate() : new Date();
1650
- const now = new Date();
1651
- const diffMins = (now - startedAt) / 1000 / 60;
1652
 
1653
  if (diffMins > 5) {
1654
- statusClass = 'bg-red-900/50 border-red-500 animate-pulse cursor-help';
1655
- content = '🆘';
1656
  } else {
1657
- statusClass = 'bg-blue-600/20 border-blue-500';
1658
- content = '🔵';
1659
  }
1660
  }
1661
  }
@@ -1669,8 +1670,8 @@ export async function renderInstructorView() {
1669
  `;
1670
  }).join('');
1671
 
1672
- // Row Header (Challenge Title)
1673
- return `
1674
  <tr class="hover:bg-gray-800/50 transition-colors">
1675
  <td class="p-3 sticky left-0 bg-gray-900 z-10 border-r border-b border-gray-700 shadow-md">
1676
  <div class="flex items-center justify-between">
@@ -1694,31 +1695,31 @@ export async function renderInstructorView() {
1694
  // Global scope for HTML access
1695
  window.showBroadcastModal = (userId, challengeId) => {
1696
  const student = currentStudents.find(s => s.id === userId);
1697
- if (!student) return;
1698
 
1699
- const p = student.progress?.[challengeId];
1700
- if (!p) return;
1701
 
1702
  const challenge = cachedChallenges.find(c => c.id === challengeId);
1703
- const title = challenge ? challenge.title : 'Unknown Challenge'; // Fallback
1704
 
1705
- const modal = document.getElementById('broadcast-modal');
1706
- const content = document.getElementById('broadcast-content');
1707
 
1708
- document.getElementById('broadcast-avatar').textContent = student.nickname[0];
1709
- document.getElementById('broadcast-author').textContent = student.nickname;
1710
- document.getElementById('broadcast-challenge').textContent = title;
1711
- // content is already just text, but let's be safe
1712
- document.getElementById('broadcast-prompt').textContent = p.prompt || p.code || ''; // robust fallback
1713
 
1714
- // Store IDs for actions
1715
- modal.dataset.userId = userId;
1716
- modal.dataset.challengeId = challengeId;
1717
 
1718
- modal.classList.remove('hidden');
1719
  // Animation trigger
1720
  setTimeout(() => {
1721
- content.classList.remove('scale-95', 'opacity-0');
1722
- content.classList.add('opacity-100', 'scale-100');
1723
  }, 10);
1724
  };
 
18
  console.error("Failed header load", e);
19
  }
20
 
21
+ return `
22
+ <div id="auth-modal" class="fixed inset-0 bg-gray-900 bg-opacity-95 flex items-center justify-center z-50">
23
  <div class="bg-gray-800 p-8 rounded-lg shadow-2xl text-center max-w-sm w-full border border-gray-700">
24
  <div class="text-6xl mb-4 animate-bounce">🔒</div>
25
  <h2 class="text-2xl font-bold text-white mb-2">講師登入</h2>
 
324
  `;
325
  }
326
 
327
+ export function setupInstructorEvents() {
328
+ let roomUnsubscribe = null;
329
+ let currentInstructor = null;
330
+
331
+ // UI References
332
+ const authModal = document.getElementById('auth-modal');
333
+ // New Auth Elements
334
+ const loginEmailInput = document.getElementById('login-email');
335
+ const loginPasswordInput = document.getElementById('login-password');
336
+ const loginBtn = document.getElementById('login-btn');
337
+ const registerBtn = document.getElementById('register-btn');
338
+ const authErrorMsg = document.getElementById('auth-error');
339
+
340
+ // Remove old authBtn reference if present
341
+ // const authBtn = document.getElementById('auth-btn');
342
+
343
+ const navAdminBtn = document.getElementById('nav-admin-btn');
344
+ const navInstBtn = document.getElementById('nav-instructors-btn');
345
+ const createBtn = document.getElementById('create-room-btn');
346
+
347
+ // Other UI
348
+ const roomInfo = document.getElementById('room-info');
349
+ const createContainer = document.getElementById('create-room-container');
350
+ const dashboardContent = document.getElementById('dashboard-content');
351
+ const displayRoomCode = document.getElementById('display-room-code');
352
+ const groupPhotoBtn = document.getElementById('group-photo-btn');
353
+ const snapshotBtn = document.getElementById('snapshot-btn');
354
+ let isSnapshotting = false;
355
 
356
  // Permission Check Helper
357
  const checkPermissions = (instructor) => {
358
  if (!instructor) return;
359
 
360
+ currentInstructor = instructor;
361
 
362
+ // 1. Create Room Permission
363
+ if (instructor.permissions?.includes('create_room')) {
364
+ createBtn.classList.remove('hidden', 'opacity-50', 'cursor-not-allowed');
365
  createBtn.disabled = false;
366
  } else {
367
+ createBtn.classList.add('opacity-50', 'cursor-not-allowed');
368
  createBtn.disabled = true;
369
  createBtn.title = "無此權限";
370
  }
371
 
372
+ // 2. Add Question Permission (Admin Button)
373
+ if (instructor.permissions?.includes('add_question')) {
374
+ navAdminBtn.classList.remove('hidden');
375
  } else {
376
+ navAdminBtn.classList.add('hidden');
377
  }
378
 
379
+ // 3. Manage Instructors Permission
380
+ if (instructor.permissions?.includes('manage_instructors')) {
381
+ navInstBtn.classList.remove('hidden');
382
  } else {
383
+ navInstBtn.classList.add('hidden');
384
  }
385
  };
386
 
387
+ // Email/Password Auth Logic
388
+ if (loginBtn && registerBtn) {
389
+ // Login Handler
390
+ loginBtn.addEventListener('click', async () => {
391
+ const email = loginEmailInput.value;
392
+ const password = loginPasswordInput.value;
393
 
394
+ if (!email || !password) {
395
+ authErrorMsg.textContent = "請輸入 Email 和密碼";
396
+ authErrorMsg.classList.remove('hidden');
397
+ return;
398
+ }
399
 
400
+ try {
401
+ loginBtn.disabled = true;
402
+ loginBtn.classList.add('opacity-50');
403
+ authErrorMsg.classList.add('hidden');
404
+
405
+ const user = await loginWithEmail(email, password);
406
+ const instructorData = await checkInstructorPermission(user);
407
+
408
+ if (instructorData) {
409
+ authModal.classList.add('hidden');
410
+ checkPermissions(instructorData);
411
+ localStorage.setItem('vibecoding_instructor_name', instructorData.name);
412
+ } else {
413
+ authErrorMsg.textContent = "未授權的帳號 (Unauthorized Account)";
414
+ authErrorMsg.classList.remove('hidden');
415
+ await signOutUser();
416
+ }
417
+ } catch (error) {
418
+ console.error(error);
419
+ let msg = error.code || error.message;
420
+ if (error.code === 'auth/invalid-credential' || error.code === 'auth/wrong-password' || error.code === 'auth/user-not-found') {
421
+ msg = "帳號或密碼錯誤。";
422
+ }
423
+ authErrorMsg.textContent = "登入失敗: " + msg;
424
+ authErrorMsg.classList.remove('hidden');
425
+ } finally {
426
+ loginBtn.disabled = false;
427
+ loginBtn.classList.remove('opacity-50');
428
+ }
429
+ });
430
 
431
  // Register Handler
432
  registerBtn.addEventListener('click', async () => {
 
435
 
436
  if (!email || !password) {
437
  authErrorMsg.textContent = "請輸入 Email 和密碼";
438
+ authErrorMsg.classList.remove('hidden');
439
+ return;
440
  }
441
 
442
  try {
443
  registerBtn.disabled = true;
444
+ registerBtn.classList.add('opacity-50');
445
+ authErrorMsg.classList.add('hidden');
446
+
447
+ // Try to create auth account
448
+ const user = await registerWithEmail(email, password);
449
+ // Check if this email is in our whitelist
450
+ const instructorData = await checkInstructorPermission(user);
451
+
452
+ if (instructorData) {
453
+ authModal.classList.add('hidden');
454
+ checkPermissions(instructorData);
455
+ localStorage.setItem('vibecoding_instructor_name', instructorData.name);
456
+ alert("註冊成功!");
457
  } else {
458
+ // Auth created but not in whitelist
459
+ authErrorMsg.textContent = "註冊成功,但您的 Email 未被列入講師名單。請聯繫管理員。";
460
+ authErrorMsg.classList.remove('hidden');
461
+ await signOutUser();
462
  }
463
  } catch (error) {
464
  console.error(error);
465
+ let msg = error.code || error.message;
466
+ if (error.code === 'auth/email-already-in-use') {
467
+ msg = "此 Email 已被註冊,請直接登入。";
468
  }
469
+ authErrorMsg.textContent = "註冊失敗: " + msg;
470
+ authErrorMsg.classList.remove('hidden');
471
  } finally {
472
  registerBtn.disabled = false;
473
+ registerBtn.classList.remove('opacity-50');
474
  }
475
  });
476
  }
 
478
  // Handle Instructor Management
479
  navInstBtn.addEventListener('click', async () => {
480
  const modal = document.getElementById('instructor-modal');
481
+ const listBody = document.getElementById('instructor-list-body');
482
 
483
+ // Load list
484
+ const instructors = await getInstructors();
485
  listBody.innerHTML = instructors.map(inst => `
486
  <tr class="border-b border-gray-700 hover:bg-gray-800">
487
  <td class="p-3">${inst.name}</td>
488
  <td class="p-3 font-mono text-sm text-cyan-400">${inst.email}</td>
489
  <td class="p-3 text-xs">
490
  ${inst.permissions?.map(p => {
491
+ const map = { create_room: '開房', add_question: '題目', manage_instructors: '管理' };
492
+ return `<span class="bg-gray-700 px-2 py-1 rounded mr-1">${map[p] || p}</span>`;
493
+ }).join('')}
494
  </td>
495
  <td class="p-3">
496
  ${inst.role === 'admin' ? '<span class="text-gray-500 italic">不可移除</span>' :
497
+ `<button class="text-red-400 hover:text-red-300" onclick="window.removeInst('${inst.email}')">移除</button>`}
498
  </td>
499
  </tr>
500
  `).join('');
501
 
502
+ modal.classList.remove('hidden');
503
  });
504
 
505
  // Add New Instructor
506
  document.getElementById('btn-add-inst').addEventListener('click', async () => {
507
  const email = document.getElementById('new-inst-email').value.trim();
508
+ const name = document.getElementById('new-inst-name').value.trim();
509
 
510
+ if (!email || !name) return alert("請輸入完整資料");
511
 
512
+ const perms = [];
513
+ if (document.getElementById('perm-room').checked) perms.push('create_room');
514
+ if (document.getElementById('perm-q').checked) perms.push('add_question');
515
+ if (document.getElementById('perm-inst').checked) perms.push('manage_instructors');
516
 
517
+ try {
518
+ await addInstructor(email, name, perms);
519
  alert("新增成功");
520
  navInstBtn.click(); // Reload list
521
  document.getElementById('new-inst-email').value = '';
522
  document.getElementById('new-inst-name').value = '';
523
  } catch (e) {
524
+ alert("新增失敗: " + e.message);
525
  }
526
  });
527
 
 
530
  if (confirm(`確定移除 ${email}?`)) {
531
  try {
532
  await removeInstructor(email);
533
+ navInstBtn.click(); // Reload
534
  } catch (e) {
535
  alert(e.message);
536
  }
537
  }
538
  };
539
 
540
+ // Auto Check Auth (Persistence)
541
+ // We rely on Firebase Auth state observer instead of session storage for security?
542
+ // Or we can just check if user is already signed in.
543
+ import("../services/firebase.js").then(async ({ auth }) => {
544
  // Handle Redirect Result first
545
  try {
546
+ console.log("Initializing Auth Check...");
547
+ const { handleRedirectResult } = await import("../services/auth.js");
548
  const redirectUser = await handleRedirectResult();
549
  if (redirectUser) console.log("Redirect User Found:", redirectUser.email);
550
+ } catch (e) { console.warn("Redirect check failed", e); }
551
 
552
  auth.onAuthStateChanged(async (user) => {
553
+ console.log("Auth State Changed to:", user ? user.email : "Logged Out");
554
  if (user) {
555
  try {
556
+ console.log("Checking permissions for:", user.email);
557
+ const instructorData = await checkInstructorPermission(user);
558
+ console.log("Permission Result:", instructorData);
559
+
560
+ if (instructorData) {
561
+ console.log("Hiding Modal and Setting Permissions...");
562
+ authModal.classList.add('hidden');
563
+ checkPermissions(instructorData);
564
  } else {
565
+ console.warn("User logged in but not an instructor.");
566
+ // Show unauthorized message
567
+ authErrorMsg.textContent = "此帳號無講師權限";
568
+ authErrorMsg.classList.remove('hidden');
569
+ authModal.classList.remove('hidden'); // Ensure modal stays up
570
  }
571
  } catch (e) {
572
+ console.error("Permission Check Failed:", e);
573
+ authErrorMsg.textContent = "權限檢查失敗: " + e.message;
574
+ authErrorMsg.classList.remove('hidden');
575
  }
576
  } else {
577
  authModal.classList.remove('hidden');
 
583
  window.confirmKick = async (userId, nickname) => {
584
  if (confirm(`確定要踢出 ${nickname} 嗎?此動作無法復原。`)) {
585
  try {
586
+ const { removeUser } = await import("../services/classroom.js");
587
+ await removeUser(userId);
588
  // UI will update automatically via subscribeToRoom
589
  } catch (e) {
590
  console.error("Kick failed:", e);
591
+ alert("移除失敗");
592
  }
593
  }
594
  };
 
600
  if (typeof htmlToImage === 'undefined') alert("截圖元件尚未載入,請稍候再試");
601
  return;
602
  }
603
+ isSnapshotting = true;
604
 
605
+ const overlay = document.getElementById('snapshot-overlay');
606
+ const countEl = document.getElementById('countdown-number');
607
+ const container = document.getElementById('group-photo-container');
608
+ const modal = document.getElementById('group-photo-modal');
609
 
610
+ // Close button hide
611
+ const closeBtn = modal.querySelector('button');
612
+ if (closeBtn) closeBtn.style.opacity = '0';
613
+ snapshotBtn.style.opacity = '0';
614
 
615
+ overlay.classList.remove('hidden');
616
+ overlay.classList.add('flex');
617
 
618
  // Countdown Sequence
619
  const runCountdown = (num) => new Promise(resolve => {
620
+ countEl.textContent = num;
621
  countEl.style.transform = 'scale(1.5)';
622
  countEl.style.opacity = '1';
623
 
624
  // Animation reset
625
  requestAnimationFrame(() => {
626
  countEl.style.transition = 'all 0.5s ease-out';
627
+ countEl.style.transform = 'scale(1)';
628
+ countEl.style.opacity = '0.5';
629
+ setTimeout(resolve, 1000);
630
  });
631
  });
632
 
633
+ await runCountdown(3);
634
+ await runCountdown(2);
635
+ await runCountdown(1);
636
 
637
+ // Action!
638
+ countEl.textContent = '';
639
+ overlay.classList.add('hidden');
640
 
641
+ // 1. Emojis Explosion
642
+ const emojis = ['🤘', '✌️', '👍', '🫶', '😎', '🔥'];
643
+ const cards = container.querySelectorAll('.group\\/card');
644
 
645
  cards.forEach(card => {
646
  // Find the monster image container
 
665
  try {
666
  // Flash Effect
667
  const flash = document.createElement('div');
668
+ flash.className = 'fixed inset-0 bg-white z-[100] transition-opacity duration-300 pointer-events-none';
669
+ document.body.appendChild(flash);
670
  setTimeout(() => flash.style.opacity = '0', 50);
671
  setTimeout(() => flash.remove(), 300);
672
 
673
+ // Use htmlToImage.toPng
674
+ const dataUrl = await htmlToImage.toPng(container, {
675
+ backgroundColor: '#111827',
676
+ pixelRatio: 2,
677
+ cacheBust: true,
678
  });
679
 
680
+ // Download
681
+ const link = document.createElement('a');
682
+ const dateStr = new Date().toISOString().slice(0, 10);
683
+ link.download = `VIBE_Class_Photo_${dateStr}.png`;
684
+ link.href = dataUrl;
685
+ link.click();
686
 
687
  } catch (e) {
688
  console.error("Snapshot failed:", e);
689
+ alert("截圖失敗 (請嘗試手動截圖/PrtSc)\n原因: " + e.message);
690
  } finally {
691
  // Restore UI
692
  if (closeBtn) closeBtn.style.opacity = '1';
693
+ snapshotBtn.style.opacity = '1';
694
+ isSnapshotting = false;
695
  }
696
  }, 600); // Slight delay for emojis to appear
697
  });
 
699
  // Group Photo Logic
700
  groupPhotoBtn.addEventListener('click', () => {
701
  const modal = document.getElementById('group-photo-modal');
702
+ const container = document.getElementById('group-photo-container');
703
+ const dateEl = document.getElementById('photo-date');
704
 
705
+ // Update Date
706
+ const now = new Date();
707
+ dateEl.textContent = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} `;
708
 
709
+ // Get saved name
710
+ const savedName = localStorage.getItem('vibecoding_instructor_name') || '講師 (Instructor)';
711
 
712
+ container.innerHTML = '';
713
 
714
+ // 1. Container for Relative Positioning with Custom Background
715
+ const relativeContainer = document.createElement('div');
716
+ 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';
717
+ relativeContainer.style.backgroundImage = "url('assets/photobg.png')";
718
+ container.appendChild(relativeContainer);
719
 
720
+ // Watermark (Bottom Right, High Z-Index, Gradient Text, Dark Backdrop)
721
+ const watermark = document.createElement('div');
722
+ 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';
723
 
724
+ const d = new Date();
725
+ const dateStr = `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} `;
726
 
727
+ watermark.innerHTML = `
728
  <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">
729
  ${dateStr} VibeCoding 怪獸成長營
730
  </span>
731
  `;
732
+ relativeContainer.appendChild(watermark);
733
 
734
+ // 2. Instructor Section (Absolute Center)
735
+ const instructorSection = document.createElement('div');
736
+ 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';
737
+ instructorSection.innerHTML = `
738
  <div class="relative">
739
  <div class="absolute inset-0 bg-yellow-500/20 blur-3xl rounded-full animate-pulse"></div>
740
  <!--Pixel Art Avatar-->
 
753
  </div>
754
  </div>
755
  `;
756
+ relativeContainer.appendChild(instructorSection);
757
 
758
  // Save name on change
759
  setTimeout(() => {
 
777
 
778
  if (total >= 40) {
779
  sizeClass = 'w-12 h-12 md:w-14 md:h-14'; // Size 60%
780
+ scaleFactor = 0.6;
781
  } else if (total >= 20) {
782
  sizeClass = 'w-16 h-16 md:w-20 md:h-20'; // Size 80%
783
+ scaleFactor = 0.8;
784
  }
785
 
786
  students.forEach((s, index) => {
787
+ const progressMap = s.progress || {};
788
  const totalLikes = Object.values(progressMap).reduce((acc, p) => acc + (p.likes || 0), 0);
789
  const totalCompleted = Object.values(progressMap).filter(p => p.status === 'completed').length;
790
 
791
+ // FIXED: Prioritize stored ID if valid (same as StudentView logic)
792
+ let monster;
793
+ if (s.monster_id && s.monster_id !== 'Egg' && s.monster_id !== 'Unknown') {
794
  const stored = MONSTER_DEFS?.find(m => m.id === s.monster_id);
795
+ if (stored) {
796
+ monster = stored;
797
  } else {
798
+ // Fallback if ID invalid
799
+ monster = getNextMonster(s.monster_stage || 0, totalLikes, total, s.monster_id);
800
  }
801
  } else {
802
+ monster = getNextMonster(s.monster_stage || 0, totalLikes, total, s.monster_id);
803
  }
804
 
805
+ // --- FIXED: Even Arc Distribution (Safe Zone: 135 deg to 405 deg) ---
806
+ // Avoids Bottom Right (0-90) and Bottom Center (90-120) entirely
807
+ const minR = 220;
808
+ // Avoids Bottom Right (0-90) and Bottom Center (90-120) entirely
809
 
810
+ // Safe Arc Range: Starts at 135 deg (Bottom Left) -> Goes CW -> Ends at 405 deg (45 deg, Bottom Right)
811
+ // Total Span = 270 degrees
812
+ // If many students, use double ring
813
 
814
+ const safeStartAngle = 135 * (Math.PI / 180);
815
+ const safeSpan = 270 * (Math.PI / 180);
816
 
817
+ // Distribute evenly
818
+ // If only 1 student, put at top (270 deg / 4.71 rad)
819
+ let finalAngle;
820
 
821
+ if (total === 1) {
822
+ finalAngle = 270 * (Math.PI / 180);
823
  } else {
824
  const step = safeSpan / (total - 1);
825
+ finalAngle = safeStartAngle + (step * index);
826
  }
827
 
828
+ // Radius: Fixed base + slight variation for "natural" look (but not overlap causing)
829
+ // Double ring logic if crowded
830
+ let radius = minR + (index % 2) * 40; // Zigzag radius (220, 260, 220...) to minimize overlap
831
 
832
+ // Reduce zigzag if few students
833
+ if (total < 10) radius = minR + (index % 2) * 20;
834
 
835
+ const xOff = Math.cos(finalAngle) * radius;
836
+ const yOff = Math.sin(finalAngle) * radius * 0.8;
837
 
838
+ const card = document.createElement('div');
839
+ card.className = 'absolute flex flex-col items-center group/card z-10 hover:z-50 transition-all duration-500 cursor-move';
840
 
841
+ card.style.left = `calc(50% + ${xOff}px)`;
842
+ card.style.top = `calc(50% + ${yOff}px)`;
843
+ card.style.transform = 'translate(-50%, -50%)';
844
 
845
+ const floatDelay = Math.random() * 2;
846
 
847
+ card.innerHTML = `
848
  <!--Top Info: Monster Stats-->
849
  <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">
850
  <div class="text-[10px] text-gray-300 mb-0.5 whitespace-nowrap">${monster.name.split(' ')[1] || monster.name}</div>
 
869
  <div class="text-xs font-bold text-white shadow-black drop-shadow-md whitespace-nowrap tracking-wide">${s.nickname}</div>
870
  </div>
871
  `;
872
+ relativeContainer.appendChild(card);
873
 
874
+ // Enable Drag & Drop
875
+ setupDraggable(card, relativeContainer);
876
  });
877
  }
878
 
879
+ modal.classList.remove('hidden');
880
  });
881
 
882
+ // Helper: Drag & Drop Logic
883
+ function setupDraggable(el, container) {
884
+ let isDragging = false;
885
+ let startX, startY, initialLeft, initialTop;
886
 
887
  el.addEventListener('mousedown', (e) => {
888
+ isDragging = true;
889
  startX = e.clientX;
890
  startY = e.clientY;
891
 
 
929
  window.addEventListener('mouseup', () => {
930
  if (isDragging) {
931
  isDragging = false;
932
+ el.style.transition = ''; // Re-enable hover effects
933
+ el.style.zIndex = ''; // Restore z-index rule (or let hover take over)
934
  }
935
  });
936
  }
937
 
938
+ // Add float animation style if not exists
939
+ if (!document.getElementById('anim-float')) {
940
  const style = document.createElement('style');
941
+ style.id = 'anim-float';
942
+ style.innerHTML = `
943
  @keyframes float {
944
 
945
  0 %, 100 % { transform: translateY(0) scale(1); }
 
947
  }
948
  }
949
  `;
950
+ document.head.appendChild(style);
951
  }
952
 
953
  navAdminBtn.addEventListener('click', () => {
954
  // Save current room to return later
955
  const currentRoom = localStorage.getItem('vibecoding_instructor_room');
956
+ localStorage.setItem('vibecoding_admin_referer', 'instructor'); // track entry source
957
+ window.location.hash = 'admin';
958
  });
959
 
960
+ // Auto-fill code
961
+ const savedRoomCode = localStorage.getItem('vibecoding_instructor_room');
962
+ if (savedRoomCode) {
963
+ document.getElementById('rejoin-room-code').value = savedRoomCode;
964
  }
965
 
966
+ const rejoinBtn = document.getElementById('rejoin-room-btn');
967
  rejoinBtn.addEventListener('click', () => {
968
  const code = document.getElementById('rejoin-room-code').value.trim();
969
+ if (!code) return alert('請輸入教室代碼');
970
+ enterRoom(code);
971
  });
972
 
973
  // Gallery Logic
974
  document.getElementById('btn-open-gallery').addEventListener('click', () => {
975
+ window.open('monster_preview.html', '_blank');
976
  });
977
 
978
  // Logout Logic
979
  document.getElementById('logout-btn').addEventListener('click', async () => {
980
  if (confirm('確定要登出講師模式嗎? (將會回到首頁)')) {
981
+ await signOutUser();
982
  sessionStorage.removeItem('vibecoding_instructor_in_room');
983
  sessionStorage.removeItem('vibecoding_admin_referer');
984
  window.location.hash = '';
 
988
 
989
  createBtn.addEventListener('click', async () => {
990
  try {
991
+ createBtn.disabled = true;
992
  createBtn.textContent = "...";
993
  const roomCode = await createRoom();
994
  enterRoom(roomCode);
995
  } catch (error) {
996
+ console.error(error);
997
  alert("建立失敗");
998
  createBtn.disabled = false;
999
  }
 
1006
 
1007
  // Check Active Room State
1008
  const activeRoom = sessionStorage.getItem('vibecoding_instructor_in_room');
1009
+ if (activeRoom === 'true' && savedRoomCode) {
1010
+ enterRoom(savedRoomCode);
1011
  }
1012
 
1013
+ // Module-level variable to track subscription (Moved to top)
1014
 
1015
+ function enterRoom(roomCode) {
1016
+ createContainer.classList.add('hidden');
1017
+ roomInfo.classList.remove('hidden');
1018
+ dashboardContent.classList.remove('hidden');
1019
+ document.getElementById('group-photo-btn').classList.remove('hidden'); // Show photo button
1020
+ displayRoomCode.textContent = roomCode;
1021
+ localStorage.setItem('vibecoding_instructor_room', roomCode);
1022
+ sessionStorage.setItem('vibecoding_instructor_in_room', 'true');
1023
 
1024
+ // Unsubscribe previous if any
1025
+ if (roomUnsubscribe) roomUnsubscribe();
1026
 
1027
  // Subscribe to updates
1028
  roomUnsubscribe = subscribeToRoom(roomCode, (students) => {
1029
+ currentStudents = students;
1030
  renderTransposedHeatmap(students);
1031
  });
1032
  }
 
1037
  // Unsubscribe
1038
  if (roomUnsubscribe) {
1039
  roomUnsubscribe();
1040
+ roomUnsubscribe = null;
1041
  }
1042
 
1043
  // UI Reset
 
1059
  // Modal Events
1060
  window.showBroadcastModal = (userId, challengeId) => {
1061
  const modal = document.getElementById('broadcast-modal');
1062
+ const content = document.getElementById('broadcast-content');
1063
 
1064
  // Find Data
1065
  const student = currentStudents.find(s => s.id === userId);
1066
+ if (!student) return alert('找不到學員資料');
1067
 
1068
+ const p = student.progress ? student.progress[challengeId] : null;
1069
+ if (!p) return alert('找不到該作品資料');
1070
 
1071
  const challenge = cachedChallenges.find(c => c.id === challengeId);
1072
+ const title = challenge ? challenge.title : '未知題目';
1073
 
1074
+ // Populate UI
1075
+ document.getElementById('broadcast-avatar').textContent = student.nickname[0] || '?';
1076
+ document.getElementById('broadcast-author').textContent = student.nickname;
1077
+ document.getElementById('broadcast-challenge').textContent = title;
1078
+ document.getElementById('broadcast-prompt').textContent = p.prompt || '(無內容)';
1079
 
1080
+ // Store IDs for Actions (Reject/BroadcastAll)
1081
+ modal.dataset.userId = userId;
1082
+ modal.dataset.challengeId = challengeId;
1083
 
1084
+ // Show
1085
+ modal.classList.remove('hidden');
1086
  setTimeout(() => {
1087
+ content.classList.remove('scale-95', 'opacity-0');
1088
  content.classList.add('opacity-100', 'scale-100');
1089
  }, 10);
1090
  };
1091
 
1092
  window.closeBroadcast = () => {
1093
  const modal = document.getElementById('broadcast-modal');
1094
+ const content = document.getElementById('broadcast-content');
1095
+ content.classList.remove('opacity-100', 'scale-100');
1096
+ content.classList.add('scale-95', 'opacity-0');
1097
  setTimeout(() => modal.classList.add('hidden'), 300);
1098
  };
1099
 
1100
  window.openStage = (prompt, author) => {
1101
+ document.getElementById('broadcast-content').classList.add('hidden');
1102
+ const stage = document.getElementById('stage-view');
1103
+ stage.classList.remove('hidden');
1104
+ document.getElementById('stage-prompt').textContent = prompt;
1105
+ document.getElementById('stage-author').textContent = author;
1106
  };
1107
 
1108
  window.closeStage = () => {
1109
+ document.getElementById('stage-view').classList.add('hidden');
1110
+ document.getElementById('broadcast-content').classList.remove('hidden');
1111
  };
1112
 
1113
  document.getElementById('btn-show-stage').addEventListener('click', () => {
1114
  const prompt = document.getElementById('broadcast-prompt').textContent;
1115
+ const author = document.getElementById('broadcast-author').textContent;
1116
+ window.openStage(prompt, author);
1117
  });
1118
 
1119
  // Reject Logic
1120
  document.getElementById('btn-reject-task').addEventListener('click', async () => {
1121
  if (!confirm('確定要退回此題目讓學員重做嗎?')) return;
1122
 
1123
+ // We need student ID (userId) and Challenge ID.
1124
+ // Currently showBroadcastModal only receives nickname, title, prompt.
1125
+ // We need to attach data-userid and data-challengeid to the modal.
1126
+ const modal = document.getElementById('broadcast-modal');
1127
+ const userId = modal.dataset.userId;
1128
+ const challengeId = modal.dataset.challengeId;
1129
+ const roomCode = localStorage.getItem('vibecoding_instructor_room');
1130
 
1131
+ if (userId && challengeId && roomCode) {
1132
  try {
1133
  await resetProgress(userId, roomCode, challengeId);
1134
+ // Close modal
1135
+ window.closeBroadcast();
1136
  } catch (e) {
1137
  console.error(e);
1138
+ alert('退回失敗');
1139
  }
1140
  }
1141
  });
1142
  // Prompt Viewer Logic
1143
  window.openPromptList = (type, id, title) => {
1144
  const modal = document.getElementById('prompt-list-modal');
1145
+ const container = document.getElementById('prompt-list-container');
1146
+ const titleEl = document.getElementById('prompt-list-title');
1147
 
1148
+ titleEl.textContent = type === 'student' ? `${title} 的所有提示詞` : `題目:${title} 的所有作品`;
1149
 
1150
+ // Reset Anonymous Toggle in List View
1151
+ const anonCheck = document.getElementById('list-anonymous-toggle');
1152
+ if (anonCheck) anonCheck.checked = false;
1153
 
1154
+ container.innerHTML = '';
1155
+ modal.classList.remove('hidden');
1156
 
1157
+ // Collect Prompts
1158
+ let prompts = [];
1159
+ // Fix: Reset selection when opening new list to prevent cross-contamination
1160
+ selectedPrompts = [];
1161
+ updateCompareButton();
1162
 
1163
+ if (type === 'student') {
1164
  const student = currentStudents.find(s => s.id === id);
1165
  if (student && student.progress) {
1166
  prompts = Object.entries(student.progress)
 
1179
  });
1180
  }
1181
  } else if (type === 'challenge') {
1182
+ currentStudents.forEach(student => {
1183
+ if (student.progress && student.progress[id]) {
1184
+ const p = student.progress[id];
1185
+ if (p.status === 'completed' && p.prompt) {
1186
+ prompts.push({
1187
+ id: `${student.id}_${id}`,
1188
+ title: student.nickname, // When viewing challenge, title is student name
1189
+ prompt: p.prompt,
1190
+ author: student.nickname,
1191
+ studentId: student.id,
1192
+ challengeId: id,
1193
+ time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : ''
1194
+ });
 
1195
  }
1196
+ }
1197
+ });
1198
  }
1199
 
1200
+ if (prompts.length === 0) {
1201
+ container.innerHTML = '<div class="col-span-full text-center text-gray-500 py-10">無資料</div>';
1202
  return;
1203
  }
1204
 
 
1237
  const roomCode = localStorage.getItem('vibecoding_instructor_room');
1238
  if (userId && challengeId && roomCode) {
1239
  try {
1240
+ const { resetProgress } = await import("../services/classroom.js");
1241
+ await resetProgress(userId, roomCode, challengeId);
1242
+ // 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)
1243
+ // For now, simple alert or auto-close
1244
+ alert("已退回");
1245
+ // close modal to refresh data context
1246
+ document.getElementById('prompt-list-modal').classList.add('hidden');
1247
  } catch (e) {
1248
+ console.error(e);
1249
+ alert("退回失敗");
1250
  }
1251
  }
1252
  }
1253
  };
1254
 
1255
  window.broadcastPrompt = (userId, challengeId) => {
1256
+ window.showBroadcastModal(userId, challengeId);
1257
  };
1258
 
1259
+ // Selection Logic
1260
+ let selectedPrompts = []; // Stores IDs
1261
 
1262
  window.handlePromptSelection = (checkbox) => {
1263
  const id = checkbox.dataset.id;
1264
 
1265
+ if (checkbox.checked) {
1266
  if (selectedPrompts.length >= 3) {
1267
  checkbox.checked = false;
1268
+ alert('最多只能選擇 3 個提示詞進行比較');
1269
+ return;
1270
  }
1271
  selectedPrompts.push(id);
1272
  } else {
1273
+ selectedPrompts = selectedPrompts.filter(pid => pid !== id);
1274
  }
1275
+ updateCompareButton();
1276
  };
1277
 
1278
+ function updateCompareButton() {
1279
  const btn = document.getElementById('btn-compare-prompts');
1280
+ if (!btn) return;
1281
 
1282
+ const count = selectedPrompts.length;
1283
+ const span = btn.querySelector('span');
1284
+ if (span) span.textContent = `🔍 比較已選項目 (${count}/3)`;
1285
 
1286
  if (count > 0) {
1287
+ btn.disabled = false;
1288
  btn.classList.remove('opacity-50', 'cursor-not-allowed');
1289
  } else {
1290
+ btn.disabled = true;
1291
  btn.classList.add('opacity-50', 'cursor-not-allowed');
1292
  }
1293
  }
1294
+ // Comparison Logic
1295
+ const compareBtn = document.getElementById('btn-compare-prompts');
1296
+ if (compareBtn) {
1297
+ compareBtn.addEventListener('click', () => {
1298
+ const dataToCompare = [];
1299
+ selectedPrompts.forEach(fullId => {
1300
+ const lastUnderscore = fullId.lastIndexOf('_');
1301
+ const studentId = fullId.substring(0, lastUnderscore);
1302
+ const challengeId = fullId.substring(lastUnderscore + 1);
1303
+
1304
+ const student = currentStudents.find(s => s.id === studentId);
1305
+ if (student && student.progress && student.progress[challengeId]) {
1306
+ const p = student.progress[challengeId];
1307
+ const challenge = cachedChallenges.find(c => c.id === challengeId);
1308
+
1309
+ dataToCompare.push({
1310
+ title: challenge ? challenge.title : '未知',
1311
+ author: student.nickname,
1312
+ prompt: p.prompt,
1313
+ time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleTimeString() : ''
 
 
1314
  });
1315
+ }
1316
+ });
1317
 
1318
+ const isAnon = document.getElementById('list-anonymous-toggle')?.checked || false;
1319
+ openComparisonView(dataToCompare, isAnon);
1320
+ });
1321
  }
1322
 
1323
+ let isAnonymous = false;
1324
 
1325
  window.toggleAnonymous = (btn) => {
1326
+ isAnonymous = !isAnonymous;
1327
+ btn.textContent = isAnonymous ? '🙈 顯示姓名' : '👀 隱藏姓名';
1328
+ btn.classList.toggle('bg-gray-700');
1329
+ btn.classList.toggle('bg-purple-700');
1330
 
1331
  // Update DOM
1332
  document.querySelectorAll('.comparison-author').forEach(el => {
1333
  if (isAnonymous) {
1334
  el.dataset.original = el.textContent;
1335
+ el.textContent = '學員';
1336
+ el.classList.add('blur-sm'); // Optional Effect
1337
  setTimeout(() => el.classList.remove('blur-sm'), 300);
1338
  } else {
1339
  if (el.dataset.original) el.textContent = el.dataset.original;
 
1343
 
1344
  window.openComparisonView = (items, initialAnonymous = false) => {
1345
  const modal = document.getElementById('comparison-modal');
1346
+ const grid = document.getElementById('comparison-grid');
1347
 
1348
+ // Apply Anonymous State
1349
+ isAnonymous = initialAnonymous;
1350
+ const anonBtn = document.getElementById('btn-anonymous-toggle');
1351
 
1352
+ // Update Toggle UI to match state
1353
+ if (anonBtn) {
1354
  if (isAnonymous) {
1355
  anonBtn.textContent = '🙈 顯示姓名';
1356
+ anonBtn.classList.add('bg-purple-700');
1357
+ anonBtn.classList.remove('bg-gray-700');
1358
  } else {
1359
  anonBtn.textContent = '👀 隱藏姓名';
1360
+ anonBtn.classList.remove('bg-purple-700');
1361
+ anonBtn.classList.add('bg-gray-700');
1362
  }
1363
  }
1364
 
1365
+ // Setup Grid Rows (Vertical Stacking)
1366
+ let rowClass = 'grid-rows-1';
1367
+ if (items.length === 2) rowClass = 'grid-rows-2';
1368
+ if (items.length === 3) rowClass = 'grid-rows-3';
1369
 
1370
+ grid.className = `absolute inset-0 grid ${rowClass} gap-0 divide-y divide-gray-600`;
1371
+ grid.innerHTML = '';
1372
 
1373
  items.forEach(item => {
1374
  const col = document.createElement('div');
 
1381
 
1382
  if (isAnonymous) {
1383
  displayAuthor = '學員';
1384
+ blurClass = 'blur-sm'; // Initial blur
1385
  // Auto remove blur after delay if needed, or keep it?
1386
  // Toggle logic removes it after delay. But initial render should probably just be static '學員' or blurred.
1387
  // The toggle logic uses dataset.original. We need to set it here too.
 
1411
  }
1412
  });
1413
 
1414
+ document.getElementById('prompt-list-modal').classList.add('hidden');
1415
+ modal.classList.remove('hidden');
1416
 
1417
+ // Init Canvas (Phase 3)
1418
+ setTimeout(setupCanvas, 100);
1419
  };
1420
 
1421
  window.closeComparison = () => {
1422
+ document.getElementById('comparison-modal').classList.add('hidden');
1423
+ clearCanvas();
1424
  };
1425
 
1426
+ // --- Phase 3 & 6: Annotation Tools ---
1427
+ let canvas, ctx;
1428
+ let isDrawing = false;
1429
+ let currentPenColor = '#ef4444'; // Red default
1430
+ let currentLineWidth = 3;
1431
+ let currentMode = 'source-over'; // 'source-over' (Pen) or 'destination-out' (Eraser)
1432
 
1433
  window.setupCanvas = () => {
1434
+ canvas = document.getElementById('annotation-canvas');
1435
+ const container = document.getElementById('comparison-container');
1436
+ if (!canvas || !container) return;
1437
 
1438
+ ctx = canvas.getContext('2d');
1439
 
1440
  // Resize
1441
  const resize = () => {
1442
+ canvas.width = container.clientWidth;
1443
  canvas.height = container.clientHeight;
1444
  ctx.lineCap = 'round';
1445
  ctx.lineJoin = 'round';
 
1447
  ctx.lineWidth = currentLineWidth;
1448
  ctx.globalCompositeOperation = currentMode;
1449
  };
1450
+ resize();
1451
+ window.addEventListener('resize', resize);
1452
 
1453
+ // Init Size UI & Cursor
1454
+ updateSizeBtnUI();
1455
+ updateCursorStyle();
1456
 
1457
+ // Cursor Logic
1458
+ const cursor = document.getElementById('tool-cursor');
1459
 
1460
  canvas.addEventListener('mouseenter', () => cursor.classList.remove('hidden'));
1461
  canvas.addEventListener('mouseleave', () => cursor.classList.add('hidden'));
1462
  canvas.addEventListener('mousemove', (e) => {
1463
+ const { x, y } = getPos(e);
1464
  cursor.style.left = `${x}px`;
1465
  cursor.style.top = `${y}px`;
1466
  });
1467
 
1468
  // Drawing Events
1469
  const start = (e) => {
1470
+ isDrawing = true;
1471
  ctx.beginPath();
1472
 
1473
  // Re-apply settings (state might change)
 
1475
  ctx.strokeStyle = currentPenColor;
1476
  ctx.lineWidth = currentLineWidth;
1477
 
1478
+ const { x, y } = getPos(e);
1479
  ctx.moveTo(x, y);
1480
  };
1481
 
1482
  const move = (e) => {
1483
  if (!isDrawing) return;
1484
+ const { x, y } = getPos(e);
1485
  ctx.lineTo(x, y);
1486
  ctx.stroke();
1487
  };
1488
 
1489
  const end = () => {
1490
+ isDrawing = false;
1491
  };
1492
 
1493
+ canvas.onmousedown = start;
1494
+ canvas.onmousemove = move;
1495
+ canvas.onmouseup = end;
1496
+ canvas.onmouseleave = end;
1497
 
1498
  // Touch support
1499
+ canvas.ontouchstart = (e) => { e.preventDefault(); start(e.touches[0]); };
1500
+ canvas.ontouchmove = (e) => { e.preventDefault(); move(e.touches[0]); };
1501
+ canvas.ontouchend = (e) => { e.preventDefault(); end(); };
1502
  };
1503
 
1504
+ function getPos(e) {
1505
  const rect = canvas.getBoundingClientRect();
1506
+ return {
1507
+ x: e.clientX - rect.left,
1508
  y: e.clientY - rect.top
1509
  };
1510
  }
1511
 
1512
  // Unified Tool Handler
1513
  window.setPenTool = (tool, color, btn) => {
1514
+ // UI Update
1515
+ document.querySelectorAll('.annotation-tool').forEach(b => {
1516
+ b.classList.remove('ring-white');
1517
+ b.classList.add('ring-transparent');
1518
+ });
1519
+ btn.classList.remove('ring-transparent');
1520
+ btn.classList.add('ring-white');
1521
 
1522
+ if (tool === 'eraser') {
1523
+ currentMode = 'destination-out';
1524
  } else {
1525
+ currentMode = 'source-over';
1526
  currentPenColor = color;
1527
  }
1528
+ updateCursorStyle();
1529
  };
1530
 
1531
  // Size Handler
1532
  window.setPenSize = (size, btn) => {
1533
+ currentLineWidth = size;
1534
+ updateSizeBtnUI();
1535
+ updateCursorStyle();
1536
  };
1537
 
1538
+ function updateCursorStyle() {
1539
  const cursor = document.getElementById('tool-cursor');
1540
+ if (!cursor) return;
1541
 
1542
+ // Size
1543
+ cursor.style.width = `${currentLineWidth}px`;
1544
+ cursor.style.height = `${currentLineWidth}px`;
1545
 
1546
+ // Color
1547
+ if (currentMode === 'destination-out') {
1548
+ // Eraser: White solid
1549
+ cursor.style.backgroundColor = 'white';
1550
  cursor.style.borderColor = '#999';
1551
  } else {
1552
+ // Pen: Tool color
1553
+ cursor.style.backgroundColor = currentPenColor;
1554
  cursor.style.borderColor = 'rgba(255,255,255,0.8)';
1555
  }
1556
  }
1557
 
1558
+ function updateSizeBtnUI() {
1559
+ document.querySelectorAll('.size-btn').forEach(b => {
1560
+ if (parseInt(b.dataset.size) === currentLineWidth) {
1561
+ b.classList.add('bg-gray-600', 'text-white');
1562
+ b.classList.remove('text-gray-400', 'hover:bg-gray-700');
1563
+ } else {
1564
+ b.classList.remove('bg-gray-600', 'text-white');
1565
+ b.classList.add('text-gray-400', 'hover:bg-gray-700');
1566
+ }
1567
+ });
1568
  }
1569
 
1570
  window.clearCanvas = () => {
1571
  if (canvas && ctx) {
1572
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
1573
  }
1574
  };
1575
  }
1576
 
1577
+ /**
1578
+ * Renders the Transposed Heatmap (Rows=Challenges, Cols=Students)
1579
+ */
1580
+ function renderTransposedHeatmap(students) {
1581
  const thead = document.getElementById('heatmap-header');
1582
+ const tbody = document.getElementById('heatmap-body');
1583
 
1584
+ if (students.length === 0) {
1585
+ thead.innerHTML = '<th class="p-4 text-left">等待資料...</th>';
1586
+ tbody.innerHTML = '<tr><td class="p-10 text-center text-gray-500">尚無學員加入</td></tr>';
1587
+ return;
1588
  }
1589
 
1590
+ // 1. Render Header (Students)
1591
+ // Sticky Top for Header Row
1592
+ // Sticky Left for the first cell ("Challenge/Student")
1593
+ let headerHtml = `
1594
  <th class="p-3 text-left sticky left-0 top-0 bg-gray-800 z-30 border-b border-gray-600 min-w-[200px] border-r border-gray-700 shadow-md">
1595
  <div class="flex justify-between items-end">
1596
  <span class="text-sm text-gray-400">題目</span>
 
1600
  `;
1601
 
1602
  students.forEach(student => {
1603
+ headerHtml += `
1604
  <th class="p-2 text-center sticky top-0 bg-gray-800 z-20 border-b border-gray-700 min-w-[80px] group">
1605
  <div class="flex flex-col items-center space-y-2 py-2">
1606
  <div class="w-8 h-8 rounded-full bg-gradient-to-br from-gray-700 to-gray-600 flex items-center justify-center text-xs font-bold text-white uppercase border border-gray-500 shadow-sm relative">
 
1620
  </th>
1621
  `;
1622
  });
1623
+ thead.innerHTML = headerHtml;
1624
 
1625
+ // 2. Render Body (Challenges as Rows)
1626
+ if (cachedChallenges.length === 0) {
1627
+ tbody.innerHTML = '<tr><td colspan="100" class="text-center py-4">沒有題目資料</td></tr>';
1628
+ return;
1629
  }
1630
 
1631
  tbody.innerHTML = cachedChallenges.map((c, index) => {
1632
+ const colors = { beginner: 'cyan', intermediate: 'blue', advanced: 'purple' };
1633
+ const color = colors[c.level] || 'gray';
1634
 
1635
  // Build Row Cells (One per student)
1636
  const rowCells = students.map(student => {
 
1641
 
1642
  if (p) {
1643
  if (p.status === 'completed') {
1644
+ statusClass = 'bg-green-500/20 border-green-500/50 hover:bg-green-500/40 cursor-default shadow-[0_0_10px_rgba(34,197,94,0.1)]';
1645
+ content = '✅';
1646
+ // Action removed: Moved to prompt list view
1647
+ action = `title="完成 - 請點擊標題查看詳情"`;
1648
  } else if (p.status === 'started') {
1649
  // Check stuck
1650
  const startedAt = p.timestamp ? p.timestamp.toDate() : new Date();
1651
+ const now = new Date();
1652
+ const diffMins = (now - startedAt) / 1000 / 60;
1653
 
1654
  if (diffMins > 5) {
1655
+ statusClass = 'bg-red-900/50 border-red-500 animate-pulse cursor-help';
1656
+ content = '🆘';
1657
  } else {
1658
+ statusClass = 'bg-blue-600/20 border-blue-500';
1659
+ content = '🔵';
1660
  }
1661
  }
1662
  }
 
1670
  `;
1671
  }).join('');
1672
 
1673
+ // Row Header (Challenge Title)
1674
+ return `
1675
  <tr class="hover:bg-gray-800/50 transition-colors">
1676
  <td class="p-3 sticky left-0 bg-gray-900 z-10 border-r border-b border-gray-700 shadow-md">
1677
  <div class="flex items-center justify-between">
 
1695
  // Global scope for HTML access
1696
  window.showBroadcastModal = (userId, challengeId) => {
1697
  const student = currentStudents.find(s => s.id === userId);
1698
+ if (!student) return;
1699
 
1700
+ const p = student.progress?.[challengeId];
1701
+ if (!p) return;
1702
 
1703
  const challenge = cachedChallenges.find(c => c.id === challengeId);
1704
+ const title = challenge ? challenge.title : 'Unknown Challenge'; // Fallback
1705
 
1706
+ const modal = document.getElementById('broadcast-modal');
1707
+ const content = document.getElementById('broadcast-content');
1708
 
1709
+ document.getElementById('broadcast-avatar').textContent = student.nickname[0];
1710
+ document.getElementById('broadcast-author').textContent = student.nickname;
1711
+ document.getElementById('broadcast-challenge').textContent = title;
1712
+ // content is already just text, but let's be safe
1713
+ document.getElementById('broadcast-prompt').textContent = p.prompt || p.code || ''; // robust fallback
1714
 
1715
+ // Store IDs for actions
1716
+ modal.dataset.userId = userId;
1717
+ modal.dataset.challengeId = challengeId;
1718
 
1719
+ modal.classList.remove('hidden');
1720
  // Animation trigger
1721
  setTimeout(() => {
1722
+ content.classList.remove('scale-95', 'opacity-0');
1723
+ content.classList.add('opacity-100', 'scale-100');
1724
  }, 10);
1725
  };