diff --git "a/src/views/InstructorView.js" "b/src/views/InstructorView.js"
--- "a/src/views/InstructorView.js"
+++ "b/src/views/InstructorView.js"
@@ -696,811 +696,809 @@ export function setupInstructorEvents() {
alert('退回失敗: ' + e.message);
}
}
- }
-
-
-
- let roomUnsubscribe = null;
- let currentInstructor = null;
-
- // UI References
- const authModal = document.getElementById('auth-modal');
- // New Auth Elements
- const loginEmailInput = document.getElementById('login-email');
- const loginPasswordInput = document.getElementById('login-password');
- const loginBtn = document.getElementById('login-btn');
- const registerBtn = document.getElementById('register-btn');
- const authErrorMsg = document.getElementById('auth-error');
-
- // Remove old authBtn reference if present
- // const authBtn = document.getElementById('auth-btn');
-
- const navAdminBtn = document.getElementById('nav-admin-btn');
- const navInstBtn = document.getElementById('nav-instructors-btn');
- const createBtn = document.getElementById('create-room-btn');
-
- // Other UI
- const roomInfo = document.getElementById('room-info');
- const createContainer = document.getElementById('create-room-container');
- const dashboardContent = document.getElementById('dashboard-content');
- const displayRoomCode = document.getElementById('display-room-code');
- const groupPhotoBtn = document.getElementById('group-photo-btn');
- const snapshotBtn = document.getElementById('snapshot-btn');
- let isSnapshotting = false;
-
- // AI Settings UI
- const setupAiBtn = document.getElementById('setup-ai-btn');
- const aiSettingsModal = document.getElementById('ai-settings-modal');
- const geminiKeyInput = document.getElementById('gemini-api-key');
- const toggleStudentAi = document.getElementById('toggle-student-ai');
- const saveAiSettingsBtn = document.getElementById('save-ai-settings');
-
- // Permission Check Helper
- const checkPermissions = (instructor) => {
- if (!instructor) return;
-
- currentInstructor = instructor;
-
- // 1. Create Room Permission
- if (instructor.permissions?.includes('create_room')) {
- createBtn.classList.remove('hidden', 'opacity-50', 'cursor-not-allowed');
- createBtn.disabled = false;
- } else {
- createBtn.classList.add('opacity-50', 'cursor-not-allowed');
- createBtn.disabled = true;
- createBtn.title = "無此權限";
- }
-
- // 2. Add Question Permission (Admin Button)
- if (instructor.permissions?.includes('add_question')) {
- navAdminBtn.classList.remove('hidden');
- } else {
- navAdminBtn.classList.add('hidden');
- }
-
- // 3. Manage Instructors Permission
- if (instructor.permissions?.includes('manage_instructors')) {
- navInstBtn.classList.remove('hidden');
- } else {
- navInstBtn.classList.add('hidden');
- }
- };
- // Auto-Check Auth on Load (Restores session from Firebase)
- (async () => {
- try {
- const { onAuthStateChanged } = await import("https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js");
- const { auth } = await import("../services/firebase.js");
-
- onAuthStateChanged(auth, async (user) => {
- if (user) {
- // User is known to Firebase, check if they are an instructor
- const instructorData = await checkInstructorPermission(user);
- if (instructorData) {
- authModal.classList.add('hidden');
- checkPermissions(instructorData);
- localStorage.setItem('vibecoding_instructor_name', instructorData.name);
-
- // Auto-reconnect room if exists
- const savedRoom = localStorage.getItem('vibecoding_room_code');
- if (savedRoom) {
- // If we already have a room code, we could auto-setup dashboard state here if needed
- // For now, at least user is logged in
- const displayRoomCode = document.getElementById('display-room-code');
- if (displayRoomCode) displayRoomCode.textContent = savedRoom;
- }
- }
- }
- });
- } catch (e) {
- console.error("Auto-Auth Check Failed:", e);
- }
- })();
-
- // --- Global Dashboard Buttons (Always Active) ---
- const logoutBtn = document.getElementById('logout-btn');
- if (logoutBtn) {
- logoutBtn.addEventListener('click', async () => {
- if (confirm("確定要登出嗎?")) {
- try {
- await signOutUser();
- // Optional: clear local storage if strictly needed, but auth state change handles UI
- // localStorage.removeItem('vibecoding_instructor_name');
- window.location.reload();
- } catch (e) {
- console.error("Logout failed:", e);
- alert("登出失敗");
- }
- }
- });
}
-
- const btnOpenGallery = document.getElementById('btn-open-gallery');
- if (btnOpenGallery) {
- btnOpenGallery.addEventListener('click', () => {
- window.open('monster_preview.html', '_blank');
- });
+} let roomUnsubscribe = null;
+let currentInstructor = null;
+
+// UI References
+const authModal = document.getElementById('auth-modal');
+// New Auth Elements
+const loginEmailInput = document.getElementById('login-email');
+const loginPasswordInput = document.getElementById('login-password');
+const loginBtn = document.getElementById('login-btn');
+const registerBtn = document.getElementById('register-btn');
+const authErrorMsg = document.getElementById('auth-error');
+
+// Remove old authBtn reference if present
+// const authBtn = document.getElementById('auth-btn');
+
+const navAdminBtn = document.getElementById('nav-admin-btn');
+const navInstBtn = document.getElementById('nav-instructors-btn');
+const createBtn = document.getElementById('create-room-btn');
+
+// Other UI
+const roomInfo = document.getElementById('room-info');
+const createContainer = document.getElementById('create-room-container');
+const dashboardContent = document.getElementById('dashboard-content');
+const displayRoomCode = document.getElementById('display-room-code');
+const groupPhotoBtn = document.getElementById('group-photo-btn');
+const snapshotBtn = document.getElementById('snapshot-btn');
+let isSnapshotting = false;
+
+// AI Settings UI
+const setupAiBtn = document.getElementById('setup-ai-btn');
+const aiSettingsModal = document.getElementById('ai-settings-modal');
+const geminiKeyInput = document.getElementById('gemini-api-key');
+const toggleStudentAi = document.getElementById('toggle-student-ai');
+const saveAiSettingsBtn = document.getElementById('save-ai-settings');
+
+// Permission Check Helper
+const checkPermissions = (instructor) => {
+ if (!instructor) return;
+
+ currentInstructor = instructor;
+
+ // 1. Create Room Permission
+ if (instructor.permissions?.includes('create_room')) {
+ createBtn.classList.remove('hidden', 'opacity-50', 'cursor-not-allowed');
+ createBtn.disabled = false;
+ } else {
+ createBtn.classList.add('opacity-50', 'cursor-not-allowed');
+ createBtn.disabled = true;
+ createBtn.title = "無此權限";
}
- if (navInstBtn) {
- navInstBtn.addEventListener('click', async () => {
- const currentUser = auth.currentUser;
- const instData = await checkInstructorPermission(currentUser);
- if (instData?.permissions?.includes('manage_instructors')) {
- document.getElementById('instructor-modal').classList.remove('hidden');
- loadInstructorList();
- } else {
- alert("無此權限");
- }
- });
+ // 2. Add Question Permission (Admin Button)
+ if (instructor.permissions?.includes('add_question')) {
+ navAdminBtn.classList.remove('hidden');
+ } else {
+ navAdminBtn.classList.add('hidden');
}
- if (navAdminBtn) {
- navAdminBtn.addEventListener('click', () => {
- if (confirm("即將離開儀表板前往題目管理頁面,確定嗎?")) {
- window.location.hash = 'admin';
- }
- });
+ // 3. Manage Instructors Permission
+ if (instructor.permissions?.includes('manage_instructors')) {
+ navInstBtn.classList.remove('hidden');
+ } else {
+ navInstBtn.classList.add('hidden');
}
+};
- // Email/Password Auth Logic
- if (loginBtn && registerBtn) {
- // Login Handler
- loginBtn.addEventListener('click', async () => {
- const email = loginEmailInput.value;
- const password = loginPasswordInput.value;
-
- if (!email || !password) {
- authErrorMsg.textContent = "請輸入 Email 和密碼";
- authErrorMsg.classList.remove('hidden');
- return;
- }
-
- try {
- loginBtn.disabled = true;
- loginBtn.classList.add('opacity-50');
- authErrorMsg.classList.add('hidden');
+// Auto-Check Auth on Load (Restores session from Firebase)
+(async () => {
+ try {
+ const { onAuthStateChanged } = await import("https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js");
+ const { auth } = await import("../services/firebase.js");
- const user = await loginWithEmail(email, password);
+ onAuthStateChanged(auth, async (user) => {
+ if (user) {
+ // User is known to Firebase, check if they are an instructor
const instructorData = await checkInstructorPermission(user);
-
if (instructorData) {
authModal.classList.add('hidden');
checkPermissions(instructorData);
localStorage.setItem('vibecoding_instructor_name', instructorData.name);
- } else {
- authErrorMsg.textContent = "未授權的帳號 (Unauthorized Account)";
- authErrorMsg.classList.remove('hidden');
- await signOutUser();
- }
- } catch (error) {
- console.error(error);
- let msg = error.code || error.message;
- if (error.code === 'auth/invalid-credential' || error.code === 'auth/wrong-password' || error.code === 'auth/user-not-found') {
- msg = "帳號或密碼錯誤。";
+
+ // Auto-reconnect room if exists
+ const savedRoom = localStorage.getItem('vibecoding_room_code');
+ if (savedRoom) {
+ // If we already have a room code, we could auto-setup dashboard state here if needed
+ // For now, at least user is logged in
+ const displayRoomCode = document.getElementById('display-room-code');
+ if (displayRoomCode) displayRoomCode.textContent = savedRoom;
+ }
}
- authErrorMsg.textContent = "登入失敗: " + msg;
- authErrorMsg.classList.remove('hidden');
- } finally {
- loginBtn.disabled = false;
- loginBtn.classList.remove('opacity-50');
}
});
+ } catch (e) {
+ console.error("Auto-Auth Check Failed:", e);
+ }
+})();
- // Forgot Password Handler
- const forgotBtn = document.createElement('button');
- forgotBtn.textContent = "忘記密碼?";
- forgotBtn.className = "text-sm text-gray-400 hover:text-white mt-2 underline block mx-auto"; // Centered link
-
- // Insert after auth-error message or append to modal content?
- // Appending to the parent of Login Button seems best, or just below it.
- // The modal structure in index.html is needed to know exact placement.
- // Assuming loginBtn is inside a flex column form.
- loginBtn.parentNode.insertBefore(forgotBtn, loginBtn.nextSibling);
-
- forgotBtn.addEventListener('click', async () => {
- const email = loginEmailInput.value;
- if (!email) {
- authErrorMsg.textContent = "請先在上欄輸入 Email 以發送重設信";
- authErrorMsg.classList.remove('hidden');
- return;
- }
- if (!confirm(`確定要發送重設密碼信件至 ${email} 嗎?`)) return;
-
+// --- Global Dashboard Buttons (Always Active) ---
+const logoutBtn = document.getElementById('logout-btn');
+if (logoutBtn) {
+ logoutBtn.addEventListener('click', async () => {
+ if (confirm("確定要登出嗎?")) {
try {
- // Dynamically import to avoid top-level dependency if not needed
- const { resetPassword } = await import("../services/auth.js");
- await resetPassword(email);
- alert(`已發送重設密碼信件至 ${email},請查收信箱並依照指示重設密碼。`);
- authErrorMsg.classList.add('hidden');
+ await signOutUser();
+ // Optional: clear local storage if strictly needed, but auth state change handles UI
+ // localStorage.removeItem('vibecoding_instructor_name');
+ window.location.reload();
} catch (e) {
- console.error(e);
- let msg = e.message;
- if (e.code === 'auth/user-not-found') msg = "找不到此帳號,請確認 Email 是否正確。";
- authErrorMsg.textContent = "發送失敗: " + msg;
- authErrorMsg.classList.remove('hidden');
+ console.error("Logout failed:", e);
+ alert("登出失敗");
}
- });
+ }
+ });
+}
- // Register Handler
- registerBtn.addEventListener('click', async () => {
- const email = loginEmailInput.value;
- const password = loginPasswordInput.value;
+const btnOpenGallery = document.getElementById('btn-open-gallery');
+if (btnOpenGallery) {
+ btnOpenGallery.addEventListener('click', () => {
+ window.open('monster_preview.html', '_blank');
+ });
+}
- if (!email || !password) {
- authErrorMsg.textContent = "請輸入 Email 和密碼";
- authErrorMsg.classList.remove('hidden');
- return;
- }
+if (navInstBtn) {
+ navInstBtn.addEventListener('click', async () => {
+ const currentUser = auth.currentUser;
+ const instData = await checkInstructorPermission(currentUser);
+ if (instData?.permissions?.includes('manage_instructors')) {
+ document.getElementById('instructor-modal').classList.remove('hidden');
+ loadInstructorList();
+ } else {
+ alert("無此權限");
+ }
+ });
+}
- try {
- registerBtn.disabled = true;
- registerBtn.classList.add('opacity-50');
- authErrorMsg.classList.add('hidden');
+if (navAdminBtn) {
+ navAdminBtn.addEventListener('click', () => {
+ if (confirm("即將離開儀表板前往題目管理頁面,確定嗎?")) {
+ window.location.hash = 'admin';
+ }
+ });
+}
- // Try to create auth account
- const user = await registerWithEmail(email, password);
- // Check if this email is in our whitelist
- const instructorData = await checkInstructorPermission(user);
+// Email/Password Auth Logic
+if (loginBtn && registerBtn) {
+ // Login Handler
+ loginBtn.addEventListener('click', async () => {
+ const email = loginEmailInput.value;
+ const password = loginPasswordInput.value;
- if (instructorData) {
- authModal.classList.add('hidden');
- checkPermissions(instructorData);
- localStorage.setItem('vibecoding_instructor_name', instructorData.name);
- alert("註冊成功!");
- } else {
- // Auth created but not in whitelist
- authErrorMsg.textContent = "註冊成功,但您的 Email 未被列入講師名單。請聯繫管理員。";
- authErrorMsg.classList.remove('hidden');
- await signOutUser();
- }
- } catch (error) {
- console.error(error);
- let msg = error.code || error.message;
- if (error.code === 'auth/email-already-in-use') {
- msg = "此 Email 已被註冊,請直接登入。";
- }
- authErrorMsg.textContent = "註冊失敗: " + msg;
+ if (!email || !password) {
+ authErrorMsg.textContent = "請輸入 Email 和密碼";
+ authErrorMsg.classList.remove('hidden');
+ return;
+ }
+
+ try {
+ loginBtn.disabled = true;
+ loginBtn.classList.add('opacity-50');
+ authErrorMsg.classList.add('hidden');
+
+ const user = await loginWithEmail(email, password);
+ const instructorData = await checkInstructorPermission(user);
+
+ if (instructorData) {
+ authModal.classList.add('hidden');
+ checkPermissions(instructorData);
+ localStorage.setItem('vibecoding_instructor_name', instructorData.name);
+ } else {
+ authErrorMsg.textContent = "未授權的帳號 (Unauthorized Account)";
authErrorMsg.classList.remove('hidden');
- } finally {
- registerBtn.disabled = false;
- registerBtn.classList.remove('opacity-50');
+ await signOutUser();
}
- });
- }
+ } catch (error) {
+ console.error(error);
+ let msg = error.code || error.message;
+ if (error.code === 'auth/invalid-credential' || error.code === 'auth/wrong-password' || error.code === 'auth/user-not-found') {
+ msg = "帳號或密碼錯誤。";
+ }
+ authErrorMsg.textContent = "登入失敗: " + msg;
+ authErrorMsg.classList.remove('hidden');
+ } finally {
+ loginBtn.disabled = false;
+ loginBtn.classList.remove('opacity-50');
+ }
+ });
- // Create Room
- console.log("Checking createBtn:", createBtn);
- if (createBtn) {
- createBtn.addEventListener('click', async () => {
- // 4-Digit Room Code
- const roomCode = Math.floor(1000 + Math.random() * 9000).toString();
- try {
- // Ensure roomInfo is visible
- const roomInfo = document.getElementById('room-info');
- const displayRoomCode = document.getElementById('display-room-code');
- const createContainer = document.getElementById('create-room-container');
- const dashboardContent = document.getElementById('dashboard-content');
+ // Forgot Password Handler
+ const forgotBtn = document.createElement('button');
+ forgotBtn.textContent = "忘記密碼?";
+ forgotBtn.className = "text-sm text-gray-400 hover:text-white mt-2 underline block mx-auto"; // Centered link
+
+ // Insert after auth-error message or append to modal content?
+ // Appending to the parent of Login Button seems best, or just below it.
+ // The modal structure in index.html is needed to know exact placement.
+ // Assuming loginBtn is inside a flex column form.
+ loginBtn.parentNode.insertBefore(forgotBtn, loginBtn.nextSibling);
+
+ forgotBtn.addEventListener('click', async () => {
+ const email = loginEmailInput.value;
+ if (!email) {
+ authErrorMsg.textContent = "請先在上欄輸入 Email 以發送重設信";
+ authErrorMsg.classList.remove('hidden');
+ return;
+ }
+ if (!confirm(`確定要發送重設密碼信件至 ${email} 嗎?`)) return;
- await createRoom(roomCode, currentInstructor ? currentInstructor.name : 'Unknown');
+ try {
+ // Dynamically import to avoid top-level dependency if not needed
+ const { resetPassword } = await import("../services/auth.js");
+ await resetPassword(email);
+ alert(`已發送重設密碼信件至 ${email},請查收信箱並依照指示重設密碼。`);
+ authErrorMsg.classList.add('hidden');
+ } catch (e) {
+ console.error(e);
+ let msg = e.message;
+ if (e.code === 'auth/user-not-found') msg = "找不到此帳號,請確認 Email 是否正確。";
+ authErrorMsg.textContent = "發送失敗: " + msg;
+ authErrorMsg.classList.remove('hidden');
+ }
+ });
- // Trigger cleanup of old rooms
- cleanupOldRooms();
+ // Register Handler
+ registerBtn.addEventListener('click', async () => {
+ const email = loginEmailInput.value;
+ const password = loginPasswordInput.value;
- displayRoomCode.textContent = roomCode;
+ if (!email || !password) {
+ authErrorMsg.textContent = "請輸入 Email 和密碼";
+ authErrorMsg.classList.remove('hidden');
+ return;
+ }
- // Store in LocalStorage
- localStorage.setItem('vibecoding_room_code', roomCode);
- localStorage.setItem('vibecoding_is_host', 'true');
+ try {
+ registerBtn.disabled = true;
+ registerBtn.classList.add('opacity-50');
+ authErrorMsg.classList.add('hidden');
+
+ // Try to create auth account
+ const user = await registerWithEmail(email, password);
+ // Check if this email is in our whitelist
+ const instructorData = await checkInstructorPermission(user);
+
+ if (instructorData) {
+ authModal.classList.add('hidden');
+ checkPermissions(instructorData);
+ localStorage.setItem('vibecoding_instructor_name', instructorData.name);
+ alert("註冊成功!");
+ } else {
+ // Auth created but not in whitelist
+ authErrorMsg.textContent = "註冊成功,但您的 Email 未被列入講師名單。請聯繫管理員。";
+ authErrorMsg.classList.remove('hidden');
+ await signOutUser();
+ }
+ } catch (error) {
+ console.error(error);
+ let msg = error.code || error.message;
+ if (error.code === 'auth/email-already-in-use') {
+ msg = "此 Email 已被註冊,請直接登入。";
+ }
+ authErrorMsg.textContent = "註冊失敗: " + msg;
+ authErrorMsg.classList.remove('hidden');
+ } finally {
+ registerBtn.disabled = false;
+ registerBtn.classList.remove('opacity-50');
+ }
+ });
+}
- // UI Updates
- createContainer.classList.add('hidden');
- roomInfo.classList.remove('hidden');
- dashboardContent.classList.remove('hidden');
+// Create Room
+console.log("Checking createBtn:", createBtn);
+if (createBtn) {
+ createBtn.addEventListener('click', async () => {
+ // 4-Digit Room Code
+ const roomCode = Math.floor(1000 + Math.random() * 9000).toString();
+ try {
+ // Ensure roomInfo is visible
+ const roomInfo = document.getElementById('room-info');
+ const displayRoomCode = document.getElementById('display-room-code');
+ const createContainer = document.getElementById('create-room-container');
+ const dashboardContent = document.getElementById('dashboard-content');
- // Start Subscription
- subscribeToRoom(roomCode, (data) => {
- updateDashboard(data);
- });
+ await createRoom(roomCode, currentInstructor ? currentInstructor.name : 'Unknown');
- } catch (e) {
- console.error(e);
- alert("無法建立教室: " + e.message);
- }
- });
- }
+ // Trigger cleanup of old rooms
+ cleanupOldRooms();
- // AI Settings Logic
- if (setupAiBtn) {
- setupAiBtn.addEventListener('click', () => {
- const key = localStorage.getItem('vibecoding_gemini_key') || '';
- const studentEnabled = localStorage.getItem('vibecoding_student_ai_enabled') === 'true';
+ displayRoomCode.textContent = roomCode;
- if (geminiKeyInput) geminiKeyInput.value = key;
- if (toggleStudentAi) toggleStudentAi.checked = studentEnabled;
+ // Store in LocalStorage
+ localStorage.setItem('vibecoding_room_code', roomCode);
+ localStorage.setItem('vibecoding_is_host', 'true');
- if (aiSettingsModal) aiSettingsModal.classList.remove('hidden');
- });
- }
+ // UI Updates
+ createContainer.classList.add('hidden');
+ roomInfo.classList.remove('hidden');
+ dashboardContent.classList.remove('hidden');
- if (saveAiSettingsBtn) {
- saveAiSettingsBtn.addEventListener('click', async () => {
- const key = geminiKeyInput ? geminiKeyInput.value.trim() : '';
- const studentEnabled = toggleStudentAi ? toggleStudentAi.checked : false;
+ // Start Subscription
+ subscribeToRoom(roomCode, (data) => {
+ updateDashboard(data);
+ });
- if (key) {
- localStorage.setItem('vibecoding_gemini_key', key);
- } else {
- localStorage.removeItem('vibecoding_gemini_key');
- }
+ } catch (e) {
+ console.error(e);
+ alert("無法建立教室: " + e.message);
+ }
+ });
+}
- localStorage.setItem('vibecoding_student_ai_enabled', studentEnabled);
+// AI Settings Logic
+if (setupAiBtn) {
+ setupAiBtn.addEventListener('click', () => {
+ const key = localStorage.getItem('vibecoding_gemini_key') || '';
+ const studentEnabled = localStorage.getItem('vibecoding_student_ai_enabled') === 'true';
- // Update Firestore if in a room
- const roomCode = localStorage.getItem('vibecoding_instructor_room');
+ if (geminiKeyInput) geminiKeyInput.value = key;
+ if (toggleStudentAi) toggleStudentAi.checked = studentEnabled;
- if (roomCode) {
- try {
- const { getFirestore, doc, updateDoc } = await import("https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js");
- const db = getFirestore();
- await updateDoc(doc(db, "classrooms", roomCode), { ai_active: studentEnabled });
- } catch (e) {
- console.error("Failed to sync AI setting to room:", e);
- }
- }
+ if (aiSettingsModal) aiSettingsModal.classList.remove('hidden');
+ });
+}
- // Initialize Gemini (if key exists)
- if (key) {
- try {
- const { initGemini } = await import("../services/gemini.js");
- await initGemini(key);
- geminiEnabled = true; // Update module-level var
- alert("AI 設定已儲存並啟動!");
- } catch (e) {
- console.error(e);
- alert("AI 啟動失敗: " + e.message);
- }
- } else {
- geminiEnabled = false;
- alert("AI 設定已儲存 (停用)");
- }
+if (saveAiSettingsBtn) {
+ saveAiSettingsBtn.addEventListener('click', async () => {
+ const key = geminiKeyInput ? geminiKeyInput.value.trim() : '';
+ const studentEnabled = toggleStudentAi ? toggleStudentAi.checked : false;
- if (aiSettingsModal) aiSettingsModal.classList.add('hidden');
- });
- }
+ if (key) {
+ localStorage.setItem('vibecoding_gemini_key', key);
+ } else {
+ localStorage.removeItem('vibecoding_gemini_key');
+ }
- // Rejoin Room
- const rejoinBtn = document.getElementById('rejoin-room-btn');
- if (rejoinBtn) {
- rejoinBtn.addEventListener('click', async () => {
- const inputCode = document.getElementById('rejoin-room-code').value.trim().toUpperCase();
- if (!inputCode) return alert("請輸入代碼");
+ localStorage.setItem('vibecoding_student_ai_enabled', studentEnabled);
- try {
- // Ensure roomInfo is visible
- const roomInfo = document.getElementById('room-info');
- const displayRoomCode = document.getElementById('display-room-code');
- const createContainer = document.getElementById('create-room-container');
- const dashboardContent = document.getElementById('dashboard-content');
-
- // Check if room exists first (optional, subscribe handles it usually)
- displayRoomCode.textContent = inputCode;
- localStorage.setItem('vibecoding_room_code', inputCode);
-
- // UI Updates
- createContainer.classList.add('hidden');
- roomInfo.classList.remove('hidden');
- dashboardContent.classList.remove('hidden');
- document.getElementById('group-photo-btn').classList.remove('hidden');
-
- subscribeToRoom(inputCode, async (data) => {
- const users = Array.isArray(data) ? data : (data?.users ? Object.values(data.users) : []);
- currentStudents = users;
-
- // Render if function available
- if (typeof renderTransposedHeatmap === 'function') {
- renderTransposedHeatmap(users);
- }
+ // Update Firestore if in a room
+ const roomCode = localStorage.getItem('vibecoding_instructor_room');
- // Auto-Restore Room View if exists
- if (localStorage.getItem('vibecoding_gemini_key')) {
- try {
- const GeminiService = await import("../services/gemini.js?t=" + Date.now());
- const apiKey = localStorage.getItem('vibecoding_gemini_key');
- const success = await GeminiService.initGemini(apiKey);
- if (success) {
- geminiEnabled = true;
- // Check student toggle
- const studentEnabled = localStorage.getItem('vibecoding_student_ai_enabled') === 'true';
- if (studentEnabled) {
- // Update Firestore
- const code = localStorage.getItem('vibecoding_room_code');
- if (code) {
- import("https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js").then(({ doc, updateDoc, getFirestore }) => {
- const db = getFirestore();
- updateDoc(doc(db, "classrooms", code), { ai_active: true }).catch(console.error);
- });
- }
- }
- }
- } catch (e) { console.warn("Gemini auto-init failed", e); }
- }
- });
+ if (roomCode) {
+ try {
+ const { getFirestore, doc, updateDoc } = await import("https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js");
+ const db = getFirestore();
+ await updateDoc(doc(db, "classrooms", roomCode), { ai_active: studentEnabled });
+ } catch (e) {
+ console.error("Failed to sync AI setting to room:", e);
+ }
+ }
+ // Initialize Gemini (if key exists)
+ if (key) {
+ try {
+ const { initGemini } = await import("../services/gemini.js");
+ await initGemini(key);
+ geminiEnabled = true; // Update module-level var
+ alert("AI 設定已儲存並啟動!");
} catch (e) {
console.error(e);
- alert("加入失敗: " + e.message);
+ alert("AI 啟動失敗: " + e.message);
}
- });
- }
+ } else {
+ geminiEnabled = false;
+ alert("AI 設定已儲存 (停用)");
+ }
- // Leave Room
- const leaveBtn = document.getElementById('leave-room-btn');
- if (leaveBtn) {
- leaveBtn.addEventListener('click', () => {
+ if (aiSettingsModal) aiSettingsModal.classList.add('hidden');
+ });
+}
+
+// Rejoin Room
+const rejoinBtn = document.getElementById('rejoin-room-btn');
+if (rejoinBtn) {
+ rejoinBtn.addEventListener('click', async () => {
+ const inputCode = document.getElementById('rejoin-room-code').value.trim().toUpperCase();
+ if (!inputCode) return alert("請輸入代碼");
+
+ try {
+ // Ensure roomInfo is visible
const roomInfo = document.getElementById('room-info');
+ const displayRoomCode = document.getElementById('display-room-code');
const createContainer = document.getElementById('create-room-container');
const dashboardContent = document.getElementById('dashboard-content');
- const displayRoomCode = document.getElementById('display-room-code');
- localStorage.removeItem('vibecoding_room_code');
- localStorage.removeItem('vibecoding_is_host');
+ // Check if room exists first (optional, subscribe handles it usually)
+ displayRoomCode.textContent = inputCode;
+ localStorage.setItem('vibecoding_room_code', inputCode);
- displayRoomCode.textContent = '';
- roomInfo.classList.add('hidden');
- dashboardContent.classList.add('hidden');
- createContainer.classList.remove('hidden');
+ // UI Updates
+ createContainer.classList.add('hidden');
+ roomInfo.classList.remove('hidden');
+ dashboardContent.classList.remove('hidden');
+ document.getElementById('group-photo-btn').classList.remove('hidden');
- // Unsubscribe logic if needed, usually auto-handled by new subscription or page reload
- window.location.reload();
- });
- }
+ subscribeToRoom(inputCode, async (data) => {
+ const users = Array.isArray(data) ? data : (data?.users ? Object.values(data.users) : []);
+ currentStudents = users;
- // Nav to Admin
- if (navAdminBtn) {
- navAdminBtn.addEventListener('click', () => {
- localStorage.setItem('vibecoding_admin_referer', 'instructor');
- window.location.hash = '#admin';
- });
- }
+ // Render if function available
+ if (typeof renderTransposedHeatmap === 'function') {
+ renderTransposedHeatmap(users);
+ }
- // Handle Instructor Management
- navInstBtn.addEventListener('click', async () => {
- const modal = document.getElementById('instructor-modal');
- const listBody = document.getElementById('instructor-list-body');
+ // Auto-Restore Room View if exists
+ if (localStorage.getItem('vibecoding_gemini_key')) {
+ try {
+ const GeminiService = await import("../services/gemini.js?t=" + Date.now());
+ const apiKey = localStorage.getItem('vibecoding_gemini_key');
+ const success = await GeminiService.initGemini(apiKey);
+ if (success) {
+ geminiEnabled = true;
+ // Check student toggle
+ const studentEnabled = localStorage.getItem('vibecoding_student_ai_enabled') === 'true';
+ if (studentEnabled) {
+ // Update Firestore
+ const code = localStorage.getItem('vibecoding_room_code');
+ if (code) {
+ import("https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js").then(({ doc, updateDoc, getFirestore }) => {
+ const db = getFirestore();
+ updateDoc(doc(db, "classrooms", code), { ai_active: true }).catch(console.error);
+ });
+ }
+ }
+ }
+ } catch (e) { console.warn("Gemini auto-init failed", e); }
+ }
+ });
- // Load list
- const instructors = await getInstructors();
- listBody.innerHTML = instructors.map(inst => `
+ } catch (e) {
+ console.error(e);
+ alert("加入失敗: " + e.message);
+ }
+ });
+}
+
+// Leave Room
+const leaveBtn = document.getElementById('leave-room-btn');
+if (leaveBtn) {
+ leaveBtn.addEventListener('click', () => {
+ const roomInfo = document.getElementById('room-info');
+ const createContainer = document.getElementById('create-room-container');
+ const dashboardContent = document.getElementById('dashboard-content');
+ const displayRoomCode = document.getElementById('display-room-code');
+
+ localStorage.removeItem('vibecoding_room_code');
+ localStorage.removeItem('vibecoding_is_host');
+
+ displayRoomCode.textContent = '';
+ roomInfo.classList.add('hidden');
+ dashboardContent.classList.add('hidden');
+ createContainer.classList.remove('hidden');
+
+ // Unsubscribe logic if needed, usually auto-handled by new subscription or page reload
+ window.location.reload();
+ });
+}
+
+// Nav to Admin
+if (navAdminBtn) {
+ navAdminBtn.addEventListener('click', () => {
+ localStorage.setItem('vibecoding_admin_referer', 'instructor');
+ window.location.hash = '#admin';
+ });
+}
+
+// Handle Instructor Management
+navInstBtn.addEventListener('click', async () => {
+ const modal = document.getElementById('instructor-modal');
+ const listBody = document.getElementById('instructor-list-body');
+
+ // Load list
+ const instructors = await getInstructors();
+ listBody.innerHTML = instructors.map(inst => `
${inst.name}
${inst.email}
${inst.permissions?.map(p => {
- const map = { create_room: '開房', add_question: '題目', manage_instructors: '管理' };
- return `${map[p] || p} `;
- }).join('')}
+ const map = { create_room: '開房', add_question: '題目', manage_instructors: '管理' };
+ return `${map[p] || p} `;
+ }).join('')}
${inst.role === 'admin' ? '不可移除 ' :
- `移除 `}
+ `移除 `}
`).join('');
- modal.classList.remove('hidden');
- });
+ modal.classList.remove('hidden');
+});
- // Add New Instructor
- const addInstBtn = document.getElementById('btn-add-inst');
- if (addInstBtn) {
- addInstBtn.addEventListener('click', async () => {
- const email = document.getElementById('new-inst-email').value.trim();
- const name = document.getElementById('new-inst-name').value.trim();
+// Add New Instructor
+const addInstBtn = document.getElementById('btn-add-inst');
+if (addInstBtn) {
+ addInstBtn.addEventListener('click', async () => {
+ const email = document.getElementById('new-inst-email').value.trim();
+ const name = document.getElementById('new-inst-name').value.trim();
- if (!email || !name) return alert("請輸入完整資料");
+ if (!email || !name) return alert("請輸入完整資料");
- const perms = [];
- if (document.getElementById('perm-room').checked) perms.push('create_room');
- if (document.getElementById('perm-q').checked) perms.push('add_question');
- if (document.getElementById('perm-inst').checked) perms.push('manage_instructors');
+ const perms = [];
+ if (document.getElementById('perm-room').checked) perms.push('create_room');
+ if (document.getElementById('perm-q').checked) perms.push('add_question');
+ if (document.getElementById('perm-inst').checked) perms.push('manage_instructors');
- try {
- await addInstructor(email, name, perms);
- alert("新增成功");
- navInstBtn.click(); // Reload list
- document.getElementById('new-inst-email').value = '';
- document.getElementById('new-inst-name').value = '';
- } catch (e) {
- alert("新增失敗: " + e.message);
- }
- });
- }
+ try {
+ await addInstructor(email, name, perms);
+ alert("新增成功");
+ navInstBtn.click(); // Reload list
+ document.getElementById('new-inst-email').value = '';
+ document.getElementById('new-inst-name').value = '';
+ } catch (e) {
+ alert("新增失敗: " + e.message);
+ }
+ });
+}
- // Global helper for remove (hacky but works for simple onclick)
- window.removeInst = async (email) => {
- if (confirm(`確定移除 ${email}?`)) {
- try {
- await removeInstructor(email);
- navInstBtn.click(); // Reload
- } catch (e) {
- alert(e.message);
- }
+// Global helper for remove (hacky but works for simple onclick)
+window.removeInst = async (email) => {
+ if (confirm(`確定移除 ${email}?`)) {
+ try {
+ await removeInstructor(email);
+ navInstBtn.click(); // Reload
+ } catch (e) {
+ alert(e.message);
}
- };
+ }
+};
- // Auto Check Auth (Persistence)
- // We rely on Firebase Auth state observer instead of session storage for security?
- // Or we can just check if user is already signed in.
- import("../services/firebase.js").then(async ({ auth }) => {
- // Handle Redirect Result first
- try {
- console.log("Initializing Auth Check...");
- const { handleRedirectResult } = await import("../services/auth.js");
- const redirectUser = await handleRedirectResult();
- if (redirectUser) console.log("Redirect User Found:", redirectUser.email);
- } catch (e) { console.warn("Redirect check failed", e); }
-
- auth.onAuthStateChanged(async (user) => {
- console.log("Auth State Changed to:", user ? user.email : "Logged Out");
- if (user) {
- try {
- console.log("Checking permissions for:", user.email);
- const instructorData = await checkInstructorPermission(user);
- console.log("Permission Result:", instructorData);
-
- if (instructorData) {
- console.log("Hiding Modal and Setting Permissions...");
- authModal.classList.add('hidden');
- checkPermissions(instructorData);
-
- // Auto-Restore Room View if exists
- const savedRoomCode = localStorage.getItem('vibecoding_room_code');
- if (savedRoomCode) {
- console.log("Restoring Room Session:", savedRoomCode);
- const roomInfo = document.getElementById('room-info');
- const displayRoomCode = document.getElementById('display-room-code');
- const createContainer = document.getElementById('create-room-container');
- const dashboardContent = document.getElementById('dashboard-content');
-
- // Restore UI
- createContainer.classList.add('hidden');
- roomInfo.classList.remove('hidden');
- dashboardContent.classList.remove('hidden');
- displayRoomCode.textContent = savedRoomCode;
-
- // Re-subscribe locally using the existing updateDashboard logic if available,
- // or we need to redefine the callback here.
- // Since updateDashboard is inside createBtn scope, we can't mistakenly access it if it's not global.
- // Wait, updateDashboard IS inside createBtn scope. That's a problem.
- // We need to move updateDashboard out or duplicate the logic here.
- // Duplicating logic for robustness:
- subscribeToRoom(savedRoomCode, (data) => {
- const users = Array.isArray(data) ? data : (data?.users ? Object.values(data.users) : []);
- currentStudents = users;
- renderTransposedHeatmap(users);
- });
- }
+// Auto Check Auth (Persistence)
+// We rely on Firebase Auth state observer instead of session storage for security?
+// Or we can just check if user is already signed in.
+import("../services/firebase.js").then(async ({ auth }) => {
+ // Handle Redirect Result first
+ try {
+ console.log("Initializing Auth Check...");
+ const { handleRedirectResult } = await import("../services/auth.js");
+ const redirectUser = await handleRedirectResult();
+ if (redirectUser) console.log("Redirect User Found:", redirectUser.email);
+ } catch (e) { console.warn("Redirect check failed", e); }
+
+ auth.onAuthStateChanged(async (user) => {
+ console.log("Auth State Changed to:", user ? user.email : "Logged Out");
+ if (user) {
+ try {
+ console.log("Checking permissions for:", user.email);
+ const instructorData = await checkInstructorPermission(user);
+ console.log("Permission Result:", instructorData);
- } else {
- console.warn("User logged in but not an instructor.");
- // Show unauthorized message
- authErrorMsg.textContent = "此帳號無講師權限";
- authErrorMsg.classList.remove('hidden');
- authModal.classList.remove('hidden'); // Ensure modal stays up
+ if (instructorData) {
+ console.log("Hiding Modal and Setting Permissions...");
+ authModal.classList.add('hidden');
+ checkPermissions(instructorData);
+
+ // Auto-Restore Room View if exists
+ const savedRoomCode = localStorage.getItem('vibecoding_room_code');
+ if (savedRoomCode) {
+ console.log("Restoring Room Session:", savedRoomCode);
+ const roomInfo = document.getElementById('room-info');
+ const displayRoomCode = document.getElementById('display-room-code');
+ const createContainer = document.getElementById('create-room-container');
+ const dashboardContent = document.getElementById('dashboard-content');
+
+ // Restore UI
+ createContainer.classList.add('hidden');
+ roomInfo.classList.remove('hidden');
+ dashboardContent.classList.remove('hidden');
+ displayRoomCode.textContent = savedRoomCode;
+
+ // Re-subscribe locally using the existing updateDashboard logic if available,
+ // or we need to redefine the callback here.
+ // Since updateDashboard is inside createBtn scope, we can't mistakenly access it if it's not global.
+ // Wait, updateDashboard IS inside createBtn scope. That's a problem.
+ // We need to move updateDashboard out or duplicate the logic here.
+ // Duplicating logic for robustness:
+ subscribeToRoom(savedRoomCode, (data) => {
+ const users = Array.isArray(data) ? data : (data?.users ? Object.values(data.users) : []);
+ currentStudents = users;
+ renderTransposedHeatmap(users);
+ });
}
- } catch (e) {
- console.error("Permission Check Failed:", e);
- authErrorMsg.textContent = "權限檢查失敗: " + e.message;
+
+ } else {
+ console.warn("User logged in but not an instructor.");
+ // Show unauthorized message
+ authErrorMsg.textContent = "此帳號無講師權限";
authErrorMsg.classList.remove('hidden');
+ authModal.classList.remove('hidden'); // Ensure modal stays up
}
- } else {
- authModal.classList.remove('hidden');
- }
- });
- });
-
- // Define Kick Function globally (robust against auth flow)
- window.confirmKick = async (userId, nickname) => {
- if (confirm(`確定要踢出 ${nickname} 嗎?此動作無法復原。`)) {
- try {
- const { removeUser } = await import("../services/classroom.js");
- await removeUser(userId);
- // UI will update automatically via subscribeToRoom
} catch (e) {
- console.error("Kick failed:", e);
- alert("移除失敗");
+ console.error("Permission Check Failed:", e);
+ authErrorMsg.textContent = "權限檢查失敗: " + e.message;
+ authErrorMsg.classList.remove('hidden');
}
+ } else {
+ authModal.classList.remove('hidden');
}
- };
+ });
+});
+// Define Kick Function globally (robust against auth flow)
+window.confirmKick = async (userId, nickname) => {
+ if (confirm(`確定要踢出 ${nickname} 嗎?此動作無法復原。`)) {
+ try {
+ const { removeUser } = await import("../services/classroom.js");
+ await removeUser(userId);
+ // UI will update automatically via subscribeToRoom
+ } catch (e) {
+ console.error("Kick failed:", e);
+ alert("移除失敗");
+ }
+ }
+};
- const btnAddInst = document.getElementById('btn-add-inst');
- if (btnAddInst) {
- btnAddInst.addEventListener('click', async () => {
- const email = document.getElementById('new-inst-email').value.trim();
- const name = document.getElementById('new-inst-name').value.trim();
- const perms = [];
- if (document.getElementById('perm-room').checked) perms.push('create_room');
- if (document.getElementById('perm-q').checked) perms.push('add_question');
- if (document.getElementById('perm-inst').checked) perms.push('manage_instructors');
- if (!email || !name) {
- alert("請輸入 Email 和姓名");
- return;
- }
+const btnAddInst = document.getElementById('btn-add-inst');
+if (btnAddInst) {
+ btnAddInst.addEventListener('click', async () => {
+ const email = document.getElementById('new-inst-email').value.trim();
+ const name = document.getElementById('new-inst-name').value.trim();
+ const perms = [];
+ if (document.getElementById('perm-room').checked) perms.push('create_room');
+ if (document.getElementById('perm-q').checked) perms.push('add_question');
+ if (document.getElementById('perm-inst').checked) perms.push('manage_instructors');
- try {
- await addInstructor(email, name, perms);
- alert("新增成功");
- document.getElementById('new-inst-email').value = '';
- document.getElementById('new-inst-name').value = '';
- loadInstructorList();
- } catch (e) {
- console.error(e);
- alert("新增失敗: " + e.message);
- }
- });
- }
+ if (!email || !name) {
+ alert("請輸入 Email 和姓名");
+ return;
+ }
- window.removeInst = async (email) => {
- if (confirm(`確定要移除 ${email} 嗎?`)) {
- try {
- await removeInstructor(email);
- loadInstructorList();
- } catch (e) {
- console.error(e);
- alert("移除失敗");
- }
+ try {
+ await addInstructor(email, name, perms);
+ alert("新增成功");
+ document.getElementById('new-inst-email').value = '';
+ document.getElementById('new-inst-name').value = '';
+ loadInstructorList();
+ } catch (e) {
+ console.error(e);
+ alert("新增失敗: " + e.message);
}
- };
- // Snapshot Logic
- snapshotBtn.addEventListener('click', async () => {
- if (isSnapshotting || typeof htmlToImage === 'undefined') {
- if (typeof htmlToImage === 'undefined') alert("截圖元件尚未載入,請稍候再試");
- return;
+ });
+}
+
+window.removeInst = async (email) => {
+ if (confirm(`確定要移除 ${email} 嗎?`)) {
+ try {
+ await removeInstructor(email);
+ loadInstructorList();
+ } catch (e) {
+ console.error(e);
+ alert("移除失敗");
}
- isSnapshotting = true;
-
- const overlay = document.getElementById('snapshot-overlay');
- const countEl = document.getElementById('countdown-number');
- const container = document.getElementById('group-photo-container');
- const modal = document.getElementById('group-photo-modal');
-
- // Close button hide
- const closeBtn = modal.querySelector('button');
- if (closeBtn) closeBtn.style.opacity = '0';
- snapshotBtn.style.opacity = '0';
-
- overlay.classList.remove('hidden');
- overlay.classList.add('flex');
-
- // Countdown Sequence
- const runCountdown = (num) => new Promise(resolve => {
- countEl.textContent = num;
- countEl.style.transform = 'scale(1.5)';
- countEl.style.opacity = '1';
-
- // Animation reset
- requestAnimationFrame(() => {
- countEl.style.transition = 'all 0.5s ease-out';
- countEl.style.transform = 'scale(1)';
- countEl.style.opacity = '0.5';
- setTimeout(resolve, 1000);
- });
+ }
+};
+// Snapshot Logic
+snapshotBtn.addEventListener('click', async () => {
+ if (isSnapshotting || typeof htmlToImage === 'undefined') {
+ if (typeof htmlToImage === 'undefined') alert("截圖元件尚未載入,請稍候再試");
+ return;
+ }
+ isSnapshotting = true;
+
+ const overlay = document.getElementById('snapshot-overlay');
+ const countEl = document.getElementById('countdown-number');
+ const container = document.getElementById('group-photo-container');
+ const modal = document.getElementById('group-photo-modal');
+
+ // Close button hide
+ const closeBtn = modal.querySelector('button');
+ if (closeBtn) closeBtn.style.opacity = '0';
+ snapshotBtn.style.opacity = '0';
+
+ overlay.classList.remove('hidden');
+ overlay.classList.add('flex');
+
+ // Countdown Sequence
+ const runCountdown = (num) => new Promise(resolve => {
+ countEl.textContent = num;
+ countEl.style.transform = 'scale(1.5)';
+ countEl.style.opacity = '1';
+
+ // Animation reset
+ requestAnimationFrame(() => {
+ countEl.style.transition = 'all 0.5s ease-out';
+ countEl.style.transform = 'scale(1)';
+ countEl.style.opacity = '0.5';
+ setTimeout(resolve, 1000);
});
+ });
- await runCountdown(3);
- await runCountdown(2);
- await runCountdown(1);
-
- // Action!
- countEl.textContent = '';
- overlay.classList.add('hidden');
-
- // 1. Emojis Explosion
- const emojis = ['🤘', '✌️', '👍', '🫶', '😎', '🔥'];
- const cards = container.querySelectorAll('.group\\/card');
-
- cards.forEach(card => {
- // Find the monster image container
- const imgContainer = card.querySelector('.monster-img-container');
- if (!imgContainer) return;
-
- // Random Emoji
- const emoji = emojis[Math.floor(Math.random() * emojis.length)];
- const emojiEl = document.createElement('div');
- emojiEl.textContent = emoji;
- // Position: Top-Right of the *Image*, slightly overlapping
- emojiEl.className = 'absolute -top-2 -right-2 text-2xl animate-bounce z-50 drop-shadow-md transform rotate-12';
- emojiEl.style.animationDuration = '0.6s';
- imgContainer.appendChild(emojiEl);
-
- // Remove after 3s
- setTimeout(() => emojiEl.remove(), 3000);
- });
+ await runCountdown(3);
+ await runCountdown(2);
+ await runCountdown(1);
+
+ // Action!
+ countEl.textContent = '';
+ overlay.classList.add('hidden');
+
+ // 1. Emojis Explosion
+ const emojis = ['🤘', '✌️', '👍', '🫶', '😎', '🔥'];
+ const cards = container.querySelectorAll('.group\\/card');
+
+ cards.forEach(card => {
+ // Find the monster image container
+ const imgContainer = card.querySelector('.monster-img-container');
+ if (!imgContainer) return;
+
+ // Random Emoji
+ const emoji = emojis[Math.floor(Math.random() * emojis.length)];
+ const emojiEl = document.createElement('div');
+ emojiEl.textContent = emoji;
+ // Position: Top-Right of the *Image*, slightly overlapping
+ emojiEl.className = 'absolute -top-2 -right-2 text-2xl animate-bounce z-50 drop-shadow-md transform rotate-12';
+ emojiEl.style.animationDuration = '0.6s';
+ imgContainer.appendChild(emojiEl);
+
+ // Remove after 3s
+ setTimeout(() => emojiEl.remove(), 3000);
+ });
- // 2. Capture using html-to-image
- setTimeout(async () => {
- try {
- // Flash Effect
- const flash = document.createElement('div');
- flash.className = 'fixed inset-0 bg-white z-[100] transition-opacity duration-300 pointer-events-none';
- document.body.appendChild(flash);
- setTimeout(() => flash.style.opacity = '0', 50);
- setTimeout(() => flash.remove(), 300);
-
- // Use htmlToImage.toPng
- const dataUrl = await htmlToImage.toPng(container, {
- backgroundColor: '#111827',
- pixelRatio: 2,
- cacheBust: true,
- });
+ // 2. Capture using html-to-image
+ setTimeout(async () => {
+ try {
+ // Flash Effect
+ const flash = document.createElement('div');
+ flash.className = 'fixed inset-0 bg-white z-[100] transition-opacity duration-300 pointer-events-none';
+ document.body.appendChild(flash);
+ setTimeout(() => flash.style.opacity = '0', 50);
+ setTimeout(() => flash.remove(), 300);
+
+ // Use htmlToImage.toPng
+ const dataUrl = await htmlToImage.toPng(container, {
+ backgroundColor: '#111827',
+ pixelRatio: 2,
+ cacheBust: true,
+ });
- // Download
- const link = document.createElement('a');
- const dateStr = new Date().toISOString().slice(0, 10);
- link.download = `VIBE_Class_Photo_${dateStr}.png`;
- link.href = dataUrl;
- link.click();
+ // Download
+ const link = document.createElement('a');
+ const dateStr = new Date().toISOString().slice(0, 10);
+ link.download = `VIBE_Class_Photo_${dateStr}.png`;
+ link.href = dataUrl;
+ link.click();
- } catch (e) {
- console.error("Snapshot failed:", e);
- alert("截圖失敗 (請嘗試手動截圖/PrtSc)\n原因: " + e.message);
- } finally {
- // Restore UI
- if (closeBtn) closeBtn.style.opacity = '1';
- snapshotBtn.style.opacity = '1';
- isSnapshotting = false;
- }
- }, 600); // Slight delay for emojis to appear
- });
+ } catch (e) {
+ console.error("Snapshot failed:", e);
+ alert("截圖失敗 (請嘗試手動截圖/PrtSc)\n原因: " + e.message);
+ } finally {
+ // Restore UI
+ if (closeBtn) closeBtn.style.opacity = '1';
+ snapshotBtn.style.opacity = '1';
+ isSnapshotting = false;
+ }
+ }, 600); // Slight delay for emojis to appear
+});
- // Group Photo Logic
- groupPhotoBtn.addEventListener('click', () => {
- const modal = document.getElementById('group-photo-modal');
- const container = document.getElementById('group-photo-container');
- const dateEl = document.getElementById('photo-date');
+// Group Photo Logic
+groupPhotoBtn.addEventListener('click', () => {
+ const modal = document.getElementById('group-photo-modal');
+ const container = document.getElementById('group-photo-container');
+ const dateEl = document.getElementById('photo-date');
- // Update Date
- const now = new Date();
- dateEl.textContent = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} `;
+ // Update Date
+ const now = new Date();
+ dateEl.textContent = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} `;
- // Get saved name
- const savedName = localStorage.getItem('vibecoding_instructor_name') || '講師 (Instructor)';
+ // Get saved name
+ const savedName = localStorage.getItem('vibecoding_instructor_name') || '講師 (Instructor)';
- container.innerHTML = '';
+ container.innerHTML = '';
- // 1. Container for Relative Positioning with Custom Background
- const relativeContainer = document.createElement('div');
- 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';
- relativeContainer.style.backgroundImage = "url('assets/photobg.png')";
- container.appendChild(relativeContainer);
+ // 1. Container for Relative Positioning with Custom Background
+ const relativeContainer = document.createElement('div');
+ 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';
+ relativeContainer.style.backgroundImage = "url('assets/photobg.png')";
+ container.appendChild(relativeContainer);
- // Watermark (Bottom Right, High Z-Index, Gradient Text, Dark Backdrop)
- const watermark = document.createElement('div');
- 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';
+ // Watermark (Bottom Right, High Z-Index, Gradient Text, Dark Backdrop)
+ const watermark = document.createElement('div');
+ 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';
- const d = new Date();
- const dateStr = `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} `;
+ const d = new Date();
+ const dateStr = `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} `;
- watermark.innerHTML = `
+ watermark.innerHTML = `
${dateStr} VibeCoding 怪獸成長營
`;
- relativeContainer.appendChild(watermark);
+ relativeContainer.appendChild(watermark);
- // 2. Instructor Section (Absolute Center)
- const instructorSection = document.createElement('div');
- 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';
- instructorSection.innerHTML = `
+ // 2. Instructor Section (Absolute Center)
+ const instructorSection = document.createElement('div');
+ 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';
+ instructorSection.innerHTML = `
@@ -1519,98 +1517,98 @@ export function setupInstructorEvents() {
`;
- relativeContainer.appendChild(instructorSection);
-
- // Save name on change
- setTimeout(() => {
- const input = document.getElementById('instructor-name-input');
- if (input) {
- input.addEventListener('input', (e) => {
- localStorage.setItem('vibecoding_instructor_name', e.target.value);
- });
- }
- }, 100);
-
- // 3. Students Scatter
- if (currentStudents.length > 0) {
- // Randomize array to prevent fixed order bias
- const students = [...currentStudents].sort(() => Math.random() - 0.5);
- const total = students.length;
-
- // --- Dynamic Sizing Logic ---
- let sizeClass = 'w-20 h-20 md:w-24 md:h-24'; // Default (Size 100%)
- let scaleFactor = 1.0;
-
- if (total >= 40) {
- sizeClass = 'w-12 h-12 md:w-14 md:h-14'; // Size 60%
- scaleFactor = 0.6;
- } else if (total >= 20) {
- sizeClass = 'w-16 h-16 md:w-20 md:h-20'; // Size 80%
- scaleFactor = 0.8;
- }
+ relativeContainer.appendChild(instructorSection);
+
+ // Save name on change
+ setTimeout(() => {
+ const input = document.getElementById('instructor-name-input');
+ if (input) {
+ input.addEventListener('input', (e) => {
+ localStorage.setItem('vibecoding_instructor_name', e.target.value);
+ });
+ }
+ }, 100);
+
+ // 3. Students Scatter
+ if (currentStudents.length > 0) {
+ // Randomize array to prevent fixed order bias
+ const students = [...currentStudents].sort(() => Math.random() - 0.5);
+ const total = students.length;
+
+ // --- Dynamic Sizing Logic ---
+ let sizeClass = 'w-20 h-20 md:w-24 md:h-24'; // Default (Size 100%)
+ let scaleFactor = 1.0;
+
+ if (total >= 40) {
+ sizeClass = 'w-12 h-12 md:w-14 md:h-14'; // Size 60%
+ scaleFactor = 0.6;
+ } else if (total >= 20) {
+ sizeClass = 'w-16 h-16 md:w-20 md:h-20'; // Size 80%
+ scaleFactor = 0.8;
+ }
- students.forEach((s, index) => {
- const progressMap = s.progress || {};
- const totalLikes = Object.values(progressMap).reduce((acc, p) => acc + (p.likes || 0), 0);
- const totalCompleted = Object.values(progressMap).filter(p => p.status === 'completed').length;
-
- // FIXED: Prioritize stored ID if valid (same as StudentView logic)
- let monster;
- if (s.monster_id && s.monster_id !== 'Egg' && s.monster_id !== 'Unknown') {
- const stored = MONSTER_DEFS?.find(m => m.id === s.monster_id);
- if (stored) {
- monster = stored;
- } else {
- // Fallback if ID invalid
- monster = getNextMonster(s.monster_stage || 0, totalLikes, total, s.monster_id);
- }
+ students.forEach((s, index) => {
+ const progressMap = s.progress || {};
+ const totalLikes = Object.values(progressMap).reduce((acc, p) => acc + (p.likes || 0), 0);
+ const totalCompleted = Object.values(progressMap).filter(p => p.status === 'completed').length;
+
+ // FIXED: Prioritize stored ID if valid (same as StudentView logic)
+ let monster;
+ if (s.monster_id && s.monster_id !== 'Egg' && s.monster_id !== 'Unknown') {
+ const stored = MONSTER_DEFS?.find(m => m.id === s.monster_id);
+ if (stored) {
+ monster = stored;
} else {
+ // Fallback if ID invalid
monster = getNextMonster(s.monster_stage || 0, totalLikes, total, s.monster_id);
}
+ } else {
+ monster = getNextMonster(s.monster_stage || 0, totalLikes, total, s.monster_id);
+ }
- // --- FIXED: Even Arc Distribution (Safe Zone: 135 deg to 405 deg) ---
- // Avoids Bottom Right (0-90) and Bottom Center (90-120) entirely
- const minR = 220;
- // Avoids Bottom Right (0-90) and Bottom Center (90-120) entirely
+ // --- FIXED: Even Arc Distribution (Safe Zone: 135 deg to 405 deg) ---
+ // Avoids Bottom Right (0-90) and Bottom Center (90-120) entirely
+ const minR = 220;
+ // Avoids Bottom Right (0-90) and Bottom Center (90-120) entirely
- // Safe Arc Range: Starts at 135 deg (Bottom Left) -> Goes CW -> Ends at 405 deg (45 deg, Bottom Right)
- // Total Span = 270 degrees
- // If many students, use double ring
+ // Safe Arc Range: Starts at 135 deg (Bottom Left) -> Goes CW -> Ends at 405 deg (45 deg, Bottom Right)
+ // Total Span = 270 degrees
+ // If many students, use double ring
- const safeStartAngle = 135 * (Math.PI / 180);
- const safeSpan = 270 * (Math.PI / 180);
+ const safeStartAngle = 135 * (Math.PI / 180);
+ const safeSpan = 270 * (Math.PI / 180);
- // Distribute evenly
- // If only 1 student, put at top (270 deg / 4.71 rad)
- let finalAngle;
+ // Distribute evenly
+ // If only 1 student, put at top (270 deg / 4.71 rad)
+ let finalAngle;
- if (total === 1) {
- finalAngle = 270 * (Math.PI / 180);
- } else {
- const step = safeSpan / (total - 1);
- finalAngle = safeStartAngle + (step * index);
- }
+ if (total === 1) {
+ finalAngle = 270 * (Math.PI / 180);
+ } else {
+ const step = safeSpan / (total - 1);
+ finalAngle = safeStartAngle + (step * index);
+ }
- // Radius: Fixed base + slight variation for "natural" look (but not overlap causing)
- // Double ring logic if crowded
- let radius = minR + (index % 2) * 40; // Zigzag radius (220, 260, 220...) to minimize overlap
+ // Radius: Fixed base + slight variation for "natural" look (but not overlap causing)
+ // Double ring logic if crowded
+ let radius = minR + (index % 2) * 40; // Zigzag radius (220, 260, 220...) to minimize overlap
- // Reduce zigzag if few students
- if (total < 10) radius = minR + (index % 2) * 20;
+ // Reduce zigzag if few students
+ if (total < 10) radius = minR + (index % 2) * 20;
- const xOff = Math.cos(finalAngle) * radius;
- const yOff = Math.sin(finalAngle) * radius * 0.8;
+ const xOff = Math.cos(finalAngle) * radius;
+ const yOff = Math.sin(finalAngle) * radius * 0.8;
- const card = document.createElement('div');
- card.className = 'absolute flex flex-col items-center group/card z-10 hover:z-50 transition-all duration-500 cursor-move';
+ const card = document.createElement('div');
+ card.className = 'absolute flex flex-col items-center group/card z-10 hover:z-50 transition-all duration-500 cursor-move';
- card.style.left = `calc(50% + ${xOff}px)`;
- card.style.top = `calc(50% + ${yOff}px)`;
- card.style.transform = 'translate(-50%, -50%)';
+ card.style.left = `calc(50% + ${xOff}px)`;
+ card.style.top = `calc(50% + ${yOff}px)`;
+ card.style.transform = 'translate(-50%, -50%)';
- const floatDelay = Math.random() * 2;
+ const floatDelay = Math.random() * 2;
- card.innerHTML = `
+ card.innerHTML = `
${monster.name.split(' ')[1] || monster.name}
@@ -1634,327 +1632,327 @@ export function setupInstructorEvents() {
- `;
- relativeContainer.appendChild(card);
-
- // Enable Drag & Drop
- setupDraggable(card, relativeContainer);
- });
- }
-
- modal.classList.remove('hidden');
- });
-
- // Helper: Drag & Drop Logic
- function setupDraggable(el, container) {
- let isDragging = false;
- let startX, startY, initialLeft, initialTop;
-
- el.addEventListener('mousedown', (e) => {
- isDragging = true;
- startX = e.clientX;
- startY = e.clientY;
-
- // Disable transition during drag for responsiveness
- el.style.transition = 'none';
- el.style.zIndex = 100; // Bring to front
-
- // Convert current computed position to fixed pixels if relying on calc
- const rect = el.getBoundingClientRect();
- const containerRect = container.getBoundingClientRect();
-
- // Calculate position relative to container
- // The current transform is translate(-50%, -50%).
- // We want to set left/top such that the center remains under the mouse offset,
- // but for simplicity, let's just use current offsetLeft/Top if possible,
- // OR robustly recalculate from rects.
-
- // Current center point relative to container:
- const centerX = rect.left - containerRect.left + rect.width / 2;
- const centerY = rect.top - containerRect.top + rect.height / 2;
-
- // Set explicit pixel values replacing calc()
- el.style.left = `${centerX}px`;
- el.style.top = `${centerY}px`;
-
- initialLeft = centerX;
- initialTop = centerY;
- });
-
- window.addEventListener('mousemove', (e) => {
- if (!isDragging) return;
- e.preventDefault();
-
- const dx = e.clientX - startX;
- const dy = e.clientY - startY;
-
- el.style.left = `${initialLeft + dx}px`;
- el.style.top = `${initialTop + dy}px`;
- });
+ `;
+ relativeContainer.appendChild(card);
- window.addEventListener('mouseup', () => {
- if (isDragging) {
- isDragging = false;
- el.style.transition = ''; // Re-enable hover effects
- el.style.zIndex = ''; // Restore z-index rule (or let hover take over)
- }
+ // Enable Drag & Drop
+ setupDraggable(card, relativeContainer);
});
}
- // Add float animation style if not exists
- if (!document.getElementById('anim-float')) {
- const style = document.createElement('style');
- style.id = 'anim-float';
- style.innerHTML = `
- @keyframes float {
-
- 0 %, 100 % { transform: translateY(0) scale(1); }
- 50% {transform: translateY(-5px) scale(1.02); }
-}
-}
- `;
- document.head.appendChild(style);
- }
+ modal.classList.remove('hidden');
+});
- // Gallery Logic
- document.getElementById('btn-open-gallery').addEventListener('click', () => {
- window.open('monster_preview.html', '_blank');
- });
+// Helper: Drag & Drop Logic
+function setupDraggable(el, container) {
+ let isDragging = false;
+ let startX, startY, initialLeft, initialTop;
- // Logout Logic
- document.getElementById('logout-btn').addEventListener('click', async () => {
- if (confirm('確定要登出講師模式嗎? (將會回到首頁)')) {
- await signOutUser();
- sessionStorage.removeItem('vibecoding_instructor_in_room');
- sessionStorage.removeItem('vibecoding_admin_referer');
- window.location.hash = '';
- window.location.reload();
- }
- });
+ el.addEventListener('mousedown', (e) => {
+ isDragging = true;
+ startX = e.clientX;
+ startY = e.clientY;
- // Check Previous Session (Handled by onAuthStateChanged now)
- // if (sessionStorage.getItem('vibecoding_instructor_auth') === 'true') {
- // authModal.classList.add('hidden');
- // }
+ // Disable transition during drag for responsiveness
+ el.style.transition = 'none';
+ el.style.zIndex = 100; // Bring to front
- // Check Active Room State
- const activeRoom = sessionStorage.getItem('vibecoding_instructor_in_room');
- if (activeRoom === 'true' && savedRoomCode) {
- enterRoom(savedRoomCode);
- }
+ // Convert current computed position to fixed pixels if relying on calc
+ const rect = el.getBoundingClientRect();
+ const containerRect = container.getBoundingClientRect();
- // Module-level variable to track subscription (Moved to top)
+ // Calculate position relative to container
+ // The current transform is translate(-50%, -50%).
+ // We want to set left/top such that the center remains under the mouse offset,
+ // but for simplicity, let's just use current offsetLeft/Top if possible,
+ // OR robustly recalculate from rects.
- function enterRoom(roomCode) {
- createContainer.classList.add('hidden');
- roomInfo.classList.remove('hidden');
- dashboardContent.classList.remove('hidden');
- document.getElementById('group-photo-btn').classList.remove('hidden'); // Show photo button
- displayRoomCode.textContent = roomCode;
- localStorage.setItem('vibecoding_instructor_room', roomCode);
- sessionStorage.setItem('vibecoding_instructor_in_room', 'true');
+ // Current center point relative to container:
+ const centerX = rect.left - containerRect.left + rect.width / 2;
+ const centerY = rect.top - containerRect.top + rect.height / 2;
- // Unsubscribe previous if any
- if (roomUnsubscribe) roomUnsubscribe();
+ // Set explicit pixel values replacing calc()
+ el.style.left = `${centerX}px`;
+ el.style.top = `${centerY}px`;
- // Subscribe to updates
- roomUnsubscribe = subscribeToRoom(roomCode, (students) => {
- currentStudents = students;
- renderTransposedHeatmap(students);
- });
- }
+ initialLeft = centerX;
+ initialTop = centerY;
+ });
- // Leave Room Logic
- document.getElementById('leave-room-btn').addEventListener('click', () => {
- if (confirm('確定要離開目前教室嗎?(不會刪除教室資料,僅回到選擇介面)')) {
- // Unsubscribe
- if (roomUnsubscribe) {
- roomUnsubscribe();
- roomUnsubscribe = null;
- }
+ window.addEventListener('mousemove', (e) => {
+ if (!isDragging) return;
+ e.preventDefault();
- // UI Reset
- createContainer.classList.remove('hidden');
- roomInfo.classList.add('hidden');
- dashboardContent.classList.add('hidden');
- document.getElementById('group-photo-btn').classList.add('hidden'); // Hide photo button
+ const dx = e.clientX - startX;
+ const dy = e.clientY - startY;
- // Clear Data Display
- document.getElementById('heatmap-body').innerHTML = '
等待資料載入... ';
- document.getElementById('heatmap-header').innerHTML = '
學員 / 關卡 ';
+ el.style.left = `${initialLeft + dx}px`;
+ el.style.top = `${initialTop + dy}px`;
+ });
- // State Clear
- sessionStorage.removeItem('vibecoding_instructor_in_room');
- localStorage.removeItem('vibecoding_instructor_room');
+ window.addEventListener('mouseup', () => {
+ if (isDragging) {
+ isDragging = false;
+ el.style.transition = ''; // Re-enable hover effects
+ el.style.zIndex = ''; // Restore z-index rule (or let hover take over)
}
});
+}
- // Modal Events
- window.showBroadcastModal = (userId, challengeId) => {
- const modal = document.getElementById('broadcast-modal');
- const content = document.getElementById('broadcast-content');
-
- // Find Data
- const student = currentStudents.find(s => s.id === userId);
- if (!student) return alert('找不到學員資料');
-
- const p = student.progress ? student.progress[challengeId] : null;
- if (!p) return alert('找不到該作品資料');
+// Add float animation style if not exists
+if (!document.getElementById('anim-float')) {
+ const style = document.createElement('style');
+ style.id = 'anim-float';
+ style.innerHTML = `
+ @keyframes float {
- const challenge = cachedChallenges.find(c => c.id === challengeId);
- const title = challenge ? challenge.title : '未知題目';
+ 0 %, 100 % { transform: translateY(0) scale(1); }
+ 50% {transform: translateY(-5px) scale(1.02); }
+}
+}
+ `;
+ document.head.appendChild(style);
+}
- // Populate UI
- document.getElementById('broadcast-avatar').textContent = student.nickname[0] || '?';
- document.getElementById('broadcast-author').textContent = student.nickname;
- document.getElementById('broadcast-challenge').textContent = title;
- document.getElementById('broadcast-prompt').textContent = p.prompt || '(無內容)';
+// Gallery Logic
+document.getElementById('btn-open-gallery').addEventListener('click', () => {
+ window.open('monster_preview.html', '_blank');
+});
+
+// Logout Logic
+document.getElementById('logout-btn').addEventListener('click', async () => {
+ if (confirm('確定要登出講師模式嗎? (將會回到首頁)')) {
+ await signOutUser();
+ sessionStorage.removeItem('vibecoding_instructor_in_room');
+ sessionStorage.removeItem('vibecoding_admin_referer');
+ window.location.hash = '';
+ window.location.reload();
+ }
+});
- // Store IDs for Actions (Reject/BroadcastAll)
- modal.dataset.userId = userId;
- modal.dataset.challengeId = challengeId;
+// Check Previous Session (Handled by onAuthStateChanged now)
+// if (sessionStorage.getItem('vibecoding_instructor_auth') === 'true') {
+// authModal.classList.add('hidden');
+// }
- // Show
- modal.classList.remove('hidden');
- setTimeout(() => {
- content.classList.remove('scale-95', 'opacity-0');
- content.classList.add('opacity-100', 'scale-100');
- }, 10);
- };
+// Check Active Room State
+const activeRoom = sessionStorage.getItem('vibecoding_instructor_in_room');
+if (activeRoom === 'true' && savedRoomCode) {
+ enterRoom(savedRoomCode);
+}
- window.closeBroadcast = () => {
- const modal = document.getElementById('broadcast-modal');
- const content = document.getElementById('broadcast-content');
- content.classList.remove('opacity-100', 'scale-100');
- content.classList.add('scale-95', 'opacity-0');
- setTimeout(() => modal.classList.add('hidden'), 300);
- };
+// Module-level variable to track subscription (Moved to top)
- window.openStage = (prompt, author) => {
- document.getElementById('broadcast-content').classList.add('hidden');
- const stage = document.getElementById('stage-view');
- stage.classList.remove('hidden');
- document.getElementById('stage-prompt').textContent = cleanText(prompt || '');
- document.getElementById('stage-author').textContent = author;
- };
+function enterRoom(roomCode) {
+ createContainer.classList.add('hidden');
+ roomInfo.classList.remove('hidden');
+ dashboardContent.classList.remove('hidden');
+ document.getElementById('group-photo-btn').classList.remove('hidden'); // Show photo button
+ displayRoomCode.textContent = roomCode;
+ localStorage.setItem('vibecoding_instructor_room', roomCode);
+ sessionStorage.setItem('vibecoding_instructor_in_room', 'true');
- window.closeStage = () => {
- document.getElementById('stage-view').classList.add('hidden');
- document.getElementById('broadcast-content').classList.remove('hidden');
- };
+ // Unsubscribe previous if any
+ if (roomUnsubscribe) roomUnsubscribe();
- document.getElementById('btn-show-stage').addEventListener('click', () => {
- const prompt = document.getElementById('broadcast-prompt').textContent;
- const author = document.getElementById('broadcast-author').textContent;
- window.openStage(prompt, author);
+ // Subscribe to updates
+ roomUnsubscribe = subscribeToRoom(roomCode, (students) => {
+ currentStudents = students;
+ renderTransposedHeatmap(students);
});
+}
- // Reject Logic
- document.getElementById('btn-reject-task').addEventListener('click', async () => {
- if (!confirm('確定要退回此題目讓學員重做嗎?')) return;
+// Leave Room Logic
+document.getElementById('leave-room-btn').addEventListener('click', () => {
+ if (confirm('確定要離開目前教室嗎?(不會刪除教室資料,僅回到選擇介面)')) {
+ // Unsubscribe
+ if (roomUnsubscribe) {
+ roomUnsubscribe();
+ roomUnsubscribe = null;
+ }
- // We need student ID (userId) and Challenge ID.
- const modal = document.getElementById('broadcast-modal');
- const userId = modal.dataset.userId;
- const challengeId = modal.dataset.challengeId;
- const roomCode = localStorage.getItem('vibecoding_instructor_room');
+ // UI Reset
+ createContainer.classList.remove('hidden');
+ roomInfo.classList.add('hidden');
+ dashboardContent.classList.add('hidden');
+ document.getElementById('group-photo-btn').classList.add('hidden'); // Hide photo button
- console.log('Reject attempt:', { userId, challengeId, roomCode });
+ // Clear Data Display
+ document.getElementById('heatmap-body').innerHTML = '
等待資料載入... ';
+ document.getElementById('heatmap-header').innerHTML = '
學員 / 關卡 ';
- if (!userId || !challengeId) {
- alert('找不到學員或題目資料,請重新開啟作品');
- return;
- }
- if (!roomCode) {
- alert('未連接到教室,請先加入教室');
- return;
- }
+ // State Clear
+ sessionStorage.removeItem('vibecoding_instructor_in_room');
+ localStorage.removeItem('vibecoding_instructor_room');
+ }
+});
+
+// Modal Events
+window.showBroadcastModal = (userId, challengeId) => {
+ const modal = document.getElementById('broadcast-modal');
+ const content = document.getElementById('broadcast-content');
+
+ // Find Data
+ const student = currentStudents.find(s => s.id === userId);
+ if (!student) return alert('找不到學員資料');
+
+ const p = student.progress ? student.progress[challengeId] : null;
+ if (!p) return alert('找不到該作品資料');
+
+ const challenge = cachedChallenges.find(c => c.id === challengeId);
+ const title = challenge ? challenge.title : '未知題目';
+
+ // Populate UI
+ document.getElementById('broadcast-avatar').textContent = student.nickname[0] || '?';
+ document.getElementById('broadcast-author').textContent = student.nickname;
+ document.getElementById('broadcast-challenge').textContent = title;
+ document.getElementById('broadcast-prompt').textContent = p.prompt || '(無內容)';
+
+ // Store IDs for Actions (Reject/BroadcastAll)
+ modal.dataset.userId = userId;
+ modal.dataset.challengeId = challengeId;
+
+ // Show
+ modal.classList.remove('hidden');
+ setTimeout(() => {
+ content.classList.remove('scale-95', 'opacity-0');
+ content.classList.add('opacity-100', 'scale-100');
+ }, 10);
+};
+
+window.closeBroadcast = () => {
+ const modal = document.getElementById('broadcast-modal');
+ const content = document.getElementById('broadcast-content');
+ content.classList.remove('opacity-100', 'scale-100');
+ content.classList.add('scale-95', 'opacity-0');
+ setTimeout(() => modal.classList.add('hidden'), 300);
+};
+
+window.openStage = (prompt, author) => {
+ document.getElementById('broadcast-content').classList.add('hidden');
+ const stage = document.getElementById('stage-view');
+ stage.classList.remove('hidden');
+ document.getElementById('stage-prompt').textContent = cleanText(prompt || '');
+ document.getElementById('stage-author').textContent = author;
+};
+
+window.closeStage = () => {
+ document.getElementById('stage-view').classList.add('hidden');
+ document.getElementById('broadcast-content').classList.remove('hidden');
+};
+
+document.getElementById('btn-show-stage').addEventListener('click', () => {
+ const prompt = document.getElementById('broadcast-prompt').textContent;
+ const author = document.getElementById('broadcast-author').textContent;
+ window.openStage(prompt, author);
+});
+
+// Reject Logic
+document.getElementById('btn-reject-task').addEventListener('click', async () => {
+ if (!confirm('確定要退回此題目讓學員重做嗎?')) return;
+
+ // We need student ID (userId) and Challenge ID.
+ const modal = document.getElementById('broadcast-modal');
+ const userId = modal.dataset.userId;
+ const challengeId = modal.dataset.challengeId;
+ const roomCode = localStorage.getItem('vibecoding_instructor_room');
+
+ console.log('Reject attempt:', { userId, challengeId, roomCode });
+
+ if (!userId || !challengeId) {
+ alert('找不到學員或題目資料,��重新開啟作品');
+ return;
+ }
+ if (!roomCode) {
+ alert('未連接到教室,請先加入教室');
+ return;
+ }
- try {
- await resetProgress(userId, roomCode, challengeId);
- alert('已成功退回,學員將需要重新作答');
- // Close modal
- window.closeBroadcast();
- } catch (e) {
- console.error(e);
- alert('退回失敗: ' + e.message);
- }
- });
- // Prompt Viewer Logic
- window.openPromptList = (type, id, title) => {
- const modal = document.getElementById('prompt-list-modal');
- const container = document.getElementById('prompt-list-container');
- const titleEl = document.getElementById('prompt-list-title');
-
- // Set Global State for AI Analysis Scope
- if (type === 'challenge') {
- window.currentViewingChallengeId = id;
- } else {
- window.currentViewingChallengeId = null;
- }
+ try {
+ await resetProgress(userId, roomCode, challengeId);
+ alert('已成功退回,學員將需要重新作答');
+ // Close modal
+ window.closeBroadcast();
+ } catch (e) {
+ console.error(e);
+ alert('退回失敗: ' + e.message);
+ }
+});
+// Prompt Viewer Logic
+window.openPromptList = (type, id, title) => {
+ const modal = document.getElementById('prompt-list-modal');
+ const container = document.getElementById('prompt-list-container');
+ const titleEl = document.getElementById('prompt-list-title');
+
+ // Set Global State for AI Analysis Scope
+ if (type === 'challenge') {
+ window.currentViewingChallengeId = id;
+ } else {
+ window.currentViewingChallengeId = null;
+ }
- titleEl.textContent = type === 'student' ? `${title} 的所有提示詞` : `題目:${title} 的所有作品`;
+ titleEl.textContent = type === 'student' ? `${title} 的所有提示詞` : `題目:${title} 的所有作品`;
- // Reset Anonymous Toggle in List View
- const anonCheck = document.getElementById('list-anonymous-toggle');
- if (anonCheck) anonCheck.checked = false;
+ // Reset Anonymous Toggle in List View
+ const anonCheck = document.getElementById('list-anonymous-toggle');
+ if (anonCheck) anonCheck.checked = false;
- container.innerHTML = '';
- modal.classList.remove('hidden');
+ container.innerHTML = '';
+ modal.classList.remove('hidden');
+
+ // Collect Prompts
+ let prompts = [];
+ // Fix: Reset selection when opening new list to prevent cross-contamination
+ selectedPrompts = [];
+ updateCompareButton();
- // Collect Prompts
- let prompts = [];
- // Fix: Reset selection when opening new list to prevent cross-contamination
- selectedPrompts = [];
- updateCompareButton();
-
- if (type === 'student') {
- const student = currentStudents.find(s => s.id === id);
- if (student && student.progress) {
- prompts = Object.entries(student.progress)
- .filter(([_, p]) => p.status === 'completed' && p.prompt)
- .map(([challengeId, p]) => {
- const challenge = cachedChallenges.find(c => c.id === challengeId);
- return {
- id: `${student.id}_${challengeId}`,
- title: challenge ? challenge.title : '未知題目',
- prompt: p.prompt,
- author: student.nickname,
- studentId: student.id,
- challengeId: challengeId,
- time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : ''
- };
+ if (type === 'student') {
+ const student = currentStudents.find(s => s.id === id);
+ if (student && student.progress) {
+ prompts = Object.entries(student.progress)
+ .filter(([_, p]) => p.status === 'completed' && p.prompt)
+ .map(([challengeId, p]) => {
+ const challenge = cachedChallenges.find(c => c.id === challengeId);
+ return {
+ id: `${student.id}_${challengeId}`,
+ title: challenge ? challenge.title : '未知題目',
+ prompt: p.prompt,
+ author: student.nickname,
+ studentId: student.id,
+ challengeId: challengeId,
+ time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : ''
+ };
+ });
+ }
+ } else if (type === 'challenge') {
+ currentStudents.forEach(student => {
+ if (student.progress && student.progress[id]) {
+ const p = student.progress[id];
+ if (p.status === 'completed' && p.prompt) {
+ prompts.push({
+ id: `${student.id}_${id}`,
+ title: student.nickname, // When viewing challenge, title is student name
+ prompt: p.prompt,
+ author: student.nickname,
+ studentId: student.id,
+ challengeId: id,
+ time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : ''
});
- }
- } else if (type === 'challenge') {
- currentStudents.forEach(student => {
- if (student.progress && student.progress[id]) {
- const p = student.progress[id];
- if (p.status === 'completed' && p.prompt) {
- prompts.push({
- id: `${student.id}_${id}`,
- title: student.nickname, // When viewing challenge, title is student name
- prompt: p.prompt,
- author: student.nickname,
- studentId: student.id,
- challengeId: id,
- time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : ''
- });
- }
}
- });
- }
+ }
+ });
+ }
- if (prompts.length === 0) {
- container.innerHTML = '
無資料
';
- return;
- }
+ if (prompts.length === 0) {
+ container.innerHTML = '
無資料
';
+ return;
+ }
- prompts.forEach(p => {
- const card = document.createElement('div');
- // Reduced height (h-64 -> h-48) and padding, but larger text inside
- 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';
- card.innerHTML = `
+ prompts.forEach(p => {
+ const card = document.createElement('div');
+ // Reduced height (h-64 -> h-48) and padding, but larger text inside
+ 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';
+ card.innerHTML = `
${p.title}
@@ -1975,304 +1973,313 @@ export function setupInstructorEvents() {
`;
- container.appendChild(card);
- });
- };
-
- // Helper Actions
- window.confirmReset = async (userId, challengeId, title) => {
- if (confirm(`確定要退回 ${title} 嗎?此動作將清除學員目前的進度。`)) {
- // Unified top-level import
- const roomCode = localStorage.getItem('vibecoding_room_code') || localStorage.getItem('vibecoding_instructor_room'); // Fallback
-
- if (userId && challengeId) {
- try {
- alert("正在執行退回 (列表模式)...");
- // Use top-level import directly
- await resetProgress(userId, roomCode, challengeId);
- // 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)
- // For now, simple alert or auto-close
- alert("已退回");
- // close modal to refresh data context
- document.getElementById('prompt-list-modal').classList.add('hidden');
- } catch (e) {
- console.error(e);
- alert("退回失敗");
- }
- }
- }
- };
-
- window.broadcastPrompt = (userId, challengeId) => {
- window.showBroadcastModal(userId, challengeId);
- };
-
- // Selection Logic
- let selectedPrompts = []; // Stores IDs
+ container.appendChild(card);
+ });
+};
- window.handlePromptSelection = (checkbox) => {
- const id = checkbox.dataset.id;
+// Helper Actions
+window.confirmReset = async (userId, challengeId, title) => {
+ if (confirm(`確定要退回 ${title} 嗎?此動作將清除學員目前的進度。`)) {
+ // Unified top-level import
+ const roomCode = localStorage.getItem('vibecoding_room_code') || localStorage.getItem('vibecoding_instructor_room'); // Fallback
- if (checkbox.checked) {
- if (selectedPrompts.length >= 3) {
- checkbox.checked = false;
- alert('最多只能選擇 3 個提示詞進行比較');
- return;
+ if (userId && challengeId) {
+ try {
+ alert("正在執行退回 (列表模式)...");
+ // Use top-level import directly
+ await resetProgress(userId, roomCode, challengeId);
+ // 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)
+ // For now, simple alert or auto-close
+ alert("已退回");
+ // close modal to refresh data context
+ document.getElementById('prompt-list-modal').classList.add('hidden');
+ } catch (e) {
+ console.error(e);
+ alert("退回失敗");
}
- selectedPrompts.push(id);
- } else {
- selectedPrompts = selectedPrompts.filter(pid => pid !== id);
}
- updateCompareButton();
- };
+ }
+};
- function updateCompareButton() {
- const btn = document.getElementById('btn-compare-prompts');
- if (!btn) return;
+window.broadcastPrompt = (userId, challengeId) => {
+ window.showBroadcastModal(userId, challengeId);
+};
- const count = selectedPrompts.length;
- const span = btn.querySelector('span');
- if (span) span.textContent = `🔍 比較已選項目 (${count}/3)`;
+// Selection Logic
+let selectedPrompts = []; // Stores IDs
- if (count > 0) {
- btn.disabled = false;
- btn.classList.remove('opacity-50', 'cursor-not-allowed');
- } else {
- btn.disabled = true;
- btn.classList.add('opacity-50', 'cursor-not-allowed');
+window.handlePromptSelection = (checkbox) => {
+ const id = checkbox.dataset.id;
+
+ if (checkbox.checked) {
+ if (selectedPrompts.length >= 3) {
+ checkbox.checked = false;
+ alert('最多只能選擇 3 個提示詞進行比較');
+ return;
}
+ selectedPrompts.push(id);
+ } else {
+ selectedPrompts = selectedPrompts.filter(pid => pid !== id);
}
- // Comparison Logic
- const compareBtn = document.getElementById('btn-compare-prompts');
- if (compareBtn) {
- compareBtn.addEventListener('click', () => {
- const dataToCompare = [];
- selectedPrompts.forEach(fullId => {
- const lastUnderscore = fullId.lastIndexOf('_');
- const studentId = fullId.substring(0, lastUnderscore);
- const challengeId = fullId.substring(lastUnderscore + 1);
-
- const student = currentStudents.find(s => s.id === studentId);
- if (student && student.progress && student.progress[challengeId]) {
- const p = student.progress[challengeId];
- const challenge = cachedChallenges.find(c => c.id === challengeId);
-
- dataToCompare.push({
- title: challenge ? challenge.title : '未知',
- author: student.nickname,
- prompt: p.prompt,
- time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleTimeString() : ''
- });
- }
- });
+ updateCompareButton();
+};
+
+function updateCompareButton() {
+ const btn = document.getElementById('btn-compare-prompts');
+ if (!btn) return;
+
+ const count = selectedPrompts.length;
+ const span = btn.querySelector('span');
+ if (span) span.textContent = `🔍 比較已選項目 (${count}/3)`;
+
+ if (count > 0) {
+ btn.disabled = false;
+ btn.classList.remove('opacity-50', 'cursor-not-allowed');
+ } else {
+ btn.disabled = true;
+ btn.classList.add('opacity-50', 'cursor-not-allowed');
+ }
+}
+// Comparison Logic
+const compareBtn = document.getElementById('btn-compare-prompts');
+if (compareBtn) {
+ compareBtn.addEventListener('click', () => {
+ const dataToCompare = [];
+ selectedPrompts.forEach(fullId => {
+ const lastUnderscore = fullId.lastIndexOf('_');
+ const studentId = fullId.substring(0, lastUnderscore);
+ const challengeId = fullId.substring(lastUnderscore + 1);
+
+ const student = currentStudents.find(s => s.id === studentId);
+ if (student && student.progress && student.progress[challengeId]) {
+ const p = student.progress[challengeId];
+ const challenge = cachedChallenges.find(c => c.id === challengeId);
- const isAnon = document.getElementById('list-anonymous-toggle')?.checked || false;
- openComparisonView(dataToCompare, isAnon);
+ dataToCompare.push({
+ title: challenge ? challenge.title : '未知',
+ author: student.nickname,
+ prompt: p.prompt,
+ time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleTimeString() : ''
+ });
+ }
});
- }
+ const isAnon = document.getElementById('list-anonymous-toggle')?.checked || false;
+ openComparisonView(dataToCompare, isAnon);
+ });
+}
- // AI Analysis Logic
- const aiAnalyzeBtn = document.getElementById('btn-ai-analyze');
- if (aiAnalyzeBtn) {
- // Show button if key exists
- if (localStorage.getItem('vibecoding_gemini_key')) {
- aiAnalyzeBtn.classList.remove('hidden');
- }
- aiAnalyzeBtn.addEventListener('click', async () => {
- if (currentStudents.length === 0) return alert("無學生資料");
+// AI Analysis Logic
+const aiAnalyzeBtn = document.getElementById('btn-ai-analyze');
+if (aiAnalyzeBtn) {
+ // Show button if key exists
+ if (localStorage.getItem('vibecoding_gemini_key')) {
+ aiAnalyzeBtn.classList.remove('hidden');
+ }
- // Robust Challenge ID Detection
- let challengeId = window.currentViewingChallengeId;
+ aiAnalyzeBtn.addEventListener('click', async () => {
+ if (currentStudents.length === 0) return alert("無學生資料");
- // Fallback 1: Try to infer from first card
- if (!challengeId) {
- const firstCard = document.getElementById('prompt-list-container')?.firstElementChild;
- if (firstCard) {
- const fullId = firstCard.querySelector('input[type="checkbox"]')?.dataset.id;
- if (fullId && fullId.includes('_')) {
- challengeId = fullId.split('_').pop();
- window.currentViewingChallengeId = challengeId; // Set it for future stability
- }
+ // Robust Challenge ID Detection
+ let challengeId = window.currentViewingChallengeId;
+
+ // Fallback 1: Try to infer from first card
+ if (!challengeId) {
+ const firstCard = document.getElementById('prompt-list-container')?.firstElementChild;
+ if (firstCard) {
+ const fullId = firstCard.querySelector('input[type="checkbox"]')?.dataset.id;
+ if (fullId && fullId.includes('_')) {
+ challengeId = fullId.split('_').pop();
+ window.currentViewingChallengeId = challengeId; // Set it for future stability
}
}
+ }
- if (!challengeId) return alert("無法確認目前所在的題目,請重新開啟列表");
+ if (!challengeId) return alert("無法確認目前所在的題目,請重新開啟列表");
- // UI Loading State
- aiAnalyzeBtn.innerHTML = '⏳ AI 分析中 (Batch)... ';
- aiAnalyzeBtn.disabled = true;
- aiAnalyzeBtn.classList.add('animate-pulse');
+ // UI Loading State
+ aiAnalyzeBtn.innerHTML = '⏳ AI 分析中 (Batch)... ';
+ aiAnalyzeBtn.disabled = true;
+ aiAnalyzeBtn.classList.add('animate-pulse');
- try {
- const challenge = cachedChallenges.find(c => c.id === challengeId);
- const challengeDesc = challenge ? challenge.description : "No Description";
-
- // 1. Collect Valid Submissions for THIS challenge only
- const submissions = [];
- currentStudents.forEach(s => {
- const p = s.progress?.[challengeId];
- if (p && p.status === 'completed' && p.prompt && p.prompt.length > 3) {
- submissions.push({
- userId: s.id,
- prompt: p.prompt,
- nickname: s.nickname
- });
- }
- });
+ try {
+ const challenge = cachedChallenges.find(c => c.id === challengeId);
+ const challengeDesc = challenge ? challenge.description : "No Description";
+
+ // 1. Collect Valid Submissions for THIS challenge only
+ const submissions = [];
+ currentStudents.forEach(s => {
+ const p = s.progress?.[challengeId];
+ if (p && p.status === 'completed' && p.prompt && p.prompt.length > 3) {
+ submissions.push({
+ userId: s.id,
+ prompt: p.prompt,
+ nickname: s.nickname
+ });
+ }
+ });
- if (submissions.length === 0) throw new Error("沒有足夠的有效回答可供分析");
-
- // 2. Call Batch API
- const { evaluatePrompts } = await import("../services/gemini.js");
- const results = await evaluatePrompts(submissions, challengeDesc);
-
- // 3. Update UI with Badges
- const badgeColors = {
- "rough": "bg-gray-600 text-gray-200 border-gray-500", // 原石
- "precise": "bg-blue-600 text-blue-100 border-blue-400", // 精確
- "gentle": "bg-pink-600 text-pink-100 border-pink-400", // 有禮
- "creative": "bg-purple-600 text-purple-100 border-purple-400", // 創意
- "spam": "bg-red-900 text-red-200 border-red-700", // 無效
- "parrot": "bg-yellow-600 text-yellow-100 border-yellow-400" // 鸚鵡
- };
-
- const badgeLabels = {
- "rough": "🗿 原石",
- "precise": "🎯 精確",
- "gentle": "💖 有禮",
- "creative": "✨ 創意",
- "spam": "🗑️ 無效",
- "parrot": "🦜 鸚鵡"
- };
-
- let count = 0;
- Object.entries(results).forEach(([category, ids]) => {
- ids.forEach(userId => {
- // Find card
- const checkbox = document.querySelector(`input[data-id="${userId}_${challengeId}"]`);
- if (checkbox) {
- const card = checkbox.closest('.group\\/card');
- if (card) {
- const header = card.querySelector('h3')?.parentNode;
- if (header) {
- // Remove old badge
- const oldBadge = card.querySelector('.ai-badge');
- if (oldBadge) oldBadge.remove();
-
- const badge = document.createElement('span');
- badge.className = `ai-badge ml-2 text-xs px-2 py-0.5 rounded-full border ${badgeColors[category] || 'bg-gray-600'}`;
- badge.textContent = badgeLabels[category] || category;
- header.appendChild(badge);
- count++;
- }
+ if (submissions.length === 0) throw new Error("沒有足足夠的有效回答可供分析");
+
+ // 2. Call Batch API
+ const { initGemini, evaluatePrompts } = await import("../services/gemini.js");
+ if (localStorage.getItem('vibecoding_gemini_key')) {
+ await initGemini(localStorage.getItem('vibecoding_gemini_key'));
+ }
+ const results = await evaluatePrompts(submissions, challengeId);
+
+ // 3. Update UI with Badges
+ const badgeColors = {
+ "rough": "bg-gray-600 text-gray-200 border-gray-500", // 原石
+ "precise": "bg-blue-600 text-blue-100 border-blue-400", // 精確
+ "gentle": "bg-pink-600 text-pink-100 border-pink-400", // 有禮
+ "creative": "bg-purple-600 text-purple-100 border-purple-400", // 創意
+ "spam": "bg-red-900 text-red-200 border-red-700", // 無效
+ "parrot": "bg-yellow-600 text-yellow-100 border-yellow-400" // 鸚鵡
+ };
+
+ const badgeLabels = {
+ "rough": "🗿 原石",
+ "precise": "🎯 精確",
+ "gentle": "💖 有禮",
+ "creative": "✨ 創意",
+ "spam": "🗑️ 無效",
+ "parrot": "🦜 鸚鵡"
+ };
+
+ let count = 0;
+ Object.entries(results).forEach(([category, ids]) => {
+ ids.forEach(userId => {
+ // Find card
+ const checkbox = document.querySelector(`input[data-id="${userId}_${challengeId}"]`);
+ if (checkbox) {
+ const card = checkbox.closest('.group\\/card');
+ if (card) {
+ const header = card.querySelector('h3')?.parentNode;
+ if (header) {
+ // Remove old badge
+ const oldBadge = card.querySelector('.ai-badge');
+ if (oldBadge) oldBadge.remove();
+
+ const badge = document.createElement('span');
+ badge.className = `ai-badge ml-2 text-xs px-2 py-0.5 rounded-full border ${badgeColors[category] || 'bg-gray-600'}`;
+ badge.textContent = badgeLabels[category] || category;
+ header.appendChild(badge);
+ count++;
}
}
- });
+ }
});
+ });
- aiAnalyzeBtn.innerHTML = `✅ 完成分析 (${count}) `;
- setTimeout(() => {
- aiAnalyzeBtn.innerHTML = '✨ AI 選粹 (刷新) ';
- aiAnalyzeBtn.disabled = false;
- aiAnalyzeBtn.classList.remove('animate-pulse');
- }, 3000);
-
- } catch (e) {
- console.error(e);
- alert("分析失敗: " + e.message);
- aiAnalyzeBtn.innerHTML = '❌ 重試 ';
+ aiAnalyzeBtn.innerHTML = `✅ 完成分析 (${count}) `;
+ setTimeout(() => {
+ aiAnalyzeBtn.innerHTML = '✨ AI 選粹 (刷新) ';
aiAnalyzeBtn.disabled = false;
aiAnalyzeBtn.classList.remove('animate-pulse');
- }
- });
- if (!localStorage.getItem('vibecoding_gemini_key')) {
- alert("請先設定 Gemini API Key");
- return;
- }
- // 1. Open the list
- window.openPromptList('challenge', challengeId, challengeTitle);
+ }, 3000);
- // 2. Trigger analysis logic automatically after slight delay (to ensure DOM render)
- setTimeout(() => {
- const btn = document.getElementById('btn-ai-analyze');
- if (btn && !btn.disabled) {
- btn.click();
- } else {
- console.warn("AI Analyze button not found or disabled");
- }
- }, 300);
- };
-
- let isAnonymous = false;
-
- window.toggleAnonymous = (btn) => {
- isAnonymous = !isAnonymous;
- btn.textContent = isAnonymous ? '🙈 顯示姓名' : '👀 隱藏姓名';
- btn.classList.toggle('bg-gray-700');
- btn.classList.toggle('bg-purple-700');
-
- // Update DOM
- document.querySelectorAll('.comparison-author').forEach(el => {
- if (isAnonymous) {
- el.dataset.original = el.textContent;
- el.textContent = '學員';
- el.classList.add('blur-sm'); // Optional Effect
- setTimeout(() => el.classList.remove('blur-sm'), 300);
- } else {
- if (el.dataset.original) el.textContent = el.dataset.original;
- }
- });
- };
- window.openComparisonView = (items, initialAnonymous = false) => {
- const modal = document.getElementById('comparison-modal');
- const grid = document.getElementById('comparison-grid');
+ } catch (e) {
+ console.error(e);
+ alert("分析失敗: " + e.message);
+ aiAnalyzeBtn.innerHTML = '❌ 重試 ';
+ aiAnalyzeBtn.disabled = false;
+ aiAnalyzeBtn.classList.remove('animate-pulse');
+ }
+ });
+}
- // Apply Anonymous State
- isAnonymous = initialAnonymous;
- const anonBtn = document.getElementById('btn-anonymous-toggle');
- // Update Toggle UI to match state
- if (anonBtn) {
- if (isAnonymous) {
- anonBtn.textContent = '🙈 顯示姓名';
- anonBtn.classList.add('bg-purple-700');
- anonBtn.classList.remove('bg-gray-700');
- } else {
- anonBtn.textContent = '👀 隱藏姓名';
- anonBtn.classList.remove('bg-purple-700');
- anonBtn.classList.add('bg-gray-700');
- }
+// Direct Heatmap AI Analysis Link
+window.analyzeChallenge = (challengeId, challengeTitle) => {
+ if (!localStorage.getItem('vibecoding_gemini_key')) {
+ alert("請先設定 Gemini API Key");
+ return;
+ }
+ // 1. Open the list
+ window.openPromptList('challenge', challengeId, challengeTitle);
+
+ // 2. Trigger analysis logic automatically after slight delay (to ensure DOM render)
+ setTimeout(() => {
+ const btn = document.getElementById('btn-ai-analyze');
+ if (btn && !btn.disabled) {
+ btn.click();
+ } else {
+ console.warn("AI Analyze button not found or disabled");
+ }
+ }, 300);
+};
+
+let isAnonymous = false;
+
+window.toggleAnonymous = (btn) => {
+ isAnonymous = !isAnonymous;
+ btn.textContent = isAnonymous ? '🙈 顯示姓名' : '👀 隱藏姓名';
+ btn.classList.toggle('bg-gray-700');
+ btn.classList.toggle('bg-purple-700');
+
+ // Update DOM
+ document.querySelectorAll('.comparison-author').forEach(el => {
+ if (isAnonymous) {
+ el.dataset.original = el.textContent;
+ el.textContent = '學員';
+ el.classList.add('blur-sm'); // Optional Effect
+ setTimeout(() => el.classList.remove('blur-sm'), 300);
+ } else {
+ if (el.dataset.original) el.textContent = el.dataset.original;
}
+ });
+};
+
+window.openComparisonView = (items, initialAnonymous = false) => {
+ const modal = document.getElementById('comparison-modal');
+ const grid = document.getElementById('comparison-grid');
+
+ // Apply Anonymous State
+ isAnonymous = initialAnonymous;
+ const anonBtn = document.getElementById('btn-anonymous-toggle');
+
+ // Update Toggle UI to match state
+ if (anonBtn) {
+ if (isAnonymous) {
+ anonBtn.textContent = '🙈 顯示姓名';
+ anonBtn.classList.add('bg-purple-700');
+ anonBtn.classList.remove('bg-gray-700');
+ } else {
+ anonBtn.textContent = '👀 隱藏姓名';
+ anonBtn.classList.remove('bg-purple-700');
+ anonBtn.classList.add('bg-gray-700');
+ }
+ }
- // Setup Grid Rows (Vertical Stacking)
- let rowClass = 'grid-rows-1';
- if (items.length === 2) rowClass = 'grid-rows-2';
- if (items.length === 3) rowClass = 'grid-rows-3';
-
- grid.className = `absolute inset-0 grid ${rowClass} gap-0 divide-y divide-gray-600`;
- grid.innerHTML = '';
-
- items.forEach(item => {
- const col = document.createElement('div');
- // Check overflow-hidden to keep it contained, use flex-row for compact header + content
- col.className = 'flex flex-row h-full bg-gray-900 p-4 overflow-hidden';
-
- // Logic for anonymous
- let displayAuthor = item.author;
- let blurClass = '';
-
- if (isAnonymous) {
- displayAuthor = '學員';
- blurClass = 'blur-sm'; // Initial blur
- // Auto remove blur after delay if needed, or keep it?
- // Toggle logic removes it after delay. But initial render should probably just be static '學員' or blurred.
- // The toggle logic uses dataset.original. We need to set it here too.
- }
+ // Setup Grid Rows (Vertical Stacking)
+ let rowClass = 'grid-rows-1';
+ if (items.length === 2) rowClass = 'grid-rows-2';
+ if (items.length === 3) rowClass = 'grid-rows-3';
+
+ grid.className = `absolute inset-0 grid ${rowClass} gap-0 divide-y divide-gray-600`;
+ grid.innerHTML = '';
+
+ items.forEach(item => {
+ const col = document.createElement('div');
+ // Check overflow-hidden to keep it contained, use flex-row for compact header + content
+ col.className = 'flex flex-row h-full bg-gray-900 p-4 overflow-hidden';
+
+ // Logic for anonymous
+ let displayAuthor = item.author;
+ let blurClass = '';
+
+ if (isAnonymous) {
+ displayAuthor = '學員';
+ blurClass = 'blur-sm'; // Initial blur
+ // Auto remove blur after delay if needed, or keep it?
+ // Toggle logic removes it after delay. But initial render should probably just be static '學員' or blurred.
+ // The toggle logic uses dataset.original. We need to set it here too.
+ }
- col.innerHTML = `
+ col.innerHTML = `
${displayAuthor}
${item.title}
@@ -2281,222 +2288,222 @@ export function setupInstructorEvents() {
${cleanText(item.prompt)}
`;
- grid.appendChild(col);
-
- // If blurred, remove blur after animation purely for effect, or keep?
- // User intention "Hidden Name" usually means "Replaced by generic name".
- // The blur effect in toggle logic was transient.
- // If we want persistent anonymity, just "學員" is enough.
- // The existing toggle logic adds 'blur-sm' then removes it in 300ms.
- // We should replicate that effect if we want consistency, or just skip blur on init.
- if (isAnonymous) {
- const el = col.querySelector('.comparison-author');
- setTimeout(() => el.classList.remove('blur-sm'), 300);
- }
- });
-
- document.getElementById('prompt-list-modal').classList.add('hidden');
- modal.classList.remove('hidden');
+ grid.appendChild(col);
+
+ // If blurred, remove blur after animation purely for effect, or keep?
+ // User intention "Hidden Name" usually means "Replaced by generic name".
+ // The blur effect in toggle logic was transient.
+ // If we want persistent anonymity, just "學員" is enough.
+ // The existing toggle logic adds 'blur-sm' then removes it in 300ms.
+ // We should replicate that effect if we want consistency, or just skip blur on init.
+ if (isAnonymous) {
+ const el = col.querySelector('.comparison-author');
+ setTimeout(() => el.classList.remove('blur-sm'), 300);
+ }
+ });
- // Init Canvas (Phase 3)
- setTimeout(setupCanvas, 100);
+ document.getElementById('prompt-list-modal').classList.add('hidden');
+ modal.classList.remove('hidden');
+
+ // Init Canvas (Phase 3)
+ setTimeout(setupCanvas, 100);
+};
+
+window.closeComparison = () => {
+ document.getElementById('comparison-modal').classList.add('hidden');
+ clearCanvas();
+};
+
+// --- Phase 3 & 6: Annotation Tools ---
+let canvas, ctx;
+let isDrawing = false;
+let currentPenColor = '#ef4444'; // Red default
+let currentLineWidth = 3;
+let currentMode = 'source-over'; // 'source-over' (Pen) or 'destination-out' (Eraser)
+
+window.setupCanvas = () => {
+ canvas = document.getElementById('annotation-canvas');
+ const container = document.getElementById('comparison-container');
+ if (!canvas || !container) return;
+
+ ctx = canvas.getContext('2d');
+
+ // Resize
+ const resize = () => {
+ canvas.width = container.clientWidth;
+ canvas.height = container.clientHeight;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ ctx.strokeStyle = currentPenColor;
+ ctx.lineWidth = currentLineWidth;
+ ctx.globalCompositeOperation = currentMode;
};
+ resize();
+ window.addEventListener('resize', resize);
+
+ // Init Size UI & Cursor
+ updateSizeBtnUI();
+ updateCursorStyle();
+
+ // Cursor Logic
+ const cursor = document.getElementById('tool-cursor');
+
+ canvas.addEventListener('mouseenter', () => cursor.classList.remove('hidden'));
+ canvas.addEventListener('mouseleave', () => cursor.classList.add('hidden'));
+ canvas.addEventListener('mousemove', (e) => {
+ const { x, y } = getPos(e);
+ cursor.style.left = `${x}px`;
+ cursor.style.top = `${y}px`;
+ });
- window.closeComparison = () => {
- document.getElementById('comparison-modal').classList.add('hidden');
- clearCanvas();
- };
+ // Drawing Events
+ const start = (e) => {
+ isDrawing = true;
+ ctx.beginPath();
- // --- Phase 3 & 6: Annotation Tools ---
- let canvas, ctx;
- let isDrawing = false;
- let currentPenColor = '#ef4444'; // Red default
- let currentLineWidth = 3;
- let currentMode = 'source-over'; // 'source-over' (Pen) or 'destination-out' (Eraser)
-
- window.setupCanvas = () => {
- canvas = document.getElementById('annotation-canvas');
- const container = document.getElementById('comparison-container');
- if (!canvas || !container) return;
-
- ctx = canvas.getContext('2d');
-
- // Resize
- const resize = () => {
- canvas.width = container.clientWidth;
- canvas.height = container.clientHeight;
- ctx.lineCap = 'round';
- ctx.lineJoin = 'round';
- ctx.strokeStyle = currentPenColor;
- ctx.lineWidth = currentLineWidth;
- ctx.globalCompositeOperation = currentMode;
- };
- resize();
- window.addEventListener('resize', resize);
-
- // Init Size UI & Cursor
- updateSizeBtnUI();
- updateCursorStyle();
-
- // Cursor Logic
- const cursor = document.getElementById('tool-cursor');
-
- canvas.addEventListener('mouseenter', () => cursor.classList.remove('hidden'));
- canvas.addEventListener('mouseleave', () => cursor.classList.add('hidden'));
- canvas.addEventListener('mousemove', (e) => {
- const { x, y } = getPos(e);
- cursor.style.left = `${x}px`;
- cursor.style.top = `${y}px`;
- });
+ // Re-apply settings (state might change)
+ ctx.globalCompositeOperation = currentMode;
+ ctx.strokeStyle = currentPenColor;
+ ctx.lineWidth = currentLineWidth;
- // Drawing Events
- const start = (e) => {
- isDrawing = true;
- ctx.beginPath();
-
- // Re-apply settings (state might change)
- ctx.globalCompositeOperation = currentMode;
- ctx.strokeStyle = currentPenColor;
- ctx.lineWidth = currentLineWidth;
-
- const { x, y } = getPos(e);
- ctx.moveTo(x, y);
- };
-
- const move = (e) => {
- if (!isDrawing) return;
- const { x, y } = getPos(e);
- ctx.lineTo(x, y);
- ctx.stroke();
- };
-
- const end = () => {
- isDrawing = false;
- };
-
- canvas.onmousedown = start;
- canvas.onmousemove = move;
- canvas.onmouseup = end;
- canvas.onmouseleave = end;
-
- // Touch support
- canvas.ontouchstart = (e) => { e.preventDefault(); start(e.touches[0]); };
- canvas.ontouchmove = (e) => { e.preventDefault(); move(e.touches[0]); };
- canvas.ontouchend = (e) => { e.preventDefault(); end(); };
+ const { x, y } = getPos(e);
+ ctx.moveTo(x, y);
};
- function getPos(e) {
- const rect = canvas.getBoundingClientRect();
- return {
- x: e.clientX - rect.left,
- y: e.clientY - rect.top
- };
- }
-
- // Unified Tool Handler
- window.setPenTool = (tool, color, btn) => {
- // UI Update
- document.querySelectorAll('.annotation-tool').forEach(b => {
- b.classList.remove('ring-white');
- b.classList.add('ring-transparent');
- });
- btn.classList.remove('ring-transparent');
- btn.classList.add('ring-white');
-
- if (tool === 'eraser') {
- currentMode = 'destination-out';
- // Force larger eraser size (e.g., 3x current size or fixed large)
- // We'll multiply current selected size by 4 for better UX
- const multiplier = 4;
- // Store original explicitly if needed, but currentLineWidth is global.
- // We should dynamically adjust context lineWidth during draw, or just hack it here.
- // Hack: If we change currentLineWidth here, the UI size buttons might look wrong.
- // Better: Update cursor style only? No, actual draw needs it.
- // Let's set a separate 'actualWidth' used in draw, OR just change currentLineWidth temporarily?
- // Simpler: Just change it. When user clicks size button, it resets.
- // But if user clicks Pen back? We need to restore.
- // Let's rely on setPenTool being called with color.
- // When "Pen" is clicked, we usually don't call setPenTool with a saved size...
- // Actually, let's just use a large default for eraser, and keep currentLineWidth for Pen.
- // We need to change how draw() uses the width.
- // BUT, since we don't want to touch draw() deep inside:
- // We will hijack currentLineWidth.
- if (!window.savedPenWidth) window.savedPenWidth = currentLineWidth;
- currentLineWidth = window.savedPenWidth * 4;
- } else {
- currentMode = 'source-over';
- currentPenColor = color;
- // Restore pen width
- if (window.savedPenWidth) {
- currentLineWidth = window.savedPenWidth;
- window.savedPenWidth = null;
- }
- }
- updateCursorStyle();
+ const move = (e) => {
+ if (!isDrawing) return;
+ const { x, y } = getPos(e);
+ ctx.lineTo(x, y);
+ ctx.stroke();
};
- // Size Handler
- window.setPenSize = (size, btn) => {
- currentLineWidth = size;
- updateSizeBtnUI();
- updateCursorStyle();
+ const end = () => {
+ isDrawing = false;
};
- function updateCursorStyle() {
- const cursor = document.getElementById('tool-cursor');
- if (!cursor) return;
-
- // Size
- cursor.style.width = `${currentLineWidth}px`;
- cursor.style.height = `${currentLineWidth}px`;
+ canvas.onmousedown = start;
+ canvas.onmousemove = move;
+ canvas.onmouseup = end;
+ canvas.onmouseleave = end;
+
+ // Touch support
+ canvas.ontouchstart = (e) => { e.preventDefault(); start(e.touches[0]); };
+ canvas.ontouchmove = (e) => { e.preventDefault(); move(e.touches[0]); };
+ canvas.ontouchend = (e) => { e.preventDefault(); end(); };
+};
+
+function getPos(e) {
+ const rect = canvas.getBoundingClientRect();
+ return {
+ x: e.clientX - rect.left,
+ y: e.clientY - rect.top
+ };
+}
- // Color
- if (currentMode === 'destination-out') {
- // Eraser: White solid
- cursor.style.backgroundColor = 'white';
- cursor.style.borderColor = '#999';
- } else {
- // Pen: Tool color
- cursor.style.backgroundColor = currentPenColor;
- cursor.style.borderColor = 'rgba(255,255,255,0.8)';
+// Unified Tool Handler
+window.setPenTool = (tool, color, btn) => {
+ // UI Update
+ document.querySelectorAll('.annotation-tool').forEach(b => {
+ b.classList.remove('ring-white');
+ b.classList.add('ring-transparent');
+ });
+ btn.classList.remove('ring-transparent');
+ btn.classList.add('ring-white');
+
+ if (tool === 'eraser') {
+ currentMode = 'destination-out';
+ // Force larger eraser size (e.g., 3x current size or fixed large)
+ // We'll multiply current selected size by 4 for better UX
+ const multiplier = 4;
+ // Store original explicitly if needed, but currentLineWidth is global.
+ // We should dynamically adjust context lineWidth during draw, or just hack it here.
+ // Hack: If we change currentLineWidth here, the UI size buttons might look wrong.
+ // Better: Update cursor style only? No, actual draw needs it.
+ // Let's set a separate 'actualWidth' used in draw, OR just change currentLineWidth temporarily?
+ // Simpler: Just change it. When user clicks size button, it resets.
+ // But if user clicks Pen back? We need to restore.
+ // Let's rely on setPenTool being called with color.
+ // When "Pen" is clicked, we usually don't call setPenTool with a saved size...
+ // Actually, let's just use a large default for eraser, and keep currentLineWidth for Pen.
+ // We need to change how draw() uses the width.
+ // BUT, since we don't want to touch draw() deep inside:
+ // We will hijack currentLineWidth.
+ if (!window.savedPenWidth) window.savedPenWidth = currentLineWidth;
+ currentLineWidth = window.savedPenWidth * 4;
+ } else {
+ currentMode = 'source-over';
+ currentPenColor = color;
+ // Restore pen width
+ if (window.savedPenWidth) {
+ currentLineWidth = window.savedPenWidth;
+ window.savedPenWidth = null;
}
}
-
- function updateSizeBtnUI() {
- document.querySelectorAll('.size-btn').forEach(b => {
- if (parseInt(b.dataset.size) === currentLineWidth) {
- b.classList.add('bg-gray-600', 'text-white');
- b.classList.remove('text-gray-400', 'hover:bg-gray-700');
- } else {
- b.classList.remove('bg-gray-600', 'text-white');
- b.classList.add('text-gray-400', 'hover:bg-gray-700');
- }
- });
+ updateCursorStyle();
+};
+
+// Size Handler
+window.setPenSize = (size, btn) => {
+ currentLineWidth = size;
+ updateSizeBtnUI();
+ updateCursorStyle();
+};
+
+function updateCursorStyle() {
+ const cursor = document.getElementById('tool-cursor');
+ if (!cursor) return;
+
+ // Size
+ cursor.style.width = `${currentLineWidth}px`;
+ cursor.style.height = `${currentLineWidth}px`;
+
+ // Color
+ if (currentMode === 'destination-out') {
+ // Eraser: White solid
+ cursor.style.backgroundColor = 'white';
+ cursor.style.borderColor = '#999';
+ } else {
+ // Pen: Tool color
+ cursor.style.backgroundColor = currentPenColor;
+ cursor.style.borderColor = 'rgba(255,255,255,0.8)';
}
+}
- window.clearCanvas = () => {
- if (canvas && ctx) {
- ctx.clearRect(0, 0, canvas.width, canvas.height);
+function updateSizeBtnUI() {
+ document.querySelectorAll('.size-btn').forEach(b => {
+ if (parseInt(b.dataset.size) === currentLineWidth) {
+ b.classList.add('bg-gray-600', 'text-white');
+ b.classList.remove('text-gray-400', 'hover:bg-gray-700');
+ } else {
+ b.classList.remove('bg-gray-600', 'text-white');
+ b.classList.add('text-gray-400', 'hover:bg-gray-700');
}
- };
-
- /**
- * Renders the Transposed Heatmap (Rows=Challenges, Cols=Students)
- */
- function renderTransposedHeatmap(students) {
- const thead = document.getElementById('heatmap-header');
- const tbody = document.getElementById('heatmap-body');
+ });
+}
- if (students.length === 0) {
- thead.innerHTML = '
等待資料... ';
- tbody.innerHTML = '
尚無學員加入 ';
- return;
- }
+window.clearCanvas = () => {
+ if (canvas && ctx) {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ }
+};
+
+/**
+ * Renders the Transposed Heatmap (Rows=Challenges, Cols=Students)
+ */
+function renderTransposedHeatmap(students) {
+ const thead = document.getElementById('heatmap-header');
+ const tbody = document.getElementById('heatmap-body');
+
+ if (students.length === 0) {
+ thead.innerHTML = '
等待資料... ';
+ tbody.innerHTML = '
尚無學員加入 ';
+ return;
+ }
- // 1. Render Header (Students)
- // Sticky Top for Header Row
- // Sticky Left for the first cell ("Challenge/Student")
- let headerHtml = `
+ // 1. Render Header (Students)
+ // Sticky Top for Header Row
+ // Sticky Left for the first cell ("Challenge/Student")
+ let headerHtml = `
題目
@@ -2505,8 +2512,8 @@ export function setupInstructorEvents() {
`;
- students.forEach(student => {
- headerHtml += `
+ students.forEach(student => {
+ headerHtml += `
@@ -2525,59 +2532,59 @@ export function setupInstructorEvents() {
`;
- });
- thead.innerHTML = headerHtml;
+ });
+ thead.innerHTML = headerHtml;
- // 2. Render Body (Challenges as Rows)
- if (cachedChallenges.length === 0) {
- tbody.innerHTML = '
沒有題目資料 ';
- return;
- }
+ // 2. Render Body (Challenges as Rows)
+ if (cachedChallenges.length === 0) {
+ tbody.innerHTML = '
沒有題目資料 ';
+ return;
+ }
- tbody.innerHTML = cachedChallenges.map((c, index) => {
- const colors = { beginner: 'cyan', intermediate: 'blue', advanced: 'purple' };
- const color = colors[c.level] || 'gray';
-
- // Build Row Cells (One per student)
- const rowCells = students.map(student => {
- const p = student.progress?.[c.id];
- let statusClass = 'bg-gray-800/30 border-gray-800'; // Default
- let content = '';
- let action = '';
-
- if (p) {
- if (p.status === 'completed') {
- statusClass = 'bg-green-500/20 border-green-500/50 hover:bg-green-500/40 cursor-pointer shadow-[0_0_10px_rgba(34,197,94,0.1)]';
- content = '✅';
- // Action restored: Allow direct click to open detailed view
- action = `onclick="window.showBroadcastModal('${student.id}', '${c.id}')" title="完成 - 點擊查看詳情"`;
- } else if (p.status === 'started') {
- // Check stuck
- const startedAt = p.timestamp ? p.timestamp.toDate() : new Date();
- const now = new Date();
- const diffMins = (now - startedAt) / 1000 / 60;
-
- if (diffMins > 5) {
- statusClass = 'bg-red-900/50 border-red-500 animate-pulse cursor-help';
- content = '🆘';
- } else {
- statusClass = 'bg-blue-600/20 border-blue-500';
- content = '🔵';
- }
+ tbody.innerHTML = cachedChallenges.map((c, index) => {
+ const colors = { beginner: 'cyan', intermediate: 'blue', advanced: 'purple' };
+ const color = colors[c.level] || 'gray';
+
+ // Build Row Cells (One per student)
+ const rowCells = students.map(student => {
+ const p = student.progress?.[c.id];
+ let statusClass = 'bg-gray-800/30 border-gray-800'; // Default
+ let content = '';
+ let action = '';
+
+ if (p) {
+ if (p.status === 'completed') {
+ statusClass = 'bg-green-500/20 border-green-500/50 hover:bg-green-500/40 cursor-pointer shadow-[0_0_10px_rgba(34,197,94,0.1)]';
+ content = '✅';
+ // Action restored: Allow direct click to open detailed view
+ action = `onclick="window.showBroadcastModal('${student.id}', '${c.id}')" title="完成 - 點擊查看詳情"`;
+ } else if (p.status === 'started') {
+ // Check stuck
+ const startedAt = p.timestamp ? p.timestamp.toDate() : new Date();
+ const now = new Date();
+ const diffMins = (now - startedAt) / 1000 / 60;
+
+ if (diffMins > 5) {
+ statusClass = 'bg-red-900/50 border-red-500 animate-pulse cursor-help';
+ content = '🆘';
+ } else {
+ statusClass = 'bg-blue-600/20 border-blue-500';
+ content = '🔵';
}
}
+ }
- return `
+ return `
${content}
`;
- }).join('');
+ }).join('');
- // Row Header (Challenge Title)
- return `
+ // Row Header (Challenge Title)
+ return `
@@ -2597,45 +2604,45 @@ export function setupInstructorEvents() {
${rowCells}
`;
- }).join('');
- }
-
- // Global scope for HTML access
- function showBroadcastModal(userId, challengeId) {
- const student = currentStudents.find(s => s.id === userId);
- if (!student) return;
-
- const p = student.progress?.[challengeId];
- if (!p) return;
-
- const challenge = cachedChallenges.find(c => c.id === challengeId);
- const title = challenge ? challenge.title : 'Unknown Challenge'; // Fallback
-
- const modal = document.getElementById('broadcast-modal');
- const content = document.getElementById('broadcast-content');
-
- document.getElementById('broadcast-avatar').textContent = student.nickname[0];
- document.getElementById('broadcast-author').textContent = student.nickname;
- document.getElementById('broadcast-challenge').textContent = title;
- // content is already just text, but let's be safe
- const rawText = p.prompt || p.code || '';
- const isCode = !p.prompt && !!p.code; // If only code exists, treat as code
- document.getElementById('broadcast-prompt').textContent = cleanText(rawText, isCode);
- document.getElementById('broadcast-prompt').style.textAlign = isCode ? 'left' : 'left'; // Always left, but explicit
+ }).join('');
+}
- // Store IDs for actions
- modal.dataset.userId = userId;
- modal.classList.remove('hidden');
- // Animation trigger
- setTimeout(() => {
- content.classList.remove('scale-95', 'opacity-0');
- content.classList.add('opacity-100', 'scale-100');
- }, 10);
- }
+// Global scope for HTML access
+function showBroadcastModal(userId, challengeId) {
+ const student = currentStudents.find(s => s.id === userId);
+ if (!student) return;
+
+ const p = student.progress?.[challengeId];
+ if (!p) return;
+
+ const challenge = cachedChallenges.find(c => c.id === challengeId);
+ const title = challenge ? challenge.title : 'Unknown Challenge'; // Fallback
+
+ const modal = document.getElementById('broadcast-modal');
+ const content = document.getElementById('broadcast-content');
+
+ document.getElementById('broadcast-avatar').textContent = student.nickname[0];
+ document.getElementById('broadcast-author').textContent = student.nickname;
+ document.getElementById('broadcast-challenge').textContent = title;
+ // content is already just text, but let's be safe
+ const rawText = p.prompt || p.code || '';
+ const isCode = !p.prompt && !!p.code; // If only code exists, treat as code
+ document.getElementById('broadcast-prompt').textContent = cleanText(rawText, isCode);
+ document.getElementById('broadcast-prompt').style.textAlign = isCode ? 'left' : 'left'; // Always left, but explicit
+
+ // Store IDs for actions
+ modal.dataset.userId = userId;
+ modal.classList.remove('hidden');
+ // Animation trigger
+ setTimeout(() => {
+ content.classList.remove('scale-95', 'opacity-0');
+ content.classList.add('opacity-100', 'scale-100');
+ }, 10);
+}
- // Bind to window
- window.renderTransposedHeatmap = renderTransposedHeatmap;
- window.showBroadcastModal = showBroadcastModal;
+// Bind to window
+window.renderTransposedHeatmap = renderTransposedHeatmap;
+window.showBroadcastModal = showBroadcastModal;
}