import { createRoom, subscribeToRoom, getChallenges, resetProgress, removeUser, cleanupOldRooms } from "../services/classroom.js";
import { loginWithEmail, registerWithEmail, signOutUser, checkInstructorPermission, getInstructors, addInstructor, updateInstructor, removeInstructor } from "../services/auth.js";
import { generateMonsterSVG, getNextMonster, MONSTER_DEFS } from "../utils/monsterUtils.js";
import { auth } from "../services/firebase.js";
// Load html-to-image dynamically (Better support than html2canvas)
const script = document.createElement('script');
script.src = "https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.js";
document.head.appendChild(script);
// Dynamic Import for Gemini will be used inside the handler to prevent load issues
// const { initGemini, evaluatePrompts } = await import("../services/gemini.js");
let cachedChallenges = [];
let currentStudents = [];
let geminiEnabled = false;
export async function renderInstructorView() {
// Pre-fetch challenges for table headers
try {
cachedChallenges = await getChallenges();
window.cachedChallenges = cachedChallenges; // Expose for dashboard
} catch (e) {
console.error("Failed header load", e);
}
return `
🔒
講師登入
請輸入帳號密碼登入
如果是第一次使用,請先通知管理員新增您的 Email 到白名單,然後點選「註冊」設定密碼。
👥 管理講師權限 (Manage Instructors)
大合照 CLASS PHOTO
2026.01.27
`;
}
export function setupInstructorEvents() {
console.log("Starting setupInstructorEvents...");
// Login Logic
const authErrorMsg = document.getElementById('auth-error');
const authModal = document.getElementById('auth-modal');
// Make functions global for robust onclick handling
window.instructorLogin = async () => {
console.log("Login clicked");
const email = document.getElementById('login-email').value.trim();
const password = document.getElementById('login-password').value.trim();
if (!email || !password) return alert("請輸入帳號與密碼");
const btn = document.getElementById('login-btn');
const originalText = btn.textContent;
btn.textContent = "登入中...";
btn.disabled = true;
try {
await loginWithEmail(email, password);
// Auth state listener will handle the rest
} catch (e) {
console.error("Login Error:", e);
if (authErrorMsg) {
authErrorMsg.textContent = "登入失敗: " + e.message;
authErrorMsg.classList.remove('hidden');
}
btn.textContent = originalText;
btn.disabled = false;
}
};
window.instructorRegister = async () => {
const email = document.getElementById('login-email').value.trim();
const password = document.getElementById('login-password').value.trim();
if (!email || !password) return alert("請輸入帳號與密碼");
try {
await registerWithEmail(email, password);
alert("註冊成功,請登入");
} catch (e) {
console.error("Register Error:", e);
if (authErrorMsg) {
authErrorMsg.textContent = "註冊失敗: " + e.message;
authErrorMsg.classList.remove('hidden');
}
}
};
// --- Instructor Management Helper ---
async function loadInstructorList() {
const tbody = document.getElementById('instructor-list-body');
if (!tbody) return;
tbody.innerHTML = '| 載入中... |
';
try {
const list = await getInstructors();
tbody.innerHTML = list.map(i => `
| ${i.name || '-'} |
${i.email} |
${(i.permissions || []).map(p => `${p}`).join('')}
|
${i.role !== 'admin' ?
`` :
'Admin'
}
|
`).join('');
} catch (e) {
console.error(e);
tbody.innerHTML = '| 載入失敗 |
';
}
}
// Utility for cleaning prompt indentation
// Utility for cleaning prompt indentation
// Utility for cleaning text for display
function cleanText(str, isCode = false) {
if (!str) return '';
// 1. Convert HTML entities if present (common in innerHTML injection flows)
str = str.replace(/ /g, ' ');
str = str.replace(/
/gi, '\n');
// 2. Normalize line endings
str = str.replace(/\r\n/g, '\n');
if (isCode) {
// Smart Dedent for Code (Preserve relative indent)
while (str.startsWith('\n')) str = str.slice(1);
str = str.trimEnd();
const lines = str.split('\n');
if (lines.length === 0) return '';
let minIndent = null;
for (const line of lines) {
// Determine indent level
const content = line.replace(/^[\s\u3000\u00A0]+/, '');
if (content.length === 0) continue; // Skip empty/whitespace-only lines
const currentIndent = line.length - content.length;
if (minIndent === null || currentIndent < minIndent) {
minIndent = currentIndent;
}
}
if (minIndent === null) return str;
return lines.map(line => {
if (line.trim().length === 0) return '';
return line.slice(minIndent);
}).join('\n');
} else {
// Aggressive Flatten for Text Prompts (Force Left Align)
return str.split('\n')
.map(line => line.replace(/^[\s\u3000\u00A0]+/g, '')) // Regex remove ALL leading whitespace (Space, Tab, FullWidth, NBSP)
.filter((line, index, arr) => {
// Remove leading/trailing empty lines
if (line.trim() === '' && (index === 0 || index === arr.length - 1)) return false;
return true;
})
.join('\n')
.trim();
}
}
// --- Transposed Heatmap Renderer ---
function renderTransposedHeatmap(users) {
const container = document.getElementById('heatmap-container');
if (!container) return; // Might be hidden
// Make sure challenges are loaded (might be empty initially)
if (cachedChallenges.length === 0) {
container.innerHTML = '載入題目中...
';
return;
}
// Sort challenges by order
const challenges = cachedChallenges.sort((a, b) => a.order - b.order);
// Sort users by login time (or name)
const sortedUsers = users.sort((a, b) => (a.joinedAt || 0) - (b.joinedAt || 0));
let html = `
|
學員 (${sortedUsers.length})
|
${challenges.map(c => `
${c.title}
${c.description?.slice(0, 50)}...
|
`).join('')}
`;
sortedUsers.forEach(user => {
const isOnline = (Date.now() - (user.lastSeen || 0)) < 60000; // 1 min threshold
const statusDot = isOnline ? '●' : '●';
html += `
${statusDot}
${user.nickname}
|
`;
challenges.forEach(c => {
const progress = user.progress?.[c.id];
let cellContent = '-';
let cellClass = 'text-center border-b border-gray-800 p-2';
if (progress) {
if (progress.status === 'completed') {
cellContent = `
✓
${new Date(progress.completedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
`;
} else if (progress.status === 'failed') {
cellContent = '✕';
} else {
// In progress
cellContent = '...';
}
}
html += `${cellContent} | `;
});
html += `
`;
});
html += `
`;
container.innerHTML = html;
}
// Dashboard Update Logic (Global Scope)
const updateDashboard = (data) => {
const users = Array.isArray(data) ? data : (data?.users ? Object.values(data.users) : []);
// Update global state for modals/snapshot
currentStudents = users;
// Use the full heatmap renderer
renderTransposedHeatmap(users);
}
// --- Broadcast Modal & Reject Logic ---
window.showBroadcastModal = (userId, challengeId) => {
const student = currentStudents.find(u => u.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';
const modal = document.getElementById('broadcast-modal');
const content = document.getElementById('broadcast-content');
if (!modal || !content) return;
const avatarEl = document.getElementById('broadcast-avatar');
if (avatarEl) avatarEl.textContent = student.nickname[0] || '?';
const authorEl = document.getElementById('broadcast-author');
if (authorEl) authorEl.textContent = student.nickname;
const challengeEl = document.getElementById('broadcast-challenge');
if (challengeEl) challengeEl.textContent = title;
const rawText = p.prompt || p.code || '';
const isCode = !p.prompt && !!p.code;
const promptContainer = document.getElementById('broadcast-prompt');
if (promptContainer) {
promptContainer.textContent = cleanText(rawText, isCode);
promptContainer.style.textAlign = 'left';
promptContainer.style.whiteSpace = 'pre-wrap';
}
// Store IDs for actions
modal.dataset.userId = userId;
modal.dataset.challengeId = challengeId;
modal.classList.remove('hidden');
setTimeout(() => {
content.classList.remove('scale-95', 'opacity-0');
content.classList.add('opacity-100', 'scale-100');
}, 10);
};
// Close Modal Logic (Global helper)
window.closeBroadcastModal = () => {
const modal = document.getElementById('broadcast-modal');
const content = document.getElementById('broadcast-content');
if (modal && content) {
content.classList.remove('opacity-100', 'scale-100');
content.classList.add('scale-95', 'opacity-0');
setTimeout(() => {
modal.classList.add('hidden');
}, 200);
}
};
// Global Reject Function
window.rejectCurrentSubmission = async () => {
alert("正在執行退回動作...");
const modal = document.getElementById('broadcast-modal');
if (!modal) return;
const userId = modal.dataset.userId;
const challengeId = modal.dataset.challengeId;
const roomCode = localStorage.getItem('vibecoding_room_code');
console.log('Reject attempt (Global) - Click details:', { dataset: modal.dataset, userId, challengeId, roomCode });
if (!userId || !challengeId) {
console.error('Missing userId or challengeId');
alert("錯誤:無法讀取學員資訊 (Missing ID)");
return;
}
if (confirm('確定要退回此學員的進度嗎?學員將需要重新作答。')) {
try {
// Use top-level import and await execution
const success = await resetProgress(userId, roomCode, challengeId);
if (success) {
alert('已成功退回,學員將需要重新作答。');
window.closeBroadcastModal();
} else {
alert('找不到該學員的進度紀錄,無法退回 (Document Not Found)。');
}
} catch (e) {
console.error("Reject failed:", e);
alert('退回失敗: ' + e.message);
}
}
};
const btnReject = null; // Logic disabled
if (btnReject) {
console.log("Reject button clicked (Delegated Event)");
const modal = document.getElementById('broadcast-modal');
if (!modal) return;
const userId = modal.dataset.userId;
const challengeId = modal.dataset.challengeId;
const roomCode = localStorage.getItem('vibecoding_room_code');
console.log('Reject attempt - Click details:', { dataset: modal.dataset, userId, challengeId, roomCode });
if (!userId || !challengeId) {
console.error('Missing userId or challengeId');
alert("錯誤:無法讀取學員資訊 (Missing ID)");
return;
}
if (confirm('確定要退回此學員的進度嗎?學員將需要重新作答。')) {
try {
// Dead code - syntax fix
const success = resetProgress(userId, roomCode, challengeId);
if (success) {
alert('已成功退回,學員將需要重新作答。');
window.closeBroadcastModal();
} else {
alert('找不到該學員的進度紀錄,無法退回 (Document Not Found)。');
}
} catch (e) {
console.error("Reject failed:", e);
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) {
createBtn.classList.remove('hidden', 'opacity-50', 'cursor-not-allowed');
createBtn.disabled = false;
} else if (createBtn) {
createBtn.classList.add('opacity-50', 'cursor-not-allowed');
createBtn.disabled = true;
createBtn.title = "無此權限";
}
// 2. Add Question Permission (Admin Button)
if (navAdminBtn) {
if (instructor.permissions?.includes('add_question')) {
navAdminBtn.classList.remove('hidden');
} else {
navAdminBtn.classList.add('hidden');
}
}
// 3. Manage Instructors Permission
if (navInstBtn) {
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) {
if (authModal) 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');
});
}
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("無此權限");
}
});
}
if (navAdminBtn) {
navAdminBtn.addEventListener('click', () => {
if (confirm("即將離開儀表板前往題目管理頁面,確定嗎?")) {
window.location.hash = 'admin';
}
});
}
// 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');
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');
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');
}
});
// 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;
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');
}
});
// Register Handler
registerBtn.addEventListener('click', async () => {
const email = loginEmailInput.value;
const password = loginPasswordInput.value;
if (!email || !password) {
authErrorMsg.textContent = "請輸入 Email 和密碼";
authErrorMsg.classList.remove('hidden');
return;
}
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');
}
});
}
// 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');
await createRoom(roomCode, currentInstructor ? currentInstructor.name : 'Unknown');
// Trigger cleanup of old rooms
cleanupOldRooms();
displayRoomCode.textContent = roomCode;
// Store in LocalStorage
localStorage.setItem('vibecoding_room_code', roomCode);
localStorage.setItem('vibecoding_is_host', 'true');
// UI Updates
createContainer.classList.add('hidden');
roomInfo.classList.remove('hidden');
dashboardContent.classList.remove('hidden');
// Start Subscription
subscribeToRoom(roomCode, (data) => {
updateDashboard(data);
});
} catch (e) {
console.error(e);
alert("無法建立教室: " + e.message);
}
});
}
// AI Settings Logic
if (setupAiBtn) {
setupAiBtn.addEventListener('click', () => {
const key = localStorage.getItem('vibecoding_gemini_key') || '';
const studentEnabled = localStorage.getItem('vibecoding_student_ai_enabled') === 'true';
if (geminiKeyInput) geminiKeyInput.value = key;
if (toggleStudentAi) toggleStudentAi.checked = studentEnabled;
if (aiSettingsModal) aiSettingsModal.classList.remove('hidden');
});
}
if (saveAiSettingsBtn) {
saveAiSettingsBtn.addEventListener('click', async () => {
const key = geminiKeyInput ? geminiKeyInput.value.trim() : '';
const studentEnabled = toggleStudentAi ? toggleStudentAi.checked : false;
if (key) {
localStorage.setItem('vibecoding_gemini_key', key);
} else {
localStorage.removeItem('vibecoding_gemini_key');
}
localStorage.setItem('vibecoding_student_ai_enabled', studentEnabled);
// Update Firestore if in a room
const roomCode = localStorage.getItem('vibecoding_instructor_room');
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("AI 啟動失敗: " + e.message);
}
} else {
geminiEnabled = false;
alert("AI 設定已儲存 (停用)");
}
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');
// 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);
}
// 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); }
}
});
} 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
if (navInstBtn) {
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('')}
|
${inst.role === 'admin' ? '不可移除' :
``}
|
`).join('');
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();
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');
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);
}
}
// 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...");
// Re-query authModal to ensure we have the live element in case of re-renders
const liveAuthModal = document.getElementById('auth-modal');
if (liveAuthModal) liveAuthModal.classList.add('hidden');
else console.warn("Live authModal not found!");
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);
});
}
} else {
console.warn("User logged in but not an instructor.");
// Show unauthorized message
authErrorMsg.textContent = "此帳號無講師權限";
authErrorMsg.classList.remove('hidden');
const liveAuthModal = document.getElementById('auth-modal');
if (liveAuthModal) liveAuthModal.classList.remove('hidden');
}
} catch (e) {
console.error("Permission Check Failed:", e);
if (authErrorMsg) {
authErrorMsg.textContent = "權限檢查失敗: " + e.message;
authErrorMsg.classList.remove('hidden');
}
}
} else {
const liveAuthModal = document.getElementById('auth-modal');
if (liveAuthModal) liveAuthModal.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;
}
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);
}
});
}
window.removeInst = async (email) => {
if (confirm(`確定要移除 ${email} 嗎?`)) {
try {
await removeInstructor(email);
loadInstructorList();
} catch (e) {
console.error(e);
alert("移除失敗");
}
}
};
// Snapshot Logic
if (snapshotBtn) {
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);
});
// 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();
} 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');
// 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)';
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);
// 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')} `;
watermark.innerHTML = `
${dateStr} VibeCoding 怪獸成長營
`;
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 = `
`;
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);
}
} 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
// 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);
// 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);
}
// 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;
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';
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;
card.innerHTML = `
${monster.name.split(' ')[1] || monster.name}
Lv.${totalCompleted + 1}
♥
${totalLikes}
`;
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`;
});
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)
}
});
}
// 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);
}
// 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();
}
});
// Check Previous Session (Handled by onAuthStateChanged now)
// if (sessionStorage.getItem('vibecoding_instructor_auth') === 'true') {
// authModal.classList.add('hidden');
// }
// Check Active Room State
const activeRoom = sessionStorage.getItem('vibecoding_instructor_in_room');
if (activeRoom === 'true' && savedRoomCode) {
enterRoom(savedRoomCode);
}
// Module-level variable to track subscription (Moved to top)
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');
// Unsubscribe previous if any
if (roomUnsubscribe) roomUnsubscribe();
// Subscribe to updates
roomUnsubscribe = subscribeToRoom(roomCode, (students) => {
currentStudents = students;
renderTransposedHeatmap(students);
});
}
// Leave Room Logic
document.getElementById('leave-room-btn').addEventListener('click', () => {
if (confirm('確定要離開目前教室嗎?(不會刪除教室資料,僅回到選擇介面)')) {
// Unsubscribe
if (roomUnsubscribe) {
roomUnsubscribe();
roomUnsubscribe = null;
}
// 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
// Clear Data Display
document.getElementById('heatmap-body').innerHTML = '| 等待資料載入... |
';
document.getElementById('heatmap-header').innerHTML = '學員 / 關卡 | ';
// 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;
}
titleEl.textContent = type === 'student' ? `${title} 的所有提示詞` : `題目:${title} 的所有作品`;
// Reset Anonymous Toggle in List View
const anonCheck = document.getElementById('list-anonymous-toggle');
if (anonCheck) anonCheck.checked = false;
container.innerHTML = '';
modal.classList.remove('hidden');
// 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() : ''
};
});
}
} 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;
}
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}
${cleanText(p.prompt)}
`;
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
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);
}
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);
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("無學生資料");
// 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("無法確認目前所在的題目,請重新開啟列表");
// 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
});
}
});
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.disabled = false;
aiAnalyzeBtn.classList.remove('animate-pulse');
}
});
}
// 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.
}
col.innerHTML = `
${displayAuthor}
${item.title}
${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');
// 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`;
});
// 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(); };
};
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();
};
// 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)';
}
}
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');
}
});
}
window.clearCanvas = () => {
if (canvas && ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
};
// Bind to window
window.renderTransposedHeatmap = renderTransposedHeatmap;
window.showBroadcastModal = showBroadcastModal;
}
}
}