-
-
-
-
+
+
+
+
+
+
+ 提示詞比較與註記
+
+
+
+
+
+ 👀 隱藏姓名
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🧽
+
+
+
+
+
+
+ S
+ M
+ L
+
+
+
+
+
+ 清除
+
+
+
+
✕
-
- 📸 大合照
-
-
- 👥 管理講師
-
-
- 管理題目
-
-
-
-
+
+
+
+
+
+ 儀表板 v26.01.27
+
+
+ Room
+
+
+ 🚪 離開
+
+
+
-
-
- 👾
-
-
- 🚪
-
-
+
+
-
-
-
-
-
-
-
-
- 等待資料載入...
-
-
-
-
-`;
+
+ 📸 大合照
+
+
+ 👥 管理講師
+
+
+ 管理題目
+
+
+
+ 重回
+
+ 開房
+
+
+
+
+
+
+
+
+ 👾
+
+
+ 🚪
+
+
+
+
+
+
+
+
+
+
+
+ 等待資料載入...
+
+
+
+
+ `;
}
-export function setupInstructorEvents() {
- let roomUnsubscribe = null;
- let currentInstructor = null;
-
- // UI References
- const authBtn = document.getElementById('google-auth-btn');
- const authModal = document.getElementById('auth-modal');
- const authErrorMsg = document.getElementById('auth-error-msg');
-
- // Core Buttons
- const createBtn = document.getElementById('create-room-btn');
- const navAdminBtn = document.getElementById('nav-admin-btn');
- const navInstBtn = document.getElementById('nav-instructors-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;
+ export function setupInstructorEvents() {
+ 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;
// Permission Check Helper
const checkPermissions = (instructor) => {
if (!instructor) return;
- currentInstructor = instructor;
+ currentInstructor = instructor;
- // 1. Create Room Permission
- if (instructor.permissions?.includes('create_room')) {
- createBtn.classList.remove('hidden', 'opacity-50', 'cursor-not-allowed');
+ // 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.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');
+ // 2. Add Question Permission (Admin Button)
+ if (instructor.permissions?.includes('add_question')) {
+ navAdminBtn.classList.remove('hidden');
} else {
- navAdminBtn.classList.add('hidden');
+ navAdminBtn.classList.add('hidden');
}
- // 3. Manage Instructors Permission
- if (instructor.permissions?.includes('manage_instructors')) {
- navInstBtn.classList.remove('hidden');
+ // 3. Manage Instructors Permission
+ if (instructor.permissions?.includes('manage_instructors')) {
+ navInstBtn.classList.remove('hidden');
} else {
- navInstBtn.classList.add('hidden');
+ navInstBtn.classList.add('hidden');
}
};
- // Google Auth Logic
- authBtn.addEventListener('click', async () => {
- try {
- // Do NOT disable button immediately to prevent browser popup blocker
- // authBtn.disabled = true;
- authBtn.classList.add('opacity-50');
+ // 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');
+ }
+ });
+
+ // 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');
- const user = await signInWithGoogle();
+ // 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);
- // Save name for avatar
- 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.message;
- let showRedirectBtn = false;
-
- if (error.code === 'auth/popup-blocked' || error.code === 'auth/popup-closed-by-user') {
- msg = "登入視窗未正常開啟 (可能是瀏覽器攔截)。";
- showRedirectBtn = true;
- }
- if (error.code === 'auth/operation-not-allowed' || error.message.includes('invalid')) {
- msg = "Google 登入功能未啟用。請前往 Firebase Console > Authentication開啟 Google Sign-In。";
- }
-
- authErrorMsg.innerHTML = `
- ${msg}
- ${showRedirectBtn ? '
改用頁面跳轉登入 ' : ''}
- `;
+ checkPermissions(instructorData);
+ localStorage.setItem('vibecoding_instructor_name', instructorData.name);
+ alert("註冊成功!");
+ } else {
+ // Auth created but not in whitelist
+ authErrorMsg.textContent = "註冊成功,但您的 Email 未被列入講師名單。請聯繫管理員。";
authErrorMsg.classList.remove('hidden');
-
- // Attach listener if button exists
- const retryBtn = document.getElementById('redirect-login-btn');
- if (retryBtn) {
- retryBtn.addEventListener('click', async () => {
- localStorage.setItem('vibecoding_redirect_login', 'true');
- try {
- const { auth } = await import("../services/firebase.js");
- const { setPersistence, browserLocalPersistence } = await import("firebase/auth");
- await setPersistence(auth, browserLocalPersistence); // Force persistence
- console.log("Persistence set to LOCAL");
- await signInWithGoogleRedirect();
- } catch (e) {
- alert("跳轉失敗: " + e.message);
- }
- });
+ 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');
}
-
- } finally {
- authBtn.disabled = false;
- authBtn.classList.remove('opacity-50');
- }
- });
+ });
+ }
// Handle Instructor Management
navInstBtn.addEventListener('click', async () => {
const modal = document.getElementById('instructor-modal');
- const listBody = document.getElementById('instructor-list-body');
+ const listBody = document.getElementById('instructor-list-body');
- // Load list
- const instructors = await getInstructors();
+ // 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('');
+ `).join('');
- modal.classList.remove('hidden');
+ modal.classList.remove('hidden');
});
// Add New Instructor
document.getElementById('btn-add-inst').addEventListener('click', async () => {
const email = document.getElementById('new-inst-email').value.trim();
- const name = document.getElementById('new-inst-name').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);
+ 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);
+ alert("新增失敗: " + e.message);
}
});
@@ -479,48 +529,48 @@ export function setupInstructorEvents() {
if (confirm(`確定移除 ${email}?`)) {
try {
await removeInstructor(email);
- navInstBtn.click(); // Reload
+ 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 }) => {
+ // 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");
+ 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); }
+ } catch (e) {console.warn("Redirect check failed", e); }
auth.onAuthStateChanged(async (user) => {
- console.log("Auth State Changed to:", user ? user.email : "Logged Out");
+ 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);
+ 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);
} 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
+ 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
}
} catch (e) {
- console.error("Permission Check Failed:", e);
- authErrorMsg.textContent = "權限檢查失敗: " + e.message;
- authErrorMsg.classList.remove('hidden');
+ console.error("Permission Check Failed:", e);
+ authErrorMsg.textContent = "權限檢查失敗: " + e.message;
+ authErrorMsg.classList.remove('hidden');
}
} else {
authModal.classList.remove('hidden');
@@ -532,12 +582,12 @@ export function setupInstructorEvents() {
window.confirmKick = async (userId, nickname) => {
if (confirm(`確定要踢出 ${nickname} 嗎?此動作無法復原。`)) {
try {
- const { removeUser } = await import("../services/classroom.js");
- await removeUser(userId);
+ const {removeUser} = await import("../services/classroom.js");
+ await removeUser(userId);
// UI will update automatically via subscribeToRoom
} catch (e) {
console.error("Kick failed:", e);
- alert("移除失敗");
+ alert("移除失敗");
}
}
};
@@ -549,47 +599,47 @@ export function setupInstructorEvents() {
if (typeof htmlToImage === 'undefined') alert("截圖元件尚未載入,請稍候再試");
return;
}
- isSnapshotting = true;
+ 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');
+ 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';
+ // 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');
+ overlay.classList.remove('hidden');
+ overlay.classList.add('flex');
// Countdown Sequence
const runCountdown = (num) => new Promise(resolve => {
- countEl.textContent = num;
+ 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);
+ countEl.style.transform = 'scale(1)';
+ countEl.style.opacity = '0.5';
+ setTimeout(resolve, 1000);
});
});
- await runCountdown(3);
- await runCountdown(2);
- await runCountdown(1);
+ await runCountdown(3);
+ await runCountdown(2);
+ await runCountdown(1);
- // Action!
- countEl.textContent = '';
- overlay.classList.add('hidden');
+ // Action!
+ countEl.textContent = '';
+ overlay.classList.add('hidden');
- // 1. Emojis Explosion
- const emojis = ['🤘', '✌️', '👍', '🫶', '😎', '🔥'];
- const cards = container.querySelectorAll('.group\\/card');
+ // 1. Emojis Explosion
+ const emojis = ['🤘', '✌️', '👍', '🫶', '😎', '🔥'];
+ const cards = container.querySelectorAll('.group\\/card');
cards.forEach(card => {
// Find the monster image container
@@ -614,33 +664,33 @@ export function setupInstructorEvents() {
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);
+ 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,
+ // 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);
+ alert("截圖失敗 (請嘗試手動截圖/PrtSc)\n原因: " + e.message);
} finally {
// Restore UI
if (closeBtn) closeBtn.style.opacity = '1';
- snapshotBtn.style.opacity = '1';
- isSnapshotting = false;
+ snapshotBtn.style.opacity = '1';
+ isSnapshotting = false;
}
}, 600); // Slight delay for emojis to appear
});
@@ -648,61 +698,61 @@ export function setupInstructorEvents() {
// 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');
+ 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 = `
-
- ${dateStr} VibeCoding 怪獸成長營
-
- `;
- relativeContainer.appendChild(watermark);
+ 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 = `
-
+ // 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);
+
+
+
+
+ 👑
+
+
+
+ `;
+ relativeContainer.appendChild(instructorSection);
// Save name on change
setTimeout(() => {
@@ -726,115 +776,115 @@ export function setupInstructorEvents() {
if (total >= 40) {
sizeClass = 'w-12 h-12 md:w-14 md:h-14'; // Size 60%
- scaleFactor = 0.6;
+ scaleFactor = 0.6;
} else if (total >= 20) {
sizeClass = 'w-16 h-16 md:w-20 md:h-20'; // Size 80%
- scaleFactor = 0.8;
+ scaleFactor = 0.8;
}
students.forEach((s, index) => {
- const progressMap = s.progress || {};
+ 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') {
+ // 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;
+ if (stored) {
+ monster = stored;
} else {
- // Fallback if ID invalid
- monster = getNextMonster(s.monster_stage || 0, totalLikes, total, s.monster_id);
+ // 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);
+ 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);
+ if (total === 1) {
+ finalAngle = 270 * (Math.PI / 180);
} else {
const step = safeSpan / (total - 1);
- finalAngle = safeStartAngle + (step * index);
+ 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 = `
-
-
-
${monster.name.split(' ')[1] || monster.name}
-
-
Lv.${totalCompleted + 1}
-
- ♥
- ${totalLikes}
-
-
+ card.innerHTML = `
+
+
+
${monster.name.split(' ')[1] || monster.name}
+
+
Lv.${totalCompleted + 1}
+
+ ♥
+ ${totalLikes}
+
+
-
-
-
-
-
-`;
- relativeContainer.appendChild(card);
-
- // Enable Drag & Drop
- setupDraggable(card, relativeContainer);
+
+
+
+
+
+ `;
+ relativeContainer.appendChild(card);
+
+ // Enable Drag & Drop
+ setupDraggable(card, relativeContainer);
});
}
- modal.classList.remove('hidden');
+ modal.classList.remove('hidden');
});
- // Helper: Drag & Drop Logic
- function setupDraggable(el, container) {
- let isDragging = false;
- let startX, startY, initialLeft, initialTop;
+ // Helper: Drag & Drop Logic
+ function setupDraggable(el, container) {
+ let isDragging = false;
+ let startX, startY, initialLeft, initialTop;
el.addEventListener('mousedown', (e) => {
- isDragging = true;
+ isDragging = true;
startX = e.clientX;
startY = e.clientY;
@@ -878,56 +928,56 @@ export function setupInstructorEvents() {
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)
+ 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')) {
+ // 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 {
+ style.id = 'anim-float';
+ style.innerHTML = `
+ @keyframes float {
- 0%, 100% { transform: translateY(0) scale(1); }
- 50% { transform: translateY(-5px) scale(1.02); }
+ 0 %, 100 % { transform: translateY(0) scale(1); }
+ 50% {transform: translateY(-5px) scale(1.02); }
}
}
-`;
- document.head.appendChild(style);
+ `;
+ document.head.appendChild(style);
}
navAdminBtn.addEventListener('click', () => {
// Save current room to return later
const currentRoom = localStorage.getItem('vibecoding_instructor_room');
- localStorage.setItem('vibecoding_admin_referer', 'instructor'); // track entry source
- window.location.hash = 'admin';
+ localStorage.setItem('vibecoding_admin_referer', 'instructor'); // track entry source
+ window.location.hash = 'admin';
});
- // Auto-fill code
- const savedRoomCode = localStorage.getItem('vibecoding_instructor_room');
- if (savedRoomCode) {
- document.getElementById('rejoin-room-code').value = savedRoomCode;
+ // Auto-fill code
+ const savedRoomCode = localStorage.getItem('vibecoding_instructor_room');
+ if (savedRoomCode) {
+ document.getElementById('rejoin-room-code').value = savedRoomCode;
}
- const rejoinBtn = document.getElementById('rejoin-room-btn');
+ const rejoinBtn = document.getElementById('rejoin-room-btn');
rejoinBtn.addEventListener('click', () => {
const code = document.getElementById('rejoin-room-code').value.trim();
- if (!code) return alert('請輸入教室代碼');
- enterRoom(code);
+ if (!code) return alert('請輸入教室代碼');
+ enterRoom(code);
});
// Gallery Logic
document.getElementById('btn-open-gallery').addEventListener('click', () => {
- window.open('monster_preview.html', '_blank');
+ window.open('monster_preview.html', '_blank');
});
// Logout Logic
document.getElementById('logout-btn').addEventListener('click', async () => {
if (confirm('確定要登出講師模式嗎? (將會回到首頁)')) {
- await signOutUser();
+ await signOutUser();
sessionStorage.removeItem('vibecoding_instructor_in_room');
sessionStorage.removeItem('vibecoding_admin_referer');
window.location.hash = '';
@@ -937,12 +987,12 @@ export function setupInstructorEvents() {
createBtn.addEventListener('click', async () => {
try {
- createBtn.disabled = true;
+ createBtn.disabled = true;
createBtn.textContent = "...";
const roomCode = await createRoom();
enterRoom(roomCode);
} catch (error) {
- console.error(error);
+ console.error(error);
alert("建立失敗");
createBtn.disabled = false;
}
@@ -955,27 +1005,27 @@ export function setupInstructorEvents() {
// Check Active Room State
const activeRoom = sessionStorage.getItem('vibecoding_instructor_in_room');
- if (activeRoom === 'true' && savedRoomCode) {
- enterRoom(savedRoomCode);
+ if (activeRoom === 'true' && savedRoomCode) {
+ enterRoom(savedRoomCode);
}
- // Module-level variable to track subscription (Moved to top)
+ // 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');
+ 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();
+ // Unsubscribe previous if any
+ if (roomUnsubscribe) roomUnsubscribe();
// Subscribe to updates
roomUnsubscribe = subscribeToRoom(roomCode, (students) => {
- currentStudents = students;
+ currentStudents = students;
renderTransposedHeatmap(students);
});
}
@@ -986,7 +1036,7 @@ export function setupInstructorEvents() {
// Unsubscribe
if (roomUnsubscribe) {
roomUnsubscribe();
- roomUnsubscribe = null;
+ roomUnsubscribe = null;
}
// UI Reset
@@ -1008,108 +1058,108 @@ export function setupInstructorEvents() {
// Modal Events
window.showBroadcastModal = (userId, challengeId) => {
const modal = document.getElementById('broadcast-modal');
- const content = document.getElementById('broadcast-content');
+ const content = document.getElementById('broadcast-content');
// Find Data
const student = currentStudents.find(s => s.id === userId);
- if (!student) return alert('找不到學員資料');
+ if (!student) return alert('找不到學員資料');
- const p = student.progress ? student.progress[challengeId] : null;
- if (!p) 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 : '未知題目';
+ 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 || '(無內容)';
+ // 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;
+ // Store IDs for Actions (Reject/BroadcastAll)
+ modal.dataset.userId = userId;
+ modal.dataset.challengeId = challengeId;
- // Show
- modal.classList.remove('hidden');
+ // Show
+ modal.classList.remove('hidden');
setTimeout(() => {
- content.classList.remove('scale-95', 'opacity-0');
+ 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');
+ 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 = prompt;
- document.getElementById('stage-author').textContent = author;
+ document.getElementById('broadcast-content').classList.add('hidden');
+ const stage = document.getElementById('stage-view');
+ stage.classList.remove('hidden');
+ document.getElementById('stage-prompt').textContent = 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('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);
+ 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.
- // Currently showBroadcastModal only receives nickname, title, prompt.
- // We need to attach data-userid and data-challengeid to the modal.
- const modal = document.getElementById('broadcast-modal');
- const userId = modal.dataset.userId;
- const challengeId = modal.dataset.challengeId;
- const roomCode = localStorage.getItem('vibecoding_instructor_room');
+ // We need student ID (userId) and Challenge ID.
+ // Currently showBroadcastModal only receives nickname, title, prompt.
+ // We need to attach data-userid and data-challengeid to the modal.
+ const modal = document.getElementById('broadcast-modal');
+ const userId = modal.dataset.userId;
+ const challengeId = modal.dataset.challengeId;
+ const roomCode = localStorage.getItem('vibecoding_instructor_room');
- if (userId && challengeId && roomCode) {
+ if (userId && challengeId && roomCode) {
try {
await resetProgress(userId, roomCode, challengeId);
- // Close modal
- window.closeBroadcast();
+ // Close modal
+ window.closeBroadcast();
} catch (e) {
console.error(e);
- alert('退回失敗');
+ alert('退回失敗');
}
}
});
// 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');
+ const container = document.getElementById('prompt-list-container');
+ const titleEl = document.getElementById('prompt-list-title');
- 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') {
+ if (type === 'student') {
const student = currentStudents.find(s => s.id === id);
if (student && student.progress) {
prompts = Object.entries(student.progress)
@@ -1128,26 +1178,26 @@ export function setupInstructorEvents() {
});
}
} 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() : ''
- });
+ 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 = '
無資料
';
+ if (prompts.length === 0) {
+ container.innerHTML = '
無資料
';
return;
}
@@ -1156,26 +1206,26 @@ export function setupInstructorEvents() {
// 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}
-
-
-
-
-
${p.prompt}
-
-
-
-
${p.time}
-
-
- 退回
-
-
+
+
${p.title}
+
+
+
+
+
${p.prompt}
+
+
+
+
${p.time}
+
+
+ 退回
+
- `;
+
+ `;
container.appendChild(card);
});
};
@@ -1186,103 +1236,103 @@ export function setupInstructorEvents() {
const roomCode = localStorage.getItem('vibecoding_instructor_room');
if (userId && challengeId && roomCode) {
try {
- const { resetProgress } = await import("../services/classroom.js");
- 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');
+ const {resetProgress} = await import("../services/classroom.js");
+ 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("退回失敗");
+ console.error(e);
+ alert("退回失敗");
}
}
}
};
window.broadcastPrompt = (userId, challengeId) => {
- window.showBroadcastModal(userId, challengeId);
+ window.showBroadcastModal(userId, challengeId);
};
- // Selection Logic
- let selectedPrompts = []; // Stores IDs
+ // Selection Logic
+ let selectedPrompts = []; // Stores IDs
window.handlePromptSelection = (checkbox) => {
const id = checkbox.dataset.id;
- if (checkbox.checked) {
+ if (checkbox.checked) {
if (selectedPrompts.length >= 3) {
checkbox.checked = false;
- alert('最多只能選擇 3 個提示詞進行比較');
- return;
+ alert('最多只能選擇 3 個提示詞進行比較');
+ return;
}
selectedPrompts.push(id);
} else {
- selectedPrompts = selectedPrompts.filter(pid => pid !== id);
+ selectedPrompts = selectedPrompts.filter(pid => pid !== id);
}
- updateCompareButton();
+ updateCompareButton();
};
- function updateCompareButton() {
+ function updateCompareButton() {
const btn = document.getElementById('btn-compare-prompts');
- if (!btn) return;
+ if (!btn) return;
- const count = selectedPrompts.length;
- const span = btn.querySelector('span');
- if (span) span.textContent = `🔍 比較已選項目 (${count}/3)`;
+ const count = selectedPrompts.length;
+ const span = btn.querySelector('span');
+ if (span) span.textContent = `🔍 比較已選項目 (${count}/3)`;
if (count > 0) {
- btn.disabled = false;
+ btn.disabled = false;
btn.classList.remove('opacity-50', 'cursor-not-allowed');
} else {
- btn.disabled = true;
+ 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() : ''
+ // 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);
- });
+ const isAnon = document.getElementById('list-anonymous-toggle')?.checked || false;
+ openComparisonView(dataToCompare, isAnon);
+ });
}
- let isAnonymous = false;
+ let isAnonymous = false;
window.toggleAnonymous = (btn) => {
- isAnonymous = !isAnonymous;
- btn.textContent = isAnonymous ? '🙈 顯示姓名' : '👀 隱藏姓名';
- btn.classList.toggle('bg-gray-700');
- btn.classList.toggle('bg-purple-700');
+ 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
+ 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;
@@ -1292,32 +1342,32 @@ export function setupInstructorEvents() {
window.openComparisonView = (items, initialAnonymous = false) => {
const modal = document.getElementById('comparison-modal');
- const grid = document.getElementById('comparison-grid');
+ const grid = document.getElementById('comparison-grid');
- // Apply Anonymous State
- isAnonymous = initialAnonymous;
- const anonBtn = document.getElementById('btn-anonymous-toggle');
+ // Apply Anonymous State
+ isAnonymous = initialAnonymous;
+ const anonBtn = document.getElementById('btn-anonymous-toggle');
- // Update Toggle UI to match state
- if (anonBtn) {
+ // Update Toggle UI to match state
+ if (anonBtn) {
if (isAnonymous) {
anonBtn.textContent = '🙈 顯示姓名';
- anonBtn.classList.add('bg-purple-700');
- anonBtn.classList.remove('bg-gray-700');
+ 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');
+ 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';
+ // 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 = '';
+ grid.className = `absolute inset-0 grid ${rowClass} gap-0 divide-y divide-gray-600`;
+ grid.innerHTML = '';
items.forEach(item => {
const col = document.createElement('div');
@@ -1330,21 +1380,21 @@ export function setupInstructorEvents() {
if (isAnonymous) {
displayAuthor = '學員';
- blurClass = 'blur-sm'; // Initial blur
+ 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}
-
-
-
-${item.prompt}
-
+
+
${displayAuthor}
+
${item.title}
+
+
+
+ ${item.prompt}
+
`;
grid.appendChild(col);
@@ -1360,35 +1410,35 @@ ${item.prompt}
}
});
- document.getElementById('prompt-list-modal').classList.add('hidden');
- modal.classList.remove('hidden');
+ document.getElementById('prompt-list-modal').classList.add('hidden');
+ modal.classList.remove('hidden');
- // Init Canvas (Phase 3)
- setTimeout(setupCanvas, 100);
+ // Init Canvas (Phase 3)
+ setTimeout(setupCanvas, 100);
};
window.closeComparison = () => {
- document.getElementById('comparison-modal').classList.add('hidden');
- clearCanvas();
+ 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)
+ // --- 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;
+ canvas = document.getElementById('annotation-canvas');
+ const container = document.getElementById('comparison-container');
+ if (!canvas || !container) return;
- ctx = canvas.getContext('2d');
+ ctx = canvas.getContext('2d');
// Resize
const resize = () => {
- canvas.width = container.clientWidth;
+ canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
@@ -1396,27 +1446,27 @@ ${item.prompt}
ctx.lineWidth = currentLineWidth;
ctx.globalCompositeOperation = currentMode;
};
- resize();
- window.addEventListener('resize', resize);
+ resize();
+ window.addEventListener('resize', resize);
- // Init Size UI & Cursor
- updateSizeBtnUI();
- updateCursorStyle();
+ // Init Size UI & Cursor
+ updateSizeBtnUI();
+ updateCursorStyle();
- // Cursor Logic
- const cursor = document.getElementById('tool-cursor');
+ // 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);
+ const {x, y} = getPos(e);
cursor.style.left = `${x}px`;
cursor.style.top = `${y}px`;
});
// Drawing Events
const start = (e) => {
- isDrawing = true;
+ isDrawing = true;
ctx.beginPath();
// Re-apply settings (state might change)
@@ -1424,132 +1474,132 @@ ${item.prompt}
ctx.strokeStyle = currentPenColor;
ctx.lineWidth = currentLineWidth;
- const { x, y } = getPos(e);
+ const {x, y} = getPos(e);
ctx.moveTo(x, y);
};
const move = (e) => {
if (!isDrawing) return;
- const { x, y } = getPos(e);
+ const {x, y} = getPos(e);
ctx.lineTo(x, y);
ctx.stroke();
};
const end = () => {
- isDrawing = false;
+ isDrawing = false;
};
- canvas.onmousedown = start;
- canvas.onmousemove = move;
- canvas.onmouseup = end;
- canvas.onmouseleave = end;
+ 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(); };
+ 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) {
+ function getPos(e) {
const rect = canvas.getBoundingClientRect();
- return {
- x: e.clientX - rect.left,
+ 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');
+ // 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';
+ if (tool === 'eraser') {
+ currentMode = 'destination-out';
} else {
- currentMode = 'source-over';
+ currentMode = 'source-over';
currentPenColor = color;
}
- updateCursorStyle();
+ updateCursorStyle();
};
// Size Handler
window.setPenSize = (size, btn) => {
- currentLineWidth = size;
- updateSizeBtnUI();
- updateCursorStyle();
+ currentLineWidth = size;
+ updateSizeBtnUI();
+ updateCursorStyle();
};
- function updateCursorStyle() {
+ function updateCursorStyle() {
const cursor = document.getElementById('tool-cursor');
- if (!cursor) return;
+ if (!cursor) return;
- // Size
- cursor.style.width = `${currentLineWidth}px`;
- cursor.style.height = `${currentLineWidth}px`;
+ // Size
+ cursor.style.width = `${currentLineWidth}px`;
+ cursor.style.height = `${currentLineWidth}px`;
- // Color
- if (currentMode === 'destination-out') {
- // Eraser: White solid
- cursor.style.backgroundColor = 'white';
+ // Color
+ if (currentMode === 'destination-out') {
+ // Eraser: White solid
+ cursor.style.backgroundColor = 'white';
cursor.style.borderColor = '#999';
} else {
- // Pen: Tool color
- cursor.style.backgroundColor = currentPenColor;
+ // 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');
- }
- });
+ 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);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
}
};
}
-/**
- * Renders the Transposed Heatmap (Rows=Challenges, Cols=Students)
- */
-function renderTransposedHeatmap(students) {
+ /**
+ * Renders the Transposed Heatmap (Rows=Challenges, Cols=Students)
+ */
+ function renderTransposedHeatmap(students) {
const thead = document.getElementById('heatmap-header');
- const tbody = document.getElementById('heatmap-body');
+ const tbody = document.getElementById('heatmap-body');
- if (students.length === 0) {
- thead.innerHTML = '
等待資料... ';
- tbody.innerHTML = '
尚無學員加入 ';
- return;
+ 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 = `
-
-
- 題目
- 學員 (${students.length})
-
-
- `;
+ // 1. Render Header (Students)
+ // Sticky Top for Header Row
+ // Sticky Left for the first cell ("Challenge/Student")
+ let headerHtml = `
+
+
+ 題目
+ 學員 (${students.length})
+
+
+ `;
students.forEach(student => {
- headerHtml += `
+ headerHtml += `
@@ -1569,17 +1619,17 @@ function renderTransposedHeatmap(students) {
`;
});
- 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';
+ 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 => {
@@ -1590,53 +1640,53 @@ function renderTransposedHeatmap(students) {
if (p) {
if (p.status === 'completed') {
- statusClass = 'bg-green-500/20 border-green-500/50 hover:bg-green-500/40 cursor-default shadow-[0_0_10px_rgba(34,197,94,0.1)]';
- content = '✅';
- // Action removed: Moved to prompt list view
- action = `title="完成 - 請點擊標題查看詳情"`;
+ statusClass = 'bg-green-500/20 border-green-500/50 hover:bg-green-500/40 cursor-default shadow-[0_0_10px_rgba(34,197,94,0.1)]';
+ content = '✅';
+ // Action removed: Moved to prompt list view
+ action = `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;
+ 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 = '🆘';
+ statusClass = 'bg-red-900/50 border-red-500 animate-pulse cursor-help';
+ content = '🆘';
} else {
- statusClass = 'bg-blue-600/20 border-blue-500';
- content = '🔵';
+ statusClass = 'bg-blue-600/20 border-blue-500';
+ content = '🔵';
}
}
}
return `
-
-
- ${content}
-
-
- `;
+
+
+ ${content}
+
+
+ `;
}).join('');
- // Row Header (Challenge Title)
- return `
-
-
-
-
- ${c.level}
-
- ${index + 1}. ${c.title}
-
-
-
-
-
-
+ // Row Header (Challenge Title)
+ return `
+
+
+
+
+ ${c.level}
+
+ ${index + 1}. ${c.title}
+
+
+
+
+
+
${rowCells}
- `;
+ `;
}).join('');
}
@@ -1644,31 +1694,31 @@ function renderTransposedHeatmap(students) {
// Global scope for HTML access
window.showBroadcastModal = (userId, challengeId) => {
const student = currentStudents.find(s => s.id === userId);
- if (!student) return;
+ if (!student) return;
- const p = student.progress?.[challengeId];
- if (!p) 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 title = challenge ? challenge.title : 'Unknown Challenge'; // Fallback
- const modal = document.getElementById('broadcast-modal');
- const content = document.getElementById('broadcast-content');
+ 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
- document.getElementById('broadcast-prompt').textContent = p.prompt || p.code || ''; // robust fallback
+ 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
+ document.getElementById('broadcast-prompt').textContent = p.prompt || p.code || ''; // robust fallback
- // Store IDs for actions
- modal.dataset.userId = userId;
- modal.dataset.challengeId = challengeId;
+ // Store IDs for actions
+ modal.dataset.userId = userId;
+ modal.dataset.challengeId = challengeId;
- modal.classList.remove('hidden');
+ modal.classList.remove('hidden');
// Animation trigger
setTimeout(() => {
- content.classList.remove('scale-95', 'opacity-0');
- content.classList.add('opacity-100', 'scale-100');
+ content.classList.remove('scale-95', 'opacity-0');
+ content.classList.add('opacity-100', 'scale-100');
}, 10);
};