Spaces:
Running
Running
| import { createRoom, subscribeToRoom, getChallenges, resetProgress, removeUser } from "../services/classroom.js"; | |
| import { generateMonsterSVG, getNextMonster, MONSTER_DEFS } from "../utils/monsterUtils.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); | |
| let cachedChallenges = []; | |
| let currentStudents = []; | |
| export async function renderInstructorView() { | |
| // Pre-fetch challenges for table headers | |
| try { | |
| cachedChallenges = await getChallenges(); | |
| } catch (e) { | |
| console.error("Failed header load", e); | |
| } | |
| return ` | |
| <div id="auth-modal" class="fixed inset-0 bg-black bg-opacity-90 backdrop-blur z-50 flex items-center justify-center"> | |
| <div class="bg-gray-800 p-8 rounded-xl border border-gray-600 shadow-2xl max-w-sm w-full"> | |
| <h2 class="text-xl font-bold text-center mb-6 text-white">🔒 講師身分驗證</h2> | |
| <input type="password" id="instructor-password" class="w-full bg-gray-900 border border-gray-700 rounded p-3 text-white text-center text-lg tracking-widest mb-4 focus:border-cyan-500 focus:outline-none" placeholder="輸入密碼"> | |
| <button id="auth-btn" class="w-full bg-purple-600 hover:bg-purple-500 text-white font-bold py-3 rounded-lg transition-colors">確認進入</button> | |
| </div> | |
| </div> | |
| <!--Broadcast Modal(Hidden by default )--> | |
| <div id="broadcast-modal" class="fixed inset-0 bg-black/90 backdrop-blur z-50 hidden flex flex-col items-center justify-center p-8 transition-opacity duration-300"> | |
| <button onclick="closeBroadcast()" class="absolute top-6 right-6 text-gray-400 hover:text-white text-2xl">✕</button> | |
| <div id="broadcast-content" class="bg-gray-800 border border-gray-600 rounded-2xl p-8 max-w-4xl w-full text-center shadow-2xl transform transition-transform scale-95 opacity-0"> | |
| <div class="mb-4 flex flex-col items-center"> | |
| <div class="w-16 h-16 rounded-full bg-cyan-600 flex items-center justify-center text-3xl font-bold text-white mb-2" id="broadcast-avatar"> | |
| D | |
| </div> | |
| <h3 class="text-xl text-cyan-300 font-bold" id="broadcast-author">Dave</h3> | |
| <span class="text-gray-500 text-sm" id="broadcast-challenge">Challenge Name</span> | |
| </div> | |
| <div class="bg-black/30 rounded-xl p-6 mb-8 text-left overflow-auto max-h-[50vh]"> | |
| <pre class="text-green-400 font-mono text-lg whitespace-pre-wrap" id="broadcast-prompt">Loading...</pre> | |
| </div> | |
| <div class="flex justify-center space-x-4"> | |
| <button id="btn-show-stage" class="bg-blue-600 hover:bg-blue-500 text-white font-bold py-3 px-8 rounded-xl flex items-center space-x-2 text-lg"> | |
| <span>🖥️ 投放到大螢幕 (本機)</span> | |
| </button> | |
| <button id="btn-reject-task" class="bg-red-600 hover:bg-red-500 text-white font-bold py-3 px-8 rounded-xl flex items-center space-x-2 text-lg shadow-lg"> | |
| <span>🛑 退回重做 (Reject)</span> | |
| </button> | |
| <!-- Future Feature: Send to Students --> | |
| <!-- | |
| <button id="btn-broadcast-all" class="bg-purple-600 hover:bg-purple-500 text-white font-bold py-3 px-8 rounded-xl flex items-center space-x-2 text-lg opacity-50 cursor-not-allowed" title="此功能開發中"> | |
| <span>📡 推送給所有人</span> | |
| </button> | |
| --> | |
| </div> | |
| </div> | |
| <!-- Big Screen Mode (Initially hidden inside modal) --> | |
| <div id="stage-view" class="hidden absolute inset-0 bg-gray-900 flex flex-col items-center justify-center p-10"> | |
| <button onclick="closeStage()" class="absolute top-6 right-6 text-gray-500 hover:text-white text-4xl">✕</button> | |
| <h1 class="text-4xl font-bold text-cyan-400 mb-8" id="stage-title">優秀作品展示</h1> | |
| <div class="bg-black border-2 border-cyan-500/50 rounded-2xl p-10 max-w-6xl w-full shadow-[0_0_50px_rgba(6,182,212,0.2)]"> | |
| <pre class="text-3xl text-green-400 font-mono whitespace-pre-wrap leading-relaxed" id="stage-prompt">...</pre> | |
| </div> | |
| <div class="mt-8 text-2xl text-gray-400"> | |
| Author: <span class="text-white font-bold" id="stage-author">Dave</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!--Group Photo Modal--> | |
| <div id="group-photo-modal" class="fixed inset-0 bg-gray-900/95 backdrop-blur-md z-50 hidden flex flex-col items-center justify-center p-4 transition-opacity duration-300"> | |
| <button onclick="document.getElementById('group-photo-modal').classList.add('hidden')" class="absolute top-6 right-6 text-gray-400 hover:text-white text-4xl z-50">✕</button> | |
| <div class="text-center mb-8 z-10"> | |
| <h2 class="text-3xl md:text-5xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 via-orange-500 to-red-500 tracking-wider drop-shadow-lg"> | |
| 大合照 CLASS PHOTO | |
| </h2> | |
| <p class="text-gray-400 mt-2 font-mono" id="photo-date">2026.01.27</p> | |
| </div> | |
| <div class="absolute top-6 left-6 z-50 flex space-x-4"> | |
| <button id="snapshot-btn" class="bg-white/10 hover:bg-white/20 text-white border border-white/30 font-bold py-2 px-6 rounded-full backdrop-blur-md transition-all flex items-center space-x-2 shadow-lg group"> | |
| <span class="text-2xl group-hover:scale-110 transition-transform">📸</span> | |
| <span>拍照 (Snapshot)</span> | |
| </button> | |
| </div> | |
| <div id="group-photo-container" class="w-full max-w-7xl flex flex-col items-center overflow-y-auto max-h-[80vh] custom-scrollbar relative"> | |
| <!-- Dynamic Content --> | |
| </div> | |
| <!-- Countdown Overlay --> | |
| <div id="snapshot-overlay" class="absolute inset-0 z-[60] hidden flex-col items-center justify-center pointer-events-none"> | |
| <div id="countdown-number" class="text-[150px] font-black text-white drop-shadow-[0_0_50px_rgba(0,0,0,0.8)] animate-pulse">3</div> | |
| </div> | |
| </div> | |
| <div class="min-h-screen p-6 pb-20 bg-gray-900 text-white"> | |
| <!-- Header --> | |
| <header class="flex flex-col md:flex-row justify-between items-center mb-6 bg-gray-800 p-4 rounded-xl border border-gray-700 space-y-4 md:space-y-0 sticky top-0 z-30 shadow-lg"> | |
| <div class="flex items-center space-x-4"> | |
| <h1 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-600"> | |
| 儀表板 <span class="text-xs text-gray-600 font-mono ml-2">v26.01.27</span> | |
| </h1> | |
| <div id="room-info" class="hidden flex items-center space-x-2 bg-black/30 px-3 py-1 rounded-lg border border-gray-700"> | |
| <span class="text-xs text-gray-500 uppercase">Room</span> | |
| <span id="display-room-code" class="text-xl font-mono font-bold text-cyan-400 tracking-widest"></span> | |
| </div> | |
| </div> | |
| <div class="flex space-x-3"> | |
| <div class="flex items-center space-x-2 text-xs text-gray-400 mr-4 border-r border-gray-700 pr-4"> | |
| <div class="flex items-center"><div class="w-3 h-3 bg-gray-700 rounded-sm mr-1"></div> 未開始</div> | |
| <div class="flex items-center"><div class="w-3 h-3 bg-blue-600 rounded-sm mr-1"></div> 進行中</div> | |
| <div class="flex items-center"><div class="w-3 h-3 bg-green-500 rounded-sm mr-1"></div> 已完成</div> | |
| <div class="flex items-center"><div class="w-3 h-3 bg-red-500 animate-pulse rounded-sm mr-1"></div> 卡關 (>5m)</div> | |
| </div> | |
| <button id="group-photo-btn" class="bg-gradient-to-r from-pink-600 to-purple-600 hover:from-pink-500 hover:to-purple-500 text-white font-bold py-2 px-4 rounded-lg transition-all shadow-lg border border-pink-400/30 flex items-center space-x-2"> | |
| <span>📸 大合照</span> | |
| </button> | |
| <button id="nav-admin-btn" class="bg-gray-700 hover:bg-gray-600 text-white font-bold py-2 px-4 rounded-lg transition-all border border-gray-600"> | |
| 管理題目 | |
| </button> | |
| <div id="create-room-container" class="flex items-center space-x-2"> | |
| <input type="text" id="rejoin-room-code" placeholder="代碼" class="bg-gray-900 border border-gray-700 text-white px-3 py-2 rounded-lg w-20 text-center focus:outline-none focus:border-cyan-500"> | |
| <button id="rejoin-room-btn" class="bg-gray-700 hover:bg-gray-600 text-white px-3 py-2 rounded-lg">重回</button> | |
| <button id="create-room-btn" class="bg-purple-600 hover:bg-purple-500 text-white font-bold px-4 py-2 rounded-lg shadow-lg">開房</button> | |
| </div> | |
| </div> | |
| </header> | |
| <!-- Heatmap Content --> | |
| <div id="dashboard-content" class="hidden overflow-x-auto pb-10"> | |
| <table class="w-full border-collapse"> | |
| <thead> | |
| <tr id="heatmap-header"> | |
| <th class="p-3 text-left sticky left-0 bg-gray-800 z-20 border-b border-gray-700 min-w-[150px]">學員 / 關卡</th> | |
| <!-- Challenges headers generated dynamically --> | |
| </tr> | |
| </thead> | |
| <tbody id="heatmap-body"> | |
| <!-- Rows generated dynamically --> | |
| <tr><td colspan="100" class="text-center py-10 text-gray-500">等待資料載入...</td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| export function setupInstructorEvents() { | |
| // Auth Logic | |
| const authBtn = document.getElementById('auth-btn'); | |
| const pwdInput = document.getElementById('instructor-password'); | |
| const authModal = document.getElementById('auth-modal'); | |
| // Default password check | |
| const checkPassword = async () => { | |
| const { verifyInstructorPassword } = await import("../services/classroom.js"); | |
| authBtn.textContent = "驗證中..."; | |
| authBtn.disabled = true; | |
| try { | |
| const isValid = await verifyInstructorPassword(pwdInput.value); | |
| if (isValid) { | |
| authModal.classList.add('hidden'); | |
| // Store session to avoid re-login on reload | |
| sessionStorage.setItem('vibecoding_instructor_auth', 'true'); | |
| } else { | |
| alert('密碼錯誤'); | |
| pwdInput.value = ''; | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| alert("驗證出錯"); | |
| } finally { | |
| authBtn.textContent = "確認進入"; | |
| authBtn.disabled = false; | |
| } | |
| }; | |
| authBtn.addEventListener('click', checkPassword); | |
| pwdInput.addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter') checkPassword(); | |
| }); | |
| const createBtn = document.getElementById('create-room-btn'); | |
| 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 navAdminBtn = document.getElementById('nav-admin-btn'); | |
| const groupPhotoBtn = document.getElementById('group-photo-btn'); | |
| const snapshotBtn = document.getElementById('snapshot-btn'); | |
| let isSnapshotting = false; | |
| // Snapshot Logic | |
| snapshotBtn.addEventListener('click', async () => { | |
| if (isSnapshotting || typeof htmlToImage === 'undefined') { | |
| if (typeof htmlToImage === 'undefined') alert("截圖元件尚未載入,請稍候再試"); | |
| return; | |
| } | |
| isSnapshotting = true; | |
| const overlay = document.getElementById('snapshot-overlay'); | |
| const countEl = document.getElementById('countdown-number'); | |
| const container = document.getElementById('group-photo-container'); | |
| const modal = document.getElementById('group-photo-modal'); | |
| // Close button hide | |
| const closeBtn = modal.querySelector('button'); | |
| if (closeBtn) closeBtn.style.opacity = '0'; | |
| snapshotBtn.style.opacity = '0'; | |
| overlay.classList.remove('hidden'); | |
| overlay.classList.add('flex'); | |
| // Countdown Sequence | |
| const runCountdown = (num) => new Promise(resolve => { | |
| countEl.textContent = num; | |
| countEl.style.transform = 'scale(1.5)'; | |
| countEl.style.opacity = '1'; | |
| // Animation reset | |
| requestAnimationFrame(() => { | |
| countEl.style.transition = 'all 0.5s ease-out'; | |
| countEl.style.transform = 'scale(1)'; | |
| countEl.style.opacity = '0.5'; | |
| setTimeout(resolve, 1000); | |
| }); | |
| }); | |
| await runCountdown(3); | |
| await runCountdown(2); | |
| await runCountdown(1); | |
| // Action! | |
| countEl.textContent = ''; | |
| overlay.classList.add('hidden'); | |
| // 1. Emojis Explosion | |
| const emojis = ['🤘', '✌️', '👍', '🫶', '😎', '🔥']; | |
| const cards = container.querySelectorAll('.group\\/card'); | |
| cards.forEach(card => { | |
| // Find the monster image container | |
| const imgContainer = card.querySelector('.monster-img-container'); | |
| if (!imgContainer) return; | |
| // Random Emoji | |
| const emoji = emojis[Math.floor(Math.random() * emojis.length)]; | |
| const emojiEl = document.createElement('div'); | |
| emojiEl.textContent = emoji; | |
| // Position: Top-Right of the *Image*, slightly overlapping | |
| emojiEl.className = 'absolute -top-2 -right-2 text-2xl animate-bounce z-50 drop-shadow-md transform rotate-12'; | |
| emojiEl.style.animationDuration = '0.6s'; | |
| imgContainer.appendChild(emojiEl); | |
| // Remove after 3s | |
| setTimeout(() => emojiEl.remove(), 3000); | |
| }); | |
| // 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 = ` | |
| <span class="text-lg md:text-2xl font-black font-mono bg-clip-text text-transparent bg-gradient-to-r from-cyan-400 to-purple-400 tracking-wider"> | |
| ${dateStr} VibeCoding 怪獸成長營 | |
| </span> | |
| `; | |
| 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 = ` | |
| <div class="relative"> | |
| <div class="absolute inset-0 bg-yellow-500/20 blur-3xl rounded-full animate-pulse"></div> | |
| <!--Pixel Art Avatar--> | |
| <img src="assets/instructor_avatar.png" class="relative w-48 h-48 md:w-64 md:h-64 object-contain pixel-art drop-shadow-[0_10px_30px_rgba(0,0,0,0.6)] z-10 hover:scale-105 transition-transform duration-300" alt="Instructor"> | |
| <!-- Editable Name Tag --> | |
| <div class="absolute -bottom-8 left-1/2 transform -translate-x-1/2 bg-black/80 backdrop-blur text-yellow-400 px-6 py-2 rounded-full border border-yellow-500/30 shadow-2xl flex items-center justify-center space-x-2 z-30 whitespace-nowrap group-hover:bg-black transition-colors min-w-[150px] max-w-[300px]"> | |
| <span class="text-xl">👑</span> | |
| <input type="text" id="instructor-name-input" | |
| value="${savedName}" | |
| class="bg-transparent border-b border-transparent hover:border-yellow-500/50 focus:border-yellow-500 text-lg font-bold text-yellow-400 text-center focus:outline-none transition-all placeholder-yellow-700" | |
| style="width: ${Math.max(savedName.length * 20, 100)}px;" | |
| onclick="this.select()" | |
| oninput="this.style.width = Math.max(this.value.length * 20, 100) + 'px'" | |
| > | |
| </div> | |
| </div> | |
| `; | |
| 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 = ` | |
| <!--Top Info: Monster Stats--> | |
| <div class="mb-1 text-center bg-gray-900/60 backdrop-blur-sm rounded-lg px-2 py-1 border border-gray-600/30 group-hover/card:bg-gray-800 group-hover/card:border-cyan-500/50 transition-all opacity-80 group-hover/card:opacity-100 transform translate-y-2 group-hover/card:translate-y-0 duration-300"> | |
| <div class="text-[10px] text-gray-300 mb-0.5 whitespace-nowrap">${monster.name.split(' ')[1] || monster.name}</div> | |
| <div class="flex items-center justify-center space-x-2"> | |
| <span class="text-[10px] bg-blue-900/50 text-blue-300 px-1.5 rounded border border-blue-500/30">Lv.${totalCompleted + 1}</span> | |
| <div class="flex items-center text-[10px] text-pink-400 font-bold"> | |
| <span>♥</span> | |
| <span class="ml-0.5">${totalLikes}</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!--Monster Image--> | |
| <div class="monster-img-container relative ${sizeClass} flex items-center justify-center transform group-hover/card:scale-125 transition-transform duration-300" style="animation: float 3s ease-in-out infinite; animation-delay: -${floatDelay}s;"> | |
| <div class="w-full h-full pixel-art drop-shadow-md filter group-hover/card:brightness-110 transition-all"> | |
| ${generateMonsterSVG(monster)} | |
| </div> | |
| </div> | |
| <!--Bottom Info: User Nickname--> | |
| <div class="mt-1 text-center bg-black/60 backdrop-blur-sm rounded-full px-3 py-0.5 border border-gray-600/50 group-hover/card:bg-cyan-900/80 group-hover/card:border-cyan-400 transition-all"> | |
| <div class="text-xs font-bold text-white shadow-black drop-shadow-md whitespace-nowrap tracking-wide">${s.nickname}</div> | |
| </div> | |
| `; | |
| 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); | |
| } | |
| 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'; | |
| }); | |
| // 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'); | |
| rejoinBtn.addEventListener('click', () => { | |
| const code = document.getElementById('rejoin-room-code').value.trim(); | |
| if (!code) return alert('請輸入教室代碼'); | |
| enterRoom(code); | |
| }); | |
| createBtn.addEventListener('click', async () => { | |
| try { | |
| createBtn.disabled = true; | |
| createBtn.textContent = "..."; | |
| const roomCode = await createRoom(); | |
| enterRoom(roomCode); | |
| } catch (error) { | |
| console.error(error); | |
| alert("建立失敗"); | |
| createBtn.disabled = false; | |
| } | |
| }); | |
| // Check Previous Session | |
| 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); | |
| } | |
| function enterRoom(roomCode) { | |
| createContainer.classList.add('hidden'); | |
| roomInfo.classList.remove('hidden'); | |
| dashboardContent.classList.remove('hidden'); | |
| displayRoomCode.textContent = roomCode; | |
| localStorage.setItem('vibecoding_instructor_room', roomCode); | |
| sessionStorage.setItem('vibecoding_instructor_in_room', 'true'); | |
| // Subscribe to updates | |
| subscribeToRoom(roomCode, (students) => { | |
| currentStudents = students; | |
| renderTransposedHeatmap(students); | |
| }); | |
| } | |
| // Modal Events | |
| 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 = 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. | |
| // 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) { | |
| try { | |
| await resetProgress(userId, roomCode, challengeId); | |
| // Close modal | |
| window.closeBroadcast(); | |
| } catch (e) { | |
| console.error(e); | |
| alert('退回失敗'); | |
| } | |
| } | |
| }); | |
| } | |
| /** | |
| * Renders the Transposed Heatmap (Rows=Challenges, Cols=Students) | |
| */ | |
| function renderTransposedHeatmap(students) { | |
| const thead = document.getElementById('heatmap-header'); | |
| const tbody = document.getElementById('heatmap-body'); | |
| if (students.length === 0) { | |
| thead.innerHTML = '<th class="p-4 text-left">等待資料...</th>'; | |
| tbody.innerHTML = '<tr><td class="p-10 text-center text-gray-500">尚無學員加入</td></tr>'; | |
| return; | |
| } | |
| // 1. Render Header (Students) | |
| // Sticky Top for Header Row | |
| // Sticky Left for the first cell ("Challenge/Student") | |
| let headerHtml = ` | |
| <th class="p-3 text-left sticky left-0 top-0 bg-gray-800 z-30 border-b border-gray-600 min-w-[200px] border-r border-gray-700 shadow-md"> | |
| <div class="flex justify-between items-end"> | |
| <span class="text-sm text-gray-400">題目</span> | |
| <span class="text-sm text-cyan-400">學員 (${students.length})</span> | |
| </div> | |
| </th> | |
| `; | |
| students.forEach(student => { | |
| headerHtml += ` | |
| <th class="p-2 text-center sticky top-0 bg-gray-800 z-20 border-b border-gray-700 min-w-[80px] group"> | |
| <div class="flex flex-col items-center space-y-2 py-2"> | |
| <div class="w-8 h-8 rounded-full bg-gradient-to-br from-gray-700 to-gray-600 flex items-center justify-center text-xs font-bold text-white uppercase border border-gray-500 shadow-sm relative"> | |
| ${student.nickname[0]} | |
| <!-- Online Indicator (Simulated) --> | |
| <div class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 border-2 border-gray-800 rounded-full"></div> | |
| </div> | |
| <div class="flex items-center justify-center space-x-1"> | |
| <span class="text-xs text-gray-300 font-medium truncate max-w-[80px] writing-vertical-lr" style="writing-mode: vertical-rl; text-orientation: mixed;"> | |
| ${student.nickname} | |
| </span> | |
| <button onclick="window.confirmKick('${student.id}', '${student.nickname}')" class="text-gray-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity" title="踢出學員"> | |
| 🗑️ | |
| </button> | |
| </div> | |
| </div> | |
| </th> | |
| `; | |
| }); | |
| thead.innerHTML = headerHtml; | |
| // 2. Render Body (Challenges as Rows) | |
| if (cachedChallenges.length === 0) { | |
| tbody.innerHTML = '<tr><td colspan="100" class="text-center py-4">沒有題目資料</td></tr>'; | |
| return; | |
| } | |
| tbody.innerHTML = cachedChallenges.map((c, index) => { | |
| const colors = { beginner: 'cyan', intermediate: 'blue', advanced: 'purple' }; | |
| const color = colors[c.level] || 'gray'; | |
| // Build Row Cells (One per student) | |
| const rowCells = students.map(student => { | |
| const p = student.progress?.[c.id]; | |
| let statusClass = 'bg-gray-800/30 border-gray-800'; // Default | |
| let content = ''; | |
| let action = ''; | |
| if (p) { | |
| if (p.status === 'completed') { | |
| statusClass = 'bg-green-500/20 border-green-500/50 hover:bg-green-500/40 cursor-pointer shadow-[0_0_10px_rgba(34,197,94,0.1)]'; | |
| content = '✅'; | |
| const safePrompt = p.prompt.replace(/"/g, '"').replace(/'/g, "\\'"); | |
| action = `onclick = "window.showBroadcastModal('${student.id}', '${c.id}', '${student.nickname}', '${c.title}', '${safePrompt}')"`; | |
| } else if (p.status === 'started') { | |
| // Check stuck | |
| const startedAt = p.timestamp ? p.timestamp.toDate() : new Date(); | |
| const now = new Date(); | |
| const diffMins = (now - startedAt) / 1000 / 60; | |
| if (diffMins > 5) { | |
| statusClass = 'bg-red-900/50 border-red-500 animate-pulse cursor-help'; | |
| content = '🆘'; | |
| } else { | |
| statusClass = 'bg-blue-600/20 border-blue-500'; | |
| content = '🔵'; | |
| } | |
| } | |
| } | |
| return ` | |
| <td class="p-1 border border-gray-800/50 text-center align-middle h-14 hover:bg-white/5 transition-colors"> | |
| <div class="mx-auto w-10 h-10 rounded-lg border flex items-center justify-center ${statusClass} transition-all duration-300" ${action}> | |
| ${content} | |
| </div> | |
| </td> | |
| `; | |
| }).join(''); | |
| // Row Header (Challenge Title) | |
| return ` | |
| <tr class="hover:bg-gray-800/50 transition-colors"> | |
| <td class="p-3 sticky left-0 bg-gray-900 z-10 border-r border-b border-gray-700 shadow-md"> | |
| <div class="flex items-center justify-between"> | |
| <div class="flex flex-col"> | |
| <span class="text-xs text-${color}-400 font-bold uppercase tracking-wider mb-0.5">${c.level}</span> | |
| <span class="font-bold text-white text-sm truncate max-w-[180px]" title="${c.title}">${index + 1}. ${c.title}</span> | |
| </div> | |
| <!-- Stats (Optional) --> | |
| <!-- <span class="text-xs text-gray-500">0%</span> --> | |
| </div> | |
| </td> | |
| ${rowCells} | |
| </tr> | |
| `; | |
| }).join(''); | |
| } | |
| // Global scope for HTML access | |
| window.showBroadcastModal = (userId, challengeId, nickname, title, prompt) => { | |
| const modal = document.getElementById('broadcast-modal'); | |
| const content = document.getElementById('broadcast-content'); | |
| document.getElementById('broadcast-avatar').textContent = nickname[0]; | |
| document.getElementById('broadcast-author').textContent = nickname; | |
| document.getElementById('broadcast-challenge').textContent = title; | |
| document.getElementById('broadcast-prompt').textContent = prompt; | |
| // Store IDs for actions | |
| modal.dataset.userId = userId; | |
| modal.dataset.challengeId = challengeId; | |
| modal.classList.remove('hidden'); | |
| // Animation trigger | |
| setTimeout(() => { | |
| content.classList.remove('scale-95', 'opacity-0'); | |
| content.classList.add('opacity-100', 'scale-100'); | |
| }, 10); | |
| }; | |