Spaces:
Running
Running
| import { createRoom, subscribeToRoom, getChallenges, resetProgress, removeUser, cleanupOldRooms } from "../services/classroom.js"; | |
| import { loginWithEmail, registerWithEmail, signOutUser, checkInstructorPermission, getInstructors, addInstructor, updateInstructor, removeInstructor } from "../services/auth.js"; | |
| import { generateMonsterSVG, getNextMonster, MONSTER_DEFS } from "../utils/monsterUtils.js"; | |
| // 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(); | |
| window.cachedChallenges = cachedChallenges; // Expose for dashboard | |
| } catch (e) { | |
| console.error("Failed header load", e); | |
| } | |
| return ` | |
| <div id="auth-modal" class="fixed inset-0 bg-gray-900 bg-opacity-95 flex items-center justify-center z-50"> | |
| <div class="bg-gray-800 p-8 rounded-lg shadow-2xl text-center max-w-sm w-full border border-gray-700"> | |
| <div class="text-6xl mb-4 animate-bounce">🔒</div> | |
| <h2 class="text-2xl font-bold text-white mb-2">講師登入</h2> | |
| <p class="text-gray-400 mb-6 text-sm">請輸入帳號密碼登入</p> | |
| <div class="space-y-3 text-left"> | |
| <div> | |
| <label class="text-xs text-gray-500 block mb-1">Email (你的帳號)</label> | |
| <input type="email" id="login-email" class="w-full bg-gray-700 text-white border border-gray-600 rounded px-3 py-2 text-sm focus:outline-none focus:border-cyan-500" placeholder="例如: teacher@school.edu"> | |
| </div> | |
| <div> | |
| <label class="text-xs text-gray-500 block mb-1">密碼</label> | |
| <input type="password" id="login-password" class="w-full bg-gray-700 text-white border border-gray-600 rounded px-3 py-2 text-sm focus:outline-none focus:border-cyan-500" placeholder="輸入密碼"> | |
| </div> | |
| </div> | |
| <div class="mt-6 flex gap-2"> | |
| <button id="login-btn" class="flex-1 bg-cyan-600 hover:bg-cyan-500 text-white font-bold py-2 px-4 rounded transition-colors duration-200"> | |
| 登入 | |
| </button> | |
| <button id="register-btn" class="flex-1 bg-gray-700 hover:bg-gray-600 text-white font-bold py-2 px-4 rounded transition-colors duration-200"> | |
| 註冊 | |
| </button> | |
| </div> | |
| <p class="text-gray-500 text-xs mt-3">如果是第一次使用,請先通知管理員新增您的 Email 到白名單,然後點選「註冊」設定密碼。</p> | |
| <p id="auth-error" class="text-red-400 text-sm mt-4 hidden"></p> | |
| </div> | |
| </div> | |
| <!-- Instructor Management Modal --> | |
| <div id="instructor-modal" class="fixed inset-0 bg-gray-900/95 backdrop-blur z-[80] hidden flex flex-col items-center justify-center p-4"> | |
| <div class="bg-gray-800 rounded-xl border border-gray-700 shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col"> | |
| <div class="p-6 border-b border-gray-700 flex justify-between items-center"> | |
| <h2 class="text-2xl font-bold text-white">👥 管理講師權限 (Manage Instructors)</h2> | |
| <button onclick="document.getElementById('instructor-modal').classList.add('hidden')" class="text-gray-400 hover:text-white text-2xl">✕</button> | |
| </div> | |
| <div class="p-6 flex-1 overflow-y-auto"> | |
| <div class="mb-6 flex space-x-2"> | |
| <input type="email" id="new-inst-email" placeholder="Email (Gmail)" class="bg-gray-900 border border-gray-600 text-white px-4 py-2 rounded flex-1"> | |
| <input type="text" id="new-inst-name" placeholder="姓名" class="bg-gray-900 border border-gray-600 text-white px-4 py-2 rounded w-32"> | |
| <div class="flex items-center space-x-2 text-sm text-gray-300 bg-gray-900 px-3 rounded border border-gray-600"> | |
| <label><input type="checkbox" id="perm-room" checked> 開房</label> | |
| <label><input type="checkbox" id="perm-q" checked> 題目</label> | |
| <label><input type="checkbox" id="perm-inst"> 管理人</label> | |
| </div> | |
| <button id="btn-add-inst" class="bg-green-600 hover:bg-green-500 text-white px-4 py-2 rounded font-bold">新增</button> | |
| </div> | |
| <table class="w-full text-left border-collapse"> | |
| <thead> | |
| <tr class="text-gray-400 border-b border-gray-700"> | |
| <th class="p-3">姓名</th> | |
| <th class="p-3">Email</th> | |
| <th class="p-3">權限</th> | |
| <th class="p-3">動作</th> | |
| </tr> | |
| </thead> | |
| <tbody id="instructor-list-body" class="text-gray-300"> | |
| <!-- Dynamic list --> | |
| </tbody> | |
| </table> | |
| </div> | |
| </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> | |
| <!-- Multi-Prompt Viewer Modal --> | |
| <div id="prompt-list-modal" class="fixed inset-0 bg-black/95 backdrop-blur z-[60] hidden flex flex-col p-6 transition-opacity duration-300"> | |
| <div class="flex justify-between items-center mb-6 border-b border-gray-700 pb-4"> | |
| <div> | |
| <h2 class="text-2xl font-bold text-cyan-400" id="prompt-list-title">提示詞列表</h2> | |
| <p class="text-gray-400 text-sm" id="prompt-list-subtitle">點選下方複選框進行比較 (最多3項)</p> | |
| </div> | |
| <button onclick="document.getElementById('prompt-list-modal').classList.add('hidden')" class="text-gray-400 hover:text-white text-3xl">✕</button> | |
| </div> | |
| <div id="prompt-list-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 overflow-y-auto flex-1 custom-scrollbar pb-20"> | |
| <!-- Dynamic Cards --> | |
| </div> | |
| <!-- Floating Action Footer --> | |
| <div class="absolute bottom-6 left-1/2 transform -translate-x-1/2 flex space-x-4"> | |
| <button id="btn-compare-prompts" class="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500 text-white font-bold py-3 px-8 rounded-full shadow-2xl flex items-center space-x-2 transition-all transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed" disabled> | |
| <span>🔍 比較已選項目 (0/3)</span> | |
| </button> | |
| <label class="flex items-center space-x-2 bg-gray-800 px-4 rounded-full border border-gray-600 cursor-pointer hover:bg-gray-700 transition"> | |
| <input type="checkbox" id="list-anonymous-toggle" class="w-5 h-5 rounded border-gray-500 text-purple-600 focus:ring-purple-500 bg-gray-900"> | |
| <span class="text-gray-300 select-none">隱藏姓名</span> | |
| </label> | |
| </div> | |
| </div> | |
| <!-- Comparison Modal with Annotation Canvas --> | |
| <div id="comparison-modal" class="fixed inset-0 bg-gray-900 z-[70] hidden flex flex-col"> | |
| <!-- Toolbar --> | |
| <div class="bg-gray-800 p-4 border-b border-gray-700 flex justify-between items-center shadow-lg z-50"> | |
| <h2 class="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-400"> | |
| 提示詞比較與註記 | |
| </h2> | |
| <div class="flex items-center space-x-4 bg-gray-900 rounded-full px-4 py-1.5 border border-gray-600"> | |
| <!-- Anonymous Toggle --> | |
| <button id="btn-anonymous-toggle" class="bg-gray-700 hover:bg-gray-600 text-white font-bold py-1 px-3 rounded text-xs transition-colors mr-2" onclick="toggleAnonymous(this)"> | |
| 👀 隱藏姓名 | |
| </button> | |
| <!-- Pen Tools --> | |
| <button class="annotation-tool ring-white p-2 rounded-full hover:bg-gray-700 transition-colors ring-2 focus:outline-none" data-tool="pen" data-color="#ef4444" onclick="setPenTool('pen', '#ef4444', this)"> | |
| <div class="w-4 h-4 rounded-full bg-red-500"></div> | |
| </button> | |
| <button class="annotation-tool p-2 rounded-full hover:bg-gray-700 transition-colors ring-2 ring-transparent focus:outline-none" data-tool="pen" data-color="#3b82f6" onclick="setPenTool('pen', '#3b82f6', this)"> | |
| <div class="w-4 h-4 rounded-full bg-blue-500"></div> | |
| </button> | |
| <button class="annotation-tool p-2 rounded-full hover:bg-gray-700 transition-colors ring-2 ring-transparent focus:outline-none" data-tool="pen" data-color="#eab308" onclick="setPenTool('pen', '#eab308', this)"> | |
| <div class="w-4 h-4 rounded-full bg-yellow-500"></div> | |
| </button> | |
| <div class="w-px h-6 bg-gray-600 mx-2"></div> | |
| <!-- Eraser --> | |
| <button class="annotation-tool p-2 rounded-full hover:bg-gray-700 transition-colors ring-2 ring-transparent focus:outline-none flex items-center justify-center" data-tool="eraser" onclick="setPenTool('eraser', null, this)" title="橡皮擦"> | |
| <svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="w-6 h-6" fill="white"> | |
| <rect x="75.264" y="277.317" transform="matrix(0.7071 -0.7071 0.7071 0.7071 -229.5849 204.8263)" style="fill:#F2EBD9;" width="114.382" height="204.46"/> | |
| <path style="fill:#6DA8D6;" d="M281.767,297.776l164.291-164.29l-67.544-67.544l-227.84,227.84l67.544,67.544l40.44-40.44 c3.19-3.19,8.362-3.191,11.553-0.002l11.555-11.555C278.576,306.138,278.577,300.966,281.767,297.776z"/> | |
| <g> | |
| <path style="fill:#185F8D;" d="M293.321,309.331c-1.595,1.595-3.686,2.393-5.777,2.393s-4.182-0.797-5.777-2.393 c-0.001-0.002-11.556,11.553-11.556,11.553c3.193,3.192,3.193,8.365,0.002,11.556l-40.44,40.44l34.663,34.664l227.84-227.84 l-34.664-34.663L293.321,309.331z"/> | |
| <rect x="74.609" y="126.462" transform="matrix(0.7071 -0.7071 0.7071 0.7071 -37.7149 210.8935)" style="fill:#185F8D;" width="322.208" height="49.021"/> | |
| </g> | |
| <path style="fill:#082947;" d="M509.607,173.926L338.073,2.393C336.542,0.861,334.463,0,332.297,0c-2.167,0-4.245,0.861-5.777,2.393 L87.126,241.787c-3.191,3.191-3.191,8.364,0,11.554l1.926,1.926L2.393,341.926c-3.191,3.191-3.191,8.364,0,11.554l156.127,156.127 c1.595,1.595,3.686,2.393,5.777,2.393c2.09,0,4.182-0.797,5.777-2.393l86.659-86.659l1.926,1.926 c1.595,1.595,3.686,2.393,5.777,2.393s4.182-0.797,5.777-2.393L509.607,185.48c1.533-1.532,2.393-3.61,2.393-5.777 C512,177.537,511.139,175.458,509.607,173.926z M264.436,407.543l-34.663-34.664l40.44-40.44c3.191-3.191,3.191-8.364,0-11.554 c-3.193-3.191-8.364-3.19-11.554,0l-40.44,40.44l-67.544-67.544l227.84-227.84l67.544,67.544l-164.292,164.29 c-3.191,3.19-3.191,8.362-0.001,11.553c1.597,1.597,3.688,2.394,5.779,2.394s4.182-0.797,5.777-2.393l164.291-164.29l34.664,34.663 L264.436,407.543z M366.96,54.387l-227.84,227.84l-34.663-34.663l227.84-227.84L366.96,54.387z M245.178,411.394l-80.881,80.881 L19.725,347.702l80.881-80.881L245.178,411.394z"/> | |
| </svg> | |
| </button> | |
| <div class="w-px h-6 bg-gray-600 mx-2"></div> | |
| <!-- Size Selector --> | |
| <div class="flex items-center space-x-1 bg-gray-800 rounded p-0.5"> | |
| <button class="size-btn px-2 py-0.5 text-xs text-gray-400 hover:text-white rounded hover:bg-gray-700" onclick="setPenSize(3, this)" data-size="3">S</button> | |
| <button class="size-btn px-2 py-0.5 text-xs text-gray-400 hover:text-white rounded hover:bg-gray-700" onclick="setPenSize(8, this)" data-size="8">M</button> | |
| <button class="size-btn px-2 py-0.5 text-xs text-gray-400 hover:text-white rounded hover:bg-gray-700" onclick="setPenSize(16, this)" data-size="16">L</button> | |
| </div> | |
| <div class="w-px h-6 bg-gray-600 mx-2"></div> | |
| <button onclick="clearCanvas()" class="text-gray-400 hover:text-white text-sm font-bold px-2"> | |
| 清除 | |
| </button> | |
| </div> | |
| <button onclick="closeComparison()" class="text-gray-400 hover:text-white text-3xl">✕</button> | |
| </div> | |
| <!-- Canvas Container --> | |
| <div class="flex-1 relative overflow-hidden bg-gray-900" id="comparison-container"> | |
| <!-- Grid Content (Will be behind canvas) --> | |
| <div id="comparison-grid" class="absolute inset-0 grid gap-0 p-0 z-0"> | |
| <!-- Dynamic Columns --> | |
| </div> | |
| <!-- Canvas Layer --> | |
| <canvas id="annotation-canvas" class="absolute inset-0 z-10 touch-none cursor-none"></canvas> | |
| <!-- Custom Tool Cursor --> | |
| <div id="tool-cursor" class="absolute hidden rounded-full pointer-events-none z-50 transform -translate-x-1/2 -translate-y-1/2 will-change-transform shadow-sm border border-white/50"></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> | |
| <button id="leave-room-btn" class="ml-2 bg-red-900/50 hover:bg-red-800 text-red-300 hover:text-white px-2 py-0.5 rounded text-xs border border-red-800/50 transition-colors" title="離開教室"> | |
| 🚪 離開 | |
| </button> | |
| </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="hidden 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-instructors-btn" class="hidden bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2 px-4 rounded-lg transition-all border border-indigo-400/30 mr-2"> | |
| 👥 管理講師 | |
| </button> | |
| <button id="nav-admin-btn" class="hidden 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> | |
| <!-- Fixed Bottom Right Controls --> | |
| <div class="fixed bottom-6 right-6 z-40 flex flex-col space-y-4"> | |
| <button id="btn-open-gallery" class="w-14 h-14 rounded-full bg-gray-800/80 backdrop-blur-md border border-cyan-500/30 text-cyan-400 shadow-[0_0_15px_rgba(6,182,212,0.3)] hover:scale-110 hover:bg-cyan-900/50 hover:border-cyan-400 transition-all duration-300 flex items-center justify-center group" title="怪獸圖鑑"> | |
| <span class="text-2xl filter drop-shadow-[0_0_5px_rgba(34,211,238,0.8)] group-hover:animate-bounce">👾</span> | |
| </button> | |
| <button id="logout-btn" class="w-14 h-14 rounded-full bg-gray-800/80 backdrop-blur-md border border-red-500/30 text-red-400 shadow-[0_0_15px_rgba(239,68,68,0.3)] hover:scale-110 hover:bg-red-900/50 hover:border-red-400 transition-all duration-300 flex items-center justify-center" title="登出"> | |
| <span class="text-xl filter drop-shadow-[0_0_5px_rgba(248,113,113,0.8)]">🚪</span> | |
| </button> | |
| </div> | |
| <!-- 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() { | |
| // Utility for cleaning prompt indentation | |
| // Utility for cleaning prompt indentation | |
| // Utility for cleaning text for display | |
| function cleanText(str, isCode = false) { | |
| if (!str) return ''; | |
| // 1. Convert HTML entities if present (common in innerHTML injection flows) | |
| str = str.replace(/ /g, ' '); | |
| str = str.replace(/<br\s*\/?>/gi, '\n'); | |
| // 2. Normalize line endings | |
| str = str.replace(/\r\n/g, '\n'); | |
| if (isCode) { | |
| // Smart Dedent for Code (Preserve relative indent) | |
| while (str.startsWith('\n')) str = str.slice(1); | |
| str = str.trimEnd(); | |
| const lines = str.split('\n'); | |
| if (lines.length === 0) return ''; | |
| let minIndent = null; | |
| for (const line of lines) { | |
| // Determine indent level | |
| const content = line.replace(/^[\s\u3000\u00A0]+/, ''); | |
| if (content.length === 0) continue; // Skip empty/whitespace-only lines | |
| const currentIndent = line.length - content.length; | |
| if (minIndent === null || currentIndent < minIndent) { | |
| minIndent = currentIndent; | |
| } | |
| } | |
| if (minIndent === null) return str; | |
| return lines.map(line => { | |
| if (line.trim().length === 0) return ''; | |
| return line.slice(minIndent); | |
| }).join('\n'); | |
| } else { | |
| // Aggressive Flatten for Text Prompts (Force Left Align) | |
| return str.split('\n') | |
| .map(line => line.replace(/^[\s\u3000\u00A0]+/g, '')) // Regex remove ALL leading whitespace (Space, Tab, FullWidth, NBSP) | |
| .filter((line, index, arr) => { | |
| // Remove leading/trailing empty lines | |
| if (line.trim() === '' && (index === 0 || index === arr.length - 1)) return false; | |
| return true; | |
| }) | |
| .join('\n') | |
| .trim(); | |
| } | |
| } | |
| 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; | |
| // 1. Create Room Permission | |
| if (instructor.permissions?.includes('create_room')) { | |
| createBtn.classList.remove('hidden', 'opacity-50', 'cursor-not-allowed'); | |
| createBtn.disabled = false; | |
| } else { | |
| createBtn.classList.add('opacity-50', 'cursor-not-allowed'); | |
| createBtn.disabled = true; | |
| createBtn.title = "無此權限"; | |
| } | |
| // 2. Add Question Permission (Admin Button) | |
| if (instructor.permissions?.includes('add_question')) { | |
| navAdminBtn.classList.remove('hidden'); | |
| } else { | |
| navAdminBtn.classList.add('hidden'); | |
| } | |
| // 3. Manage Instructors Permission | |
| if (instructor.permissions?.includes('manage_instructors')) { | |
| navInstBtn.classList.remove('hidden'); | |
| } else { | |
| navInstBtn.classList.add('hidden'); | |
| } | |
| }; | |
| // Email/Password Auth Logic | |
| if (loginBtn && registerBtn) { | |
| // Login Handler | |
| loginBtn.addEventListener('click', async () => { | |
| const email = loginEmailInput.value; | |
| const password = loginPasswordInput.value; | |
| if (!email || !password) { | |
| authErrorMsg.textContent = "請輸入 Email 和密碼"; | |
| authErrorMsg.classList.remove('hidden'); | |
| return; | |
| } | |
| try { | |
| loginBtn.disabled = true; | |
| loginBtn.classList.add('opacity-50'); | |
| authErrorMsg.classList.add('hidden'); | |
| const user = await loginWithEmail(email, password); | |
| const instructorData = await checkInstructorPermission(user); | |
| if (instructorData) { | |
| authModal.classList.add('hidden'); | |
| checkPermissions(instructorData); | |
| localStorage.setItem('vibecoding_instructor_name', instructorData.name); | |
| } else { | |
| authErrorMsg.textContent = "未授權的帳號 (Unauthorized Account)"; | |
| authErrorMsg.classList.remove('hidden'); | |
| await signOutUser(); | |
| } | |
| } catch (error) { | |
| console.error(error); | |
| let msg = error.code || error.message; | |
| if (error.code === 'auth/invalid-credential' || error.code === 'auth/wrong-password' || error.code === 'auth/user-not-found') { | |
| msg = "帳號或密碼錯誤。"; | |
| } | |
| authErrorMsg.textContent = "登入失敗: " + msg; | |
| authErrorMsg.classList.remove('hidden'); | |
| } finally { | |
| loginBtn.disabled = false; | |
| loginBtn.classList.remove('opacity-50'); | |
| } | |
| }); | |
| // Forgot Password Handler | |
| const forgotBtn = document.createElement('button'); | |
| forgotBtn.textContent = "忘記密碼?"; | |
| forgotBtn.className = "text-sm text-gray-400 hover:text-white mt-2 underline block mx-auto"; // Centered link | |
| // Insert after auth-error message or append to modal content? | |
| // Appending to the parent of Login Button seems best, or just below it. | |
| // The modal structure in index.html is needed to know exact placement. | |
| // Assuming loginBtn is inside a flex column form. | |
| loginBtn.parentNode.insertBefore(forgotBtn, loginBtn.nextSibling); | |
| forgotBtn.addEventListener('click', async () => { | |
| const email = loginEmailInput.value; | |
| if (!email) { | |
| authErrorMsg.textContent = "請先在上欄輸入 Email 以發送重設信"; | |
| authErrorMsg.classList.remove('hidden'); | |
| return; | |
| } | |
| if (!confirm(`確定要發送重設密碼信件至 ${email} 嗎?`)) return; | |
| try { | |
| // Dynamically import to avoid top-level dependency if not needed | |
| const { resetPassword } = await import("../services/auth.js"); | |
| await resetPassword(email); | |
| alert(`已發送重設密碼信件至 ${email},請查收信箱並依照指示重設密碼。`); | |
| authErrorMsg.classList.add('hidden'); | |
| } catch (e) { | |
| console.error(e); | |
| let msg = e.message; | |
| if (e.code === 'auth/user-not-found') msg = "找不到此帳號,請確認 Email 是否正確。"; | |
| authErrorMsg.textContent = "發送失敗: " + msg; | |
| authErrorMsg.classList.remove('hidden'); | |
| } | |
| }); | |
| // Register Handler | |
| registerBtn.addEventListener('click', async () => { | |
| const email = loginEmailInput.value; | |
| const password = loginPasswordInput.value; | |
| if (!email || !password) { | |
| authErrorMsg.textContent = "請輸入 Email 和密碼"; | |
| authErrorMsg.classList.remove('hidden'); | |
| return; | |
| } | |
| try { | |
| registerBtn.disabled = true; | |
| registerBtn.classList.add('opacity-50'); | |
| authErrorMsg.classList.add('hidden'); | |
| // Try to create auth account | |
| const user = await registerWithEmail(email, password); | |
| // Check if this email is in our whitelist | |
| const instructorData = await checkInstructorPermission(user); | |
| if (instructorData) { | |
| authModal.classList.add('hidden'); | |
| checkPermissions(instructorData); | |
| localStorage.setItem('vibecoding_instructor_name', instructorData.name); | |
| alert("註冊成功!"); | |
| } else { | |
| // Auth created but not in whitelist | |
| authErrorMsg.textContent = "註冊成功,但您的 Email 未被列入講師名單。請聯繫管理員。"; | |
| authErrorMsg.classList.remove('hidden'); | |
| await signOutUser(); | |
| } | |
| } catch (error) { | |
| console.error(error); | |
| let msg = error.code || error.message; | |
| if (error.code === 'auth/email-already-in-use') { | |
| msg = "此 Email 已被註冊,請直接登入。"; | |
| } | |
| authErrorMsg.textContent = "註冊失敗: " + msg; | |
| authErrorMsg.classList.remove('hidden'); | |
| } finally { | |
| registerBtn.disabled = false; | |
| registerBtn.classList.remove('opacity-50'); | |
| } | |
| }); | |
| } | |
| // Create Room | |
| // Create Room | |
| createBtn.addEventListener('click', async () => { | |
| // 4-Digit Room Code | |
| const roomCode = Math.floor(1000 + Math.random() * 9000).toString(); | |
| try { | |
| // Ensure roomInfo is visible | |
| const roomInfo = document.getElementById('room-info'); | |
| const displayRoomCode = document.getElementById('display-room-code'); | |
| const createContainer = document.getElementById('create-room-container'); | |
| const dashboardContent = document.getElementById('dashboard-content'); | |
| await createRoom(roomCode, currentInstructor ? currentInstructor.name : 'Unknown'); | |
| // Trigger cleanup of old rooms | |
| cleanupOldRooms(); | |
| displayRoomCode.textContent = roomCode; | |
| // Store in LocalStorage | |
| localStorage.setItem('vibecoding_room_code', roomCode); | |
| localStorage.setItem('vibecoding_is_host', 'true'); | |
| // Unified Entry Logic (Ensures Group Photo button is shown) | |
| console.log("Room created, calling enterRoom..."); | |
| enterRoom(roomCode); | |
| } catch (e) { | |
| console.error(e); | |
| alert("無法建立教室: " + e.message); | |
| } | |
| }); | |
| // Rejoin Room | |
| const rejoinBtn = document.getElementById('rejoin-room-btn'); | |
| if (rejoinBtn) { | |
| rejoinBtn.addEventListener('click', async () => { | |
| const inputCode = document.getElementById('rejoin-room-code').value.trim().toUpperCase(); | |
| if (!inputCode) return alert("請輸入代碼"); | |
| try { | |
| // Ensure roomInfo is visible | |
| const roomInfo = document.getElementById('room-info'); | |
| const displayRoomCode = document.getElementById('display-room-code'); | |
| const createContainer = document.getElementById('create-room-container'); | |
| const dashboardContent = document.getElementById('dashboard-content'); | |
| // Check if room exists first (optional, subscribe handles it usually) | |
| displayRoomCode.textContent = inputCode; | |
| localStorage.setItem('vibecoding_room_code', inputCode); | |
| // Unified Entry Logic | |
| console.log("Rejoining room, calling enterRoom..."); | |
| enterRoom(inputCode); | |
| } catch (e) { | |
| alert("重回失敗: " + e.message); | |
| } | |
| }); | |
| } | |
| // Leave Room | |
| const leaveBtn = document.getElementById('leave-room-btn'); | |
| if (leaveBtn) { | |
| leaveBtn.addEventListener('click', () => { | |
| const roomInfo = document.getElementById('room-info'); | |
| const createContainer = document.getElementById('create-room-container'); | |
| const dashboardContent = document.getElementById('dashboard-content'); | |
| const displayRoomCode = document.getElementById('display-room-code'); | |
| localStorage.removeItem('vibecoding_room_code'); | |
| localStorage.removeItem('vibecoding_is_host'); | |
| displayRoomCode.textContent = ''; | |
| roomInfo.classList.add('hidden'); | |
| dashboardContent.classList.add('hidden'); | |
| createContainer.classList.remove('hidden'); | |
| // Unsubscribe logic if needed, usually auto-handled by new subscription or page reload | |
| window.location.reload(); | |
| }); | |
| } | |
| // Nav to Admin | |
| if (navAdminBtn) { | |
| navAdminBtn.addEventListener('click', () => { | |
| localStorage.setItem('vibecoding_admin_referer', 'instructor'); | |
| window.location.hash = '#admin'; | |
| }); | |
| } | |
| // Handle Instructor Management | |
| navInstBtn.addEventListener('click', async () => { | |
| const modal = document.getElementById('instructor-modal'); | |
| const listBody = document.getElementById('instructor-list-body'); | |
| // Load list | |
| const instructors = await getInstructors(); | |
| listBody.innerHTML = instructors.map(inst => ` | |
| <tr class="border-b border-gray-700 hover:bg-gray-800"> | |
| <td class="p-3">${inst.name}</td> | |
| <td class="p-3 font-mono text-sm text-cyan-400">${inst.email}</td> | |
| <td class="p-3 text-xs"> | |
| ${inst.permissions?.map(p => { | |
| const map = { create_room: '開房', add_question: '題目', manage_instructors: '管理' }; | |
| return `<span class="bg-gray-700 px-2 py-1 rounded mr-1">${map[p] || p}</span>`; | |
| }).join('')} | |
| </td> | |
| <td class="p-3"> | |
| ${inst.role === 'admin' ? '<span class="text-gray-500 italic">不可移除</span>' : | |
| `<button class="text-red-400 hover:text-red-300" onclick="window.removeInst('${inst.email}')">移除</button>`} | |
| </td> | |
| </tr> | |
| `).join(''); | |
| modal.classList.remove('hidden'); | |
| }); | |
| // Add New Instructor | |
| const addInstBtn = document.getElementById('btn-add-inst'); | |
| if (addInstBtn) { | |
| addInstBtn.addEventListener('click', async () => { | |
| const email = document.getElementById('new-inst-email').value.trim(); | |
| const name = document.getElementById('new-inst-name').value.trim(); | |
| if (!email || !name) return alert("請輸入完整資料"); | |
| const perms = []; | |
| if (document.getElementById('perm-room').checked) perms.push('create_room'); | |
| if (document.getElementById('perm-q').checked) perms.push('add_question'); | |
| if (document.getElementById('perm-inst').checked) perms.push('manage_instructors'); | |
| try { | |
| await addInstructor(email, name, perms); | |
| alert("新增成功"); | |
| navInstBtn.click(); // Reload list | |
| document.getElementById('new-inst-email').value = ''; | |
| document.getElementById('new-inst-name').value = ''; | |
| } catch (e) { | |
| alert("新增失敗: " + e.message); | |
| } | |
| }); | |
| // Global helper for remove (hacky but works for simple onclick) | |
| window.removeInst = async (email) => { | |
| if (confirm(`確定移除 ${email}?`)) { | |
| try { | |
| await removeInstructor(email); | |
| navInstBtn.click(); // Reload | |
| } catch (e) { | |
| alert(e.message); | |
| } | |
| } | |
| }; | |
| // Auto Check Auth (Persistence) | |
| // We rely on Firebase Auth state observer instead of session storage for security? | |
| // Or we can just check if user is already signed in. | |
| import("../services/firebase.js").then(async ({ auth }) => { | |
| // Handle Redirect Result first | |
| try { | |
| console.log("Initializing Auth Check..."); | |
| const { handleRedirectResult } = await import("../services/auth.js"); | |
| const redirectUser = await handleRedirectResult(); | |
| if (redirectUser) console.log("Redirect User Found:", redirectUser.email); | |
| } catch (e) { console.warn("Redirect check failed", e); } | |
| auth.onAuthStateChanged(async (user) => { | |
| console.log("Auth State Changed to:", user ? user.email : "Logged Out"); | |
| if (user) { | |
| try { | |
| console.log("Checking permissions for:", user.email); | |
| const instructorData = await checkInstructorPermission(user); | |
| console.log("Permission Result:", instructorData); | |
| if (instructorData) { | |
| console.log("Hiding Modal and Setting Permissions..."); | |
| authModal.classList.add('hidden'); | |
| checkPermissions(instructorData); | |
| // Auto-Restore Room View if exists | |
| const savedRoomCode = localStorage.getItem('vibecoding_room_code'); | |
| if (savedRoomCode) { | |
| console.log("Restoring Room Session:", savedRoomCode); | |
| // Restore Room Session using the unified function | |
| // This ensures all UI elements (including Group Photo button) are shown correctly | |
| console.log("Calling enterRoom to restore session..."); | |
| enterRoom(savedRoomCode); | |
| } | |
| } 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 | |
| } | |
| } catch (e) { | |
| console.error("Permission Check Failed:", e); | |
| authErrorMsg.textContent = "權限檢查失敗: " + e.message; | |
| authErrorMsg.classList.remove('hidden'); | |
| } | |
| } else { | |
| authModal.classList.remove('hidden'); | |
| } | |
| }); | |
| }); | |
| // Define Kick Function globally (robust against auth flow) | |
| window.confirmKick = async (userId, nickname) => { | |
| if (confirm(`確定要踢出 ${nickname} 嗎?此動作無法復原。`)) { | |
| try { | |
| const { removeUser } = await import("../services/classroom.js"); | |
| await removeUser(userId); | |
| // UI will update automatically via subscribeToRoom | |
| } catch (e) { | |
| console.error("Kick failed:", e); | |
| alert("移除失敗"); | |
| } | |
| } | |
| }; | |
| // Snapshot Logic | |
| // Snapshot Logic | |
| if (snapshotBtn) { | |
| snapshotBtn.addEventListener('click', async () => { | |
| if (isSnapshotting || typeof htmlToImage === 'undefined') { | |
| if (typeof htmlToImage === 'undefined') alert("截圖元件尚未載入,請稍候再試"); | |
| return; | |
| } | |
| isSnapshotting = true; | |
| const overlay = document.getElementById('snapshot-overlay'); | |
| const countEl = document.getElementById('countdown-number'); | |
| const container = document.getElementById('group-photo-container'); | |
| const modal = document.getElementById('group-photo-modal'); | |
| // Close button hide | |
| const closeBtn = modal.querySelector('button'); | |
| if (closeBtn) closeBtn.style.opacity = '0'; | |
| snapshotBtn.style.opacity = '0'; | |
| overlay.classList.remove('hidden'); | |
| overlay.classList.add('flex'); | |
| // Countdown Sequence | |
| const runCountdown = (num) => new Promise(resolve => { | |
| countEl.textContent = num; | |
| countEl.style.transform = 'scale(1.5)'; | |
| countEl.style.opacity = '1'; | |
| // Animation reset | |
| requestAnimationFrame(() => { | |
| countEl.style.transition = 'all 0.5s ease-out'; | |
| countEl.style.transform = 'scale(1)'; | |
| countEl.style.opacity = '0.5'; | |
| setTimeout(resolve, 1000); | |
| }); | |
| }); | |
| await runCountdown(3); | |
| await runCountdown(2); | |
| await runCountdown(1); | |
| // Action! | |
| countEl.textContent = ''; | |
| overlay.classList.add('hidden'); | |
| // 1. Emojis Explosion | |
| const emojis = ['🤘', '✌️', '👍', '🫶', '😎', '🔥']; | |
| const cards = container.querySelectorAll('.group\\/card'); | |
| cards.forEach(card => { | |
| // Find the monster image container | |
| const imgContainer = card.querySelector('.monster-img-container'); | |
| if (!imgContainer) return; | |
| // Random Emoji | |
| const emoji = emojis[Math.floor(Math.random() * emojis.length)]; | |
| const emojiEl = document.createElement('div'); | |
| emojiEl.textContent = emoji; | |
| // Position: Top-Right of the *Image*, slightly overlapping | |
| emojiEl.className = 'absolute -top-2 -right-2 text-2xl animate-bounce z-50 drop-shadow-md transform rotate-12'; | |
| emojiEl.style.animationDuration = '0.6s'; | |
| imgContainer.appendChild(emojiEl); | |
| // Remove after 3s | |
| setTimeout(() => emojiEl.remove(), 3000); | |
| }); | |
| // 2. Capture using html-to-image | |
| setTimeout(async () => { | |
| try { | |
| // Flash Effect | |
| const flash = document.createElement('div'); | |
| flash.className = 'fixed inset-0 bg-white z-[100] transition-opacity duration-300 pointer-events-none'; | |
| document.body.appendChild(flash); | |
| setTimeout(() => flash.style.opacity = '0', 50); | |
| setTimeout(() => flash.remove(), 300); | |
| // Use htmlToImage.toPng | |
| const dataUrl = await htmlToImage.toPng(container, { | |
| backgroundColor: '#111827', | |
| pixelRatio: 2, | |
| cacheBust: true, | |
| }); | |
| // Download | |
| const link = document.createElement('a'); | |
| const dateStr = new Date().toISOString().slice(0, 10); | |
| link.download = `VIBE_Class_Photo_${dateStr}.png`; | |
| link.href = dataUrl; | |
| link.click(); | |
| } catch (e) { | |
| console.error("Snapshot failed:", e); | |
| alert("截圖失敗 (請嘗試手動截圖/PrtSc)\n原因: " + e.message); | |
| } finally { | |
| // Restore UI | |
| if (closeBtn) closeBtn.style.opacity = '1'; | |
| snapshotBtn.style.opacity = '1'; | |
| isSnapshotting = false; | |
| } | |
| }, 600); // Slight delay for emojis to appear | |
| }); | |
| } | |
| // Group Photo Logic | |
| if (groupPhotoBtn) { | |
| 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); | |
| } | |
| // Gallery Logic | |
| document.getElementById('btn-open-gallery').addEventListener('click', () => { | |
| window.open('monster_preview.html', '_blank'); | |
| }); | |
| // Logout Logic | |
| document.getElementById('logout-btn').addEventListener('click', async () => { | |
| if (confirm('確定要登出講師模式嗎? (將會回到首頁)')) { | |
| await signOutUser(); | |
| sessionStorage.removeItem('vibecoding_instructor_in_room'); | |
| sessionStorage.removeItem('vibecoding_admin_referer'); | |
| window.location.hash = ''; | |
| window.location.reload(); | |
| } | |
| }); | |
| // Check Previous Session (Handled by onAuthStateChanged now) | |
| // if (sessionStorage.getItem('vibecoding_instructor_auth') === 'true') { | |
| // authModal.classList.add('hidden'); | |
| // } | |
| // Check Active Room State | |
| // Module-level variable to track subscription (Moved to top) | |
| window.enterRoom = function (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_room_code', roomCode); | |
| sessionStorage.setItem('vibecoding_instructor_in_room', 'true'); | |
| // Unsubscribe previous if any | |
| if (roomUnsubscribe) roomUnsubscribe(); | |
| // Subscribe to updates | |
| roomUnsubscribe = subscribeToRoom(roomCode, (students) => { | |
| currentStudents = students; | |
| renderTransposedHeatmap(students); | |
| }); | |
| } | |
| // Leave Room Logic | |
| document.getElementById('leave-room-btn').addEventListener('click', () => { | |
| if (confirm('確定要離開目前教室嗎?(不會刪除教室資料,僅回到選擇介面)')) { | |
| // Unsubscribe | |
| if (roomUnsubscribe) { | |
| roomUnsubscribe(); | |
| roomUnsubscribe = null; | |
| } | |
| // UI Reset | |
| createContainer.classList.remove('hidden'); | |
| roomInfo.classList.add('hidden'); | |
| dashboardContent.classList.add('hidden'); | |
| document.getElementById('group-photo-btn').classList.add('hidden'); // Hide photo button | |
| // Clear Data Display | |
| document.getElementById('heatmap-body').innerHTML = '<tr><td colspan="100" class="text-center py-10 text-gray-500">等待資料載入...</td></tr>'; | |
| document.getElementById('heatmap-header').innerHTML = '<th class="p-3 text-left sticky left-0 bg-gray-800 z-20 border-b border-gray-700 min-w-[150px]">學員 / 關卡</th>'; | |
| // State Clear | |
| sessionStorage.removeItem('vibecoding_instructor_in_room'); | |
| localStorage.removeItem('vibecoding_room_code'); | |
| } | |
| }); | |
| // Modal Events | |
| window.showBroadcastModal = (userId, challengeId) => { | |
| const modal = document.getElementById('broadcast-modal'); | |
| const content = document.getElementById('broadcast-content'); | |
| // Find Data | |
| const student = currentStudents.find(s => s.id === userId); | |
| if (!student) return alert('找不到學員資料'); | |
| const p = student.progress ? student.progress[challengeId] : null; | |
| if (!p) return alert('找不到該作品資料'); | |
| const challenge = cachedChallenges.find(c => c.id === challengeId); | |
| const title = challenge ? challenge.title : '未知題目'; | |
| // Populate UI | |
| document.getElementById('broadcast-avatar').textContent = student.nickname[0] || '?'; | |
| document.getElementById('broadcast-author').textContent = student.nickname; | |
| document.getElementById('broadcast-challenge').textContent = title; | |
| document.getElementById('broadcast-prompt').textContent = p.prompt || '(無內容)'; | |
| // Store IDs for Actions (Reject/BroadcastAll) | |
| modal.dataset.userId = userId; | |
| modal.dataset.challengeId = challengeId; | |
| // Show | |
| modal.classList.remove('hidden'); | |
| setTimeout(() => { | |
| content.classList.remove('scale-95', 'opacity-0'); | |
| content.classList.add('opacity-100', 'scale-100'); | |
| }, 10); | |
| }; | |
| window.closeBroadcast = () => { | |
| const modal = document.getElementById('broadcast-modal'); | |
| const content = document.getElementById('broadcast-content'); | |
| content.classList.remove('opacity-100', 'scale-100'); | |
| content.classList.add('scale-95', 'opacity-0'); | |
| setTimeout(() => modal.classList.add('hidden'), 300); | |
| }; | |
| window.openStage = (prompt, author) => { | |
| document.getElementById('broadcast-content').classList.add('hidden'); | |
| const stage = document.getElementById('stage-view'); | |
| stage.classList.remove('hidden'); | |
| document.getElementById('stage-prompt').textContent = cleanText(prompt || ''); | |
| document.getElementById('stage-author').textContent = author; | |
| }; | |
| window.closeStage = () => { | |
| document.getElementById('stage-view').classList.add('hidden'); | |
| document.getElementById('broadcast-content').classList.remove('hidden'); | |
| }; | |
| document.getElementById('btn-show-stage').addEventListener('click', () => { | |
| const prompt = document.getElementById('broadcast-prompt').textContent; | |
| const author = document.getElementById('broadcast-author').textContent; | |
| window.openStage(prompt, author); | |
| }); | |
| // Reject Logic | |
| document.getElementById('btn-reject-task').addEventListener('click', async () => { | |
| if (!confirm('確定要退回此題目讓學員重做嗎?')) return; | |
| // We need student ID (userId) and Challenge ID. | |
| // 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_room_code'); | |
| if (userId && challengeId && roomCode) { | |
| try { | |
| await resetProgress(userId, roomCode, challengeId); | |
| alert('已成功退回,學員可重新作答'); | |
| // Close modal | |
| window.closeBroadcast(); | |
| } catch (e) { | |
| console.error('退回失敗:', e); | |
| alert('退回失敗: ' + e.message); | |
| } | |
| } | |
| }); | |
| // Prompt Viewer Logic | |
| window.openPromptList = (type, id, title) => { | |
| const modal = document.getElementById('prompt-list-modal'); | |
| const container = document.getElementById('prompt-list-container'); | |
| const titleEl = document.getElementById('prompt-list-title'); | |
| titleEl.textContent = type === 'student' ? `${title} 的所有提示詞` : `題目:${title} 的所有作品`; | |
| // Reset Anonymous Toggle in List View | |
| const anonCheck = document.getElementById('list-anonymous-toggle'); | |
| if (anonCheck) anonCheck.checked = false; | |
| container.innerHTML = ''; | |
| modal.classList.remove('hidden'); | |
| // Collect Prompts | |
| let prompts = []; | |
| // Fix: Reset selection when opening new list to prevent cross-contamination | |
| selectedPrompts = []; | |
| updateCompareButton(); | |
| if (type === 'student') { | |
| const student = currentStudents.find(s => s.id === id); | |
| if (student && student.progress) { | |
| prompts = Object.entries(student.progress) | |
| .filter(([_, p]) => p.status === 'completed' && p.prompt) | |
| .map(([challengeId, p]) => { | |
| const challenge = cachedChallenges.find(c => c.id === challengeId); | |
| return { | |
| id: `${student.id}_${challengeId}`, | |
| title: challenge ? challenge.title : '未知題目', | |
| prompt: p.prompt, | |
| author: student.nickname, | |
| studentId: student.id, | |
| challengeId: challengeId, | |
| time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : '' | |
| }; | |
| }); | |
| } | |
| } else if (type === 'challenge') { | |
| currentStudents.forEach(student => { | |
| if (student.progress && student.progress[id]) { | |
| const p = student.progress[id]; | |
| if (p.status === 'completed' && p.prompt) { | |
| prompts.push({ | |
| id: `${student.id}_${id}`, | |
| title: student.nickname, // When viewing challenge, title is student name | |
| prompt: p.prompt, | |
| author: student.nickname, | |
| studentId: student.id, | |
| challengeId: id, | |
| time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleString() : '' | |
| }); | |
| } | |
| } | |
| }); | |
| } | |
| if (prompts.length === 0) { | |
| container.innerHTML = '<div class="col-span-full text-center text-gray-500 py-10">無資料</div>'; | |
| return; | |
| } | |
| prompts.forEach(p => { | |
| const card = document.createElement('div'); | |
| // Reduced height (h-64 -> h-48) and padding, but larger text inside | |
| card.className = 'bg-gray-800 rounded-xl p-3 border border-gray-700 hover:border-cyan-500 transition-colors flex flex-col h-48 group'; | |
| card.innerHTML = ` | |
| <div class="flex justify-between items-start mb-1.5"> | |
| <h3 class="font-bold text-white text-base truncate w-3/4" title="${p.title}">${p.title}</h3> | |
| <!-- Checkbox --> | |
| <input type="checkbox" class="w-6 h-6 rounded border-gray-600 text-purple-600 focus:ring-purple-500 bg-gray-700 prompt-select-checkbox cursor-pointer transform scale-110" | |
| data-id="${p.id}" | |
| onchange="handlePromptSelection(this)"> | |
| </div> | |
| <!-- Prompt Content --> | |
| <div class="bg-black/30 rounded p-2 flex-1 overflow-y-auto font-mono text-green-300 whitespace-pre-wrap custom-scrollbar text-base leading-snug group-hover:text-green-200 transaction-colors mb-2" style="text-align: left;">${cleanText(p.prompt)}</div> | |
| <!-- Footer: Time + Actions --> | |
| <div class="flex justify-between items-center text-[10px] text-gray-500 mt-auto"> | |
| <span>${p.time}</span> | |
| <div class="flex space-x-2"> | |
| <button onclick="window.confirmReset('${p.studentId}', '${p.challengeId}', '${p.title}')" class="px-2 py-1 bg-red-900/30 hover:bg-red-800 text-red-400 rounded transition-colors text-xs border border-red-800/50" title="退回重做"> | |
| 退回 | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| container.appendChild(card); | |
| }); | |
| }; | |
| // Helper Actions | |
| window.confirmReset = async (userId, challengeId, title) => { | |
| console.log('🔵 confirmReset called'); | |
| console.log(' userId:', userId); | |
| console.log(' challengeId:', challengeId); | |
| console.log(' title:', title); | |
| console.log(' typeof resetProgress:', typeof resetProgress); | |
| if (confirm(`確定要退回 ${title} 嗎?此動作將清除學員目前的進度。`)) { | |
| console.log('✅ User confirmed reset'); | |
| const roomCode = localStorage.getItem('vibecoding_room_code'); | |
| console.log(' roomCode:', roomCode); | |
| if (userId && challengeId && roomCode) { | |
| console.log('✅ All parameters valid, calling resetProgress...'); | |
| try { | |
| // Use already imported resetProgress function | |
| await resetProgress(userId, roomCode, challengeId); | |
| console.log('✅ resetProgress completed successfully'); | |
| alert("已退回"); | |
| // close modal to refresh data context | |
| document.getElementById('prompt-list-modal').classList.add('hidden'); | |
| console.log('✅ Modal closed'); | |
| } catch (e) { | |
| console.error("❌ 退回失敗:", e); | |
| console.error("Error stack:", e.stack); | |
| alert("退回失敗: " + e.message); | |
| } | |
| } else { | |
| console.error('❌ Missing required parameters:'); | |
| console.error(' userId:', userId, '(valid:', !!userId, ')'); | |
| console.error(' challengeId:', challengeId, '(valid:', !!challengeId, ')'); | |
| console.error(' roomCode:', roomCode, '(valid:', !!roomCode, ')'); | |
| alert('缺少必要參數,無法執行退回操作'); | |
| } | |
| } else { | |
| console.log('❌ User cancelled reset'); | |
| } | |
| }; | |
| window.broadcastPrompt = (userId, challengeId) => { | |
| window.showBroadcastModal(userId, challengeId); | |
| }; | |
| // Selection Logic | |
| let selectedPrompts = []; // Stores IDs | |
| window.handlePromptSelection = (checkbox) => { | |
| const id = checkbox.dataset.id; | |
| if (checkbox.checked) { | |
| if (selectedPrompts.length >= 3) { | |
| checkbox.checked = false; | |
| alert('最多只能選擇 3 個提示詞進行比較'); | |
| return; | |
| } | |
| selectedPrompts.push(id); | |
| } else { | |
| selectedPrompts = selectedPrompts.filter(pid => pid !== id); | |
| } | |
| updateCompareButton(); | |
| }; | |
| function updateCompareButton() { | |
| const btn = document.getElementById('btn-compare-prompts'); | |
| if (!btn) return; | |
| const count = selectedPrompts.length; | |
| const span = btn.querySelector('span'); | |
| if (span) span.textContent = `🔍 比較已選項目 (${count}/3)`; | |
| if (count > 0) { | |
| btn.disabled = false; | |
| btn.classList.remove('opacity-50', 'cursor-not-allowed'); | |
| } else { | |
| btn.disabled = true; | |
| btn.classList.add('opacity-50', 'cursor-not-allowed'); | |
| } | |
| } | |
| // Comparison Logic | |
| const compareBtn = document.getElementById('btn-compare-prompts'); | |
| if (compareBtn) { | |
| compareBtn.addEventListener('click', () => { | |
| const dataToCompare = []; | |
| selectedPrompts.forEach(fullId => { | |
| const lastUnderscore = fullId.lastIndexOf('_'); | |
| const studentId = fullId.substring(0, lastUnderscore); | |
| const challengeId = fullId.substring(lastUnderscore + 1); | |
| const student = currentStudents.find(s => s.id === studentId); | |
| if (student && student.progress && student.progress[challengeId]) { | |
| const p = student.progress[challengeId]; | |
| const challenge = cachedChallenges.find(c => c.id === challengeId); | |
| dataToCompare.push({ | |
| title: challenge ? challenge.title : '未知', | |
| author: student.nickname, | |
| prompt: p.prompt, | |
| time: p.completedAt ? new Date(p.completedAt.seconds * 1000).toLocaleTimeString() : '' | |
| }); | |
| } | |
| }); | |
| const isAnon = document.getElementById('list-anonymous-toggle')?.checked || false; | |
| openComparisonView(dataToCompare, isAnon); | |
| }); | |
| } | |
| let isAnonymous = false; | |
| window.toggleAnonymous = (btn) => { | |
| isAnonymous = !isAnonymous; | |
| btn.textContent = isAnonymous ? '🙈 顯示姓名' : '👀 隱藏姓名'; | |
| btn.classList.toggle('bg-gray-700'); | |
| btn.classList.toggle('bg-purple-700'); | |
| // Update DOM | |
| document.querySelectorAll('.comparison-author').forEach(el => { | |
| if (isAnonymous) { | |
| el.dataset.original = el.textContent; | |
| el.textContent = '學員'; | |
| el.classList.add('blur-sm'); // Optional Effect | |
| setTimeout(() => el.classList.remove('blur-sm'), 300); | |
| } else { | |
| if (el.dataset.original) el.textContent = el.dataset.original; | |
| } | |
| }); | |
| }; | |
| window.openComparisonView = (items, initialAnonymous = false) => { | |
| const modal = document.getElementById('comparison-modal'); | |
| const grid = document.getElementById('comparison-grid'); | |
| // Apply Anonymous State | |
| isAnonymous = initialAnonymous; | |
| const anonBtn = document.getElementById('btn-anonymous-toggle'); | |
| // Update Toggle UI to match state | |
| if (anonBtn) { | |
| if (isAnonymous) { | |
| anonBtn.textContent = '🙈 顯示姓名'; | |
| anonBtn.classList.add('bg-purple-700'); | |
| anonBtn.classList.remove('bg-gray-700'); | |
| } else { | |
| anonBtn.textContent = '👀 隱藏姓名'; | |
| anonBtn.classList.remove('bg-purple-700'); | |
| anonBtn.classList.add('bg-gray-700'); | |
| } | |
| } | |
| // Setup Grid Rows (Vertical Stacking) | |
| let rowClass = 'grid-rows-1'; | |
| if (items.length === 2) rowClass = 'grid-rows-2'; | |
| if (items.length === 3) rowClass = 'grid-rows-3'; | |
| grid.className = `absolute inset-0 grid ${rowClass} gap-0 divide-y divide-gray-600`; | |
| grid.innerHTML = ''; | |
| items.forEach(item => { | |
| const col = document.createElement('div'); | |
| // Check overflow-hidden to keep it contained, use flex-row for compact header + content | |
| col.className = 'flex flex-row h-full bg-gray-900 p-4 overflow-hidden'; | |
| // Logic for anonymous | |
| let displayAuthor = item.author; | |
| let blurClass = ''; | |
| if (isAnonymous) { | |
| displayAuthor = '學員'; | |
| blurClass = 'blur-sm'; // Initial blur | |
| // Auto remove blur after delay if needed, or keep it? | |
| // Toggle logic removes it after delay. But initial render should probably just be static '學員' or blurred. | |
| // The toggle logic uses dataset.original. We need to set it here too. | |
| } | |
| col.innerHTML = ` | |
| <div class="w-48 flex-shrink-0 border-r border-gray-700 pr-4 mr-4 flex flex-col justify-center"> | |
| <h3 class="text-xl font-bold text-cyan-400 mb-1 comparison-author ${blurClass}" data-original="${item.author}">${displayAuthor}</h3> | |
| <p class="text-md text-gray-400 truncate" title="${item.title}">${item.title}</p> | |
| </div> | |
| <!-- Prompt Content: Larger Text (text-4xl) --> | |
| <!-- Prompt Content: Larger Text (text-4xl) --> | |
| <div class="flex-1 overflow-y-auto font-mono text-green-300 text-3xl leading-relaxed whitespace-pre-wrap p-2 hover:bg-white/5 transition-colors rounded custom-scrollbar text-left" style="text-align: left !important;">${cleanText(item.prompt)}</div> | |
| `; | |
| grid.appendChild(col); | |
| // If blurred, remove blur after animation purely for effect, or keep? | |
| // User intention "Hidden Name" usually means "Replaced by generic name". | |
| // The blur effect in toggle logic was transient. | |
| // If we want persistent anonymity, just "學員" is enough. | |
| // The existing toggle logic adds 'blur-sm' then removes it in 300ms. | |
| // We should replicate that effect if we want consistency, or just skip blur on init. | |
| if (isAnonymous) { | |
| const el = col.querySelector('.comparison-author'); | |
| setTimeout(() => el.classList.remove('blur-sm'), 300); | |
| } | |
| }); | |
| document.getElementById('prompt-list-modal').classList.add('hidden'); | |
| modal.classList.remove('hidden'); | |
| // Init Canvas (Phase 3) | |
| setTimeout(setupCanvas, 100); | |
| }; | |
| window.closeComparison = () => { | |
| document.getElementById('comparison-modal').classList.add('hidden'); | |
| clearCanvas(); | |
| }; | |
| // --- Phase 3 & 6: Annotation Tools --- | |
| let canvas, ctx; | |
| let isDrawing = false; | |
| let currentPenColor = '#ef4444'; // Red default | |
| let currentLineWidth = 3; | |
| let currentMode = 'source-over'; // 'source-over' (Pen) or 'destination-out' (Eraser) | |
| window.setupCanvas = () => { | |
| canvas = document.getElementById('annotation-canvas'); | |
| const container = document.getElementById('comparison-container'); | |
| if (!canvas || !container) return; | |
| ctx = canvas.getContext('2d'); | |
| // Resize | |
| const resize = () => { | |
| canvas.width = container.clientWidth; | |
| canvas.height = container.clientHeight; | |
| ctx.lineCap = 'round'; | |
| ctx.lineJoin = 'round'; | |
| ctx.strokeStyle = currentPenColor; | |
| ctx.lineWidth = currentLineWidth; | |
| ctx.globalCompositeOperation = currentMode; | |
| }; | |
| resize(); | |
| window.addEventListener('resize', resize); | |
| // Init Size UI & Cursor | |
| updateSizeBtnUI(); | |
| updateCursorStyle(); | |
| // Cursor Logic | |
| const cursor = document.getElementById('tool-cursor'); | |
| canvas.addEventListener('mouseenter', () => cursor.classList.remove('hidden')); | |
| canvas.addEventListener('mouseleave', () => cursor.classList.add('hidden')); | |
| canvas.addEventListener('mousemove', (e) => { | |
| const { x, y } = getPos(e); | |
| cursor.style.left = `${x}px`; | |
| cursor.style.top = `${y}px`; | |
| }); | |
| // Drawing Events | |
| const start = (e) => { | |
| isDrawing = true; | |
| ctx.beginPath(); | |
| // Re-apply settings (state might change) | |
| ctx.globalCompositeOperation = currentMode; | |
| ctx.strokeStyle = currentPenColor; | |
| ctx.lineWidth = currentLineWidth; | |
| const { x, y } = getPos(e); | |
| ctx.moveTo(x, y); | |
| }; | |
| const move = (e) => { | |
| if (!isDrawing) return; | |
| const { x, y } = getPos(e); | |
| ctx.lineTo(x, y); | |
| ctx.stroke(); | |
| }; | |
| const end = () => { | |
| isDrawing = false; | |
| }; | |
| canvas.onmousedown = start; | |
| canvas.onmousemove = move; | |
| canvas.onmouseup = end; | |
| canvas.onmouseleave = end; | |
| // Touch support | |
| canvas.ontouchstart = (e) => { e.preventDefault(); start(e.touches[0]); }; | |
| canvas.ontouchmove = (e) => { e.preventDefault(); move(e.touches[0]); }; | |
| canvas.ontouchend = (e) => { e.preventDefault(); end(); }; | |
| }; | |
| function getPos(e) { | |
| const rect = canvas.getBoundingClientRect(); | |
| return { | |
| x: e.clientX - rect.left, | |
| y: e.clientY - rect.top | |
| }; | |
| } | |
| // Unified Tool Handler | |
| window.setPenTool = (tool, color, btn) => { | |
| // UI Update | |
| document.querySelectorAll('.annotation-tool').forEach(b => { | |
| b.classList.remove('ring-white'); | |
| b.classList.add('ring-transparent'); | |
| }); | |
| btn.classList.remove('ring-transparent'); | |
| btn.classList.add('ring-white'); | |
| if (tool === 'eraser') { | |
| currentMode = 'destination-out'; | |
| // Force larger eraser size (e.g., 3x current size or fixed large) | |
| // We'll multiply current selected size by 4 for better UX | |
| const multiplier = 4; | |
| // Store original explicitly if needed, but currentLineWidth is global. | |
| // We should dynamically adjust context lineWidth during draw, or just hack it here. | |
| // Hack: If we change currentLineWidth here, the UI size buttons might look wrong. | |
| // Better: Update cursor style only? No, actual draw needs it. | |
| // Let's set a separate 'actualWidth' used in draw, OR just change currentLineWidth temporarily? | |
| // Simpler: Just change it. When user clicks size button, it resets. | |
| // But if user clicks Pen back? We need to restore. | |
| // Let's rely on setPenTool being called with color. | |
| // When "Pen" is clicked, we usually don't call setPenTool with a saved size... | |
| // Actually, let's just use a large default for eraser, and keep currentLineWidth for Pen. | |
| // We need to change how draw() uses the width. | |
| // BUT, since we don't want to touch draw() deep inside: | |
| // We will hijack currentLineWidth. | |
| if (!window.savedPenWidth) window.savedPenWidth = currentLineWidth; | |
| currentLineWidth = window.savedPenWidth * 4; | |
| } else { | |
| currentMode = 'source-over'; | |
| currentPenColor = color; | |
| // Restore pen width | |
| if (window.savedPenWidth) { | |
| currentLineWidth = window.savedPenWidth; | |
| window.savedPenWidth = null; | |
| } | |
| } | |
| updateCursorStyle(); | |
| }; | |
| // Size Handler | |
| window.setPenSize = (size, btn) => { | |
| currentLineWidth = size; | |
| updateSizeBtnUI(); | |
| updateCursorStyle(); | |
| }; | |
| function updateCursorStyle() { | |
| const cursor = document.getElementById('tool-cursor'); | |
| if (!cursor) return; | |
| // Size | |
| cursor.style.width = `${currentLineWidth}px`; | |
| cursor.style.height = `${currentLineWidth}px`; | |
| // Color | |
| if (currentMode === 'destination-out') { | |
| // Eraser: White solid | |
| cursor.style.backgroundColor = 'white'; | |
| cursor.style.borderColor = '#999'; | |
| } else { | |
| // Pen: Tool color | |
| cursor.style.backgroundColor = currentPenColor; | |
| cursor.style.borderColor = 'rgba(255,255,255,0.8)'; | |
| } | |
| } | |
| function updateSizeBtnUI() { | |
| document.querySelectorAll('.size-btn').forEach(b => { | |
| if (parseInt(b.dataset.size) === currentLineWidth) { | |
| b.classList.add('bg-gray-600', 'text-white'); | |
| b.classList.remove('text-gray-400', 'hover:bg-gray-700'); | |
| } else { | |
| b.classList.remove('bg-gray-600', 'text-white'); | |
| b.classList.add('text-gray-400', 'hover:bg-gray-700'); | |
| } | |
| }); | |
| } | |
| window.clearCanvas = () => { | |
| if (canvas && ctx) { | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| } | |
| }; | |
| } | |
| /** | |
| * 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"> | |
| <button onclick="window.openPromptList('student', '${student.id}', '${student.nickname}')" class="text-xs text-gray-300 font-medium truncate max-w-[80px] writing-vertical-lr hover:text-cyan-400 hover:font-bold transition-all" style="writing-mode: vertical-rl; text-orientation: mixed;" title="查看該學員所有提示詞"> | |
| ${student.nickname} | |
| </button> | |
| <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-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; | |
| 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> | |
| <button onclick="window.openPromptList('challenge', '${c.id}', '${c.title}')" class="font-bold text-white text-sm truncate max-w-[180px] text-left hover:text-cyan-400 transition-colors" title="查看此題目所有作品"> | |
| ${index + 1}. ${c.title} | |
| </button> | |
| </div> | |
| <!-- Stats (Optional) --> | |
| <!-- <span class="text-xs text-gray-500">0%</span> --> | |
| </div> | |
| </td> | |
| ${rowCells} | |
| </tr> | |
| `; | |
| }).join(''); | |
| } | |
| } | |