Spaces:
Running
Running
| import { submitPrompt, getChallenges, startChallenge, getUserProgress, getPeerPrompts, resetProgress, toggleLike, subscribeToNotifications, markNotificationRead, getClassSize, updateUserMonster, getUser, subscribeToUserProgress } from "../services/classroom.js"; | |
| import { generateMonsterSVG, getNextMonster, MONSTER_STAGES, MONSTER_DEFS } from "../utils/monsterUtils.js"; | |
| // Cache challenges locally | |
| let cachedChallenges = []; | |
| function renderTaskCard(c, userProgress) { | |
| const p = userProgress[c.id] || {}; | |
| const isCompleted = p.status === 'completed'; | |
| const isStarted = p.status === 'started'; | |
| // 1. Completed State: Collapsed with Trophy | |
| if (isCompleted) { | |
| return ` | |
| <div id="card-${c.id}" class="bg-gray-800/50 border border-green-500/30 rounded-xl p-4 flex items-center justify-between group hover:bg-gray-800 transition-all"> | |
| <div class="flex items-center space-x-3"> | |
| <div class="text-2xl">🏆</div> | |
| <h3 class="font-bold text-gray-300 group-hover:text-white transition-colors">${c.title}</h3> | |
| </div> | |
| <div class="flex items-center space-x-3"> | |
| <button onclick="window.resetLevel('${c.id}')" class="text-xs bg-red-900/50 hover:bg-red-700 text-red-300 border border-red-800 px-2 py-1 rounded transition-colors" title="重置進度 (Reset)"> | |
| ↺ 重置 | |
| </button> | |
| <span class="text-xs text-green-400 font-mono bg-green-900/30 px-2 py-1 rounded">已通關</span> | |
| <button onclick="document.getElementById('detail-${c.id}').classList.toggle('hidden')" class="text-gray-500 hover:text-white"> | |
| <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Hidden detail for reference --> | |
| <div id="detail-${c.id}" class="hidden mt-2 p-4 bg-gray-900/50 rounded-xl border border-gray-700 text-sm text-gray-400"> | |
| <p class="mb-2">您的提示詞:</p> | |
| <div class="font-mono bg-black p-2 rounded text-gray-300 border border-gray-800">${p.submission_prompt}</div> | |
| </div> | |
| `; | |
| } | |
| // 2. Started or Not Started | |
| return ` | |
| <div id="card-${c.id}" class="group relative bg-gray-800 bg-opacity-50 border ${isStarted ? 'border-cyan-500/50 shadow-[0_0_15px_rgba(6,182,212,0.1)]' : 'border-gray-700'} rounded-2xl overflow-hidden hover:border-cyan-500/50 transition-all duration-300 flex flex-col"> | |
| <div class="absolute top-0 left-0 w-1 h-full ${isStarted ? 'bg-cyan-500' : 'bg-gray-600'} group-hover:bg-cyan-400 transition-colors"></div> | |
| <div class="p-6 pl-8 flex-1 flex flex-col"> | |
| <div class="flex flex-col sm:flex-row justify-between items-start mb-4 gap-4"> | |
| <div> | |
| <h2 class="text-xl font-bold text-white mb-1">${c.title}</h2> | |
| <div class="bg-blue-900/30 border border-blue-500/30 p-4 rounded-xl my-3 shadow-[inset_0_0_10px_rgba(59,130,246,0.1)]"> | |
| <div class="flex items-start space-x-2 text-cyan-300 mb-1"> | |
| <svg class="w-5 h-5 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> | |
| <span class="font-bold text-sm tracking-wider uppercase">任務說明</span> | |
| </div> | |
| <p class="text-gray-200 text-base font-medium whitespace-pre-line leading-relaxed pl-7">${c.description}</p> | |
| </div> | |
| </div> | |
| </div> | |
| ${!isStarted ? ` | |
| <!-- Not Started State --> | |
| <div class="mt-4 flex justify-center"> | |
| <button onclick="window.startLevel('${c.id}', '${c.link}')" | |
| class="w-full sm:w-auto bg-gray-700 hover:bg-cyan-600 hover:text-white text-gray-200 font-bold py-3 px-6 rounded-xl transition-all flex items-center justify-center space-x-2 shadow-lg"> | |
| <span>🚀 開始任務 (Start Task)</span> | |
| </button> | |
| </div> | |
| ` : ` | |
| <!-- Started State: Input Area --> | |
| <div class="mt-4 pt-4 border-t border-gray-700/50"> | |
| <div class="flex justify-between items-center mb-2"> | |
| <label class="block text-xs uppercase tracking-wider text-cyan-400 animate-pulse">任務進行中</label> | |
| <a href="${c.link}" target="_blank" class="text-xs text-gray-500 hover:text-white flex items-center space-x-1"> | |
| <span>再次開啟 GeminCanvas</span> | |
| <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg> | |
| </a> | |
| </div> | |
| <div class="flex flex-col space-y-2"> | |
| <textarea id="input-${c.id}" rows="2" | |
| class="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:border-cyan-500 focus:outline-none transition-colors text-sm" | |
| placeholder="貼上您的修復提示詞..."></textarea> | |
| <div id="error-${c.id}" class="text-red-500 text-xs hidden">提示詞太短囉,請多寫一點細節!</div> | |
| <button onclick="window.submitLevel('${c.id}')" | |
| class="w-full bg-gradient-to-r from-cyan-600 to-blue-600 hover:from-cyan-500 hover:to-blue-500 text-white px-6 py-2 rounded-lg font-bold transition-transform active:scale-95 shadow-lg shadow-cyan-900/50"> | |
| 提交解答 | |
| </button> | |
| </div> | |
| </div> | |
| `} | |
| </div> | |
| </div> | |
| `; | |
| } | |
| export async function renderStudentView() { | |
| const nickname = localStorage.getItem('vibecoding_nickname') || 'Guest'; | |
| const roomCode = localStorage.getItem('vibecoding_room_code') || 'Unknown'; | |
| const userId = localStorage.getItem('vibecoding_user_id'); | |
| // Fetch challenges if empty | |
| if (cachedChallenges.length === 0) { | |
| try { | |
| cachedChallenges = await getChallenges(); | |
| } catch (e) { | |
| console.error("Failed to fetch challenges", e); | |
| throw new Error("無法讀取題目列表 (Error: " + e.message + ")"); | |
| } | |
| } | |
| // Fetch User Progress | |
| let userProgress = {}; | |
| if (userId) { | |
| try { | |
| userProgress = await getUserProgress(userId); | |
| } catch (e) { | |
| console.error("Failed to fetch progress", e); | |
| } | |
| } | |
| const levelGroups = { | |
| beginner: cachedChallenges.filter(c => c.level === 'beginner'), | |
| intermediate: cachedChallenges.filter(c => c.level === 'intermediate'), | |
| advanced: cachedChallenges.filter(c => c.level === 'advanced') | |
| }; | |
| const levelNames = { | |
| beginner: "初級 (Beginner)", | |
| intermediate: "中級 (Intermediate)", | |
| advanced: "高級 (Advanced)" | |
| }; | |
| // --- Monster Section Render Function --- | |
| // --- Monster Section Helper Functions --- | |
| // 1. Calculate Monster State (Separated logic) | |
| const calculateMonsterState = (currentUserProgress, classSize, userProfile) => { | |
| const totalLikes = Object.values(currentUserProgress).reduce((acc, p) => acc + (p.likes || 0), 0); | |
| // Count completions per level | |
| const counts = { | |
| 1: cachedChallenges.filter(c => c.level === 'beginner' && currentUserProgress[c.id]?.status === 'completed').length, | |
| 2: cachedChallenges.filter(c => c.level === 'intermediate' && currentUserProgress[c.id]?.status === 'completed').length, | |
| 3: cachedChallenges.filter(c => c.level === 'advanced' && currentUserProgress[c.id]?.status === 'completed').length | |
| }; | |
| const totalCompleted = counts[1] + counts[2] + counts[3]; | |
| let potentialStage = 0; | |
| if (counts[1] >= 5) potentialStage = 1; | |
| if (counts[2] >= 5 && potentialStage >= 1) potentialStage = 2; | |
| if (counts[3] >= 5 && potentialStage >= 2) potentialStage = 3; | |
| const actualStage = userProfile.monster_stage || 0; | |
| const actualMonsterId = userProfile.monster_id || 'Egg'; | |
| // Regression Logic Check | |
| if (actualStage > potentialStage) { | |
| // Regression Logic: Pass 'Egg' (or null) to enforce downgrade | |
| const targetId = potentialStage === 0 ? 'Egg' : null; // If going to 0, force Egg. Else keep null to let system decide? Or should we query? | |
| // Actually, if we devolve, we lose the specific form. So we should probably reset ID to something generic or clear it. | |
| // If we go to stage 0, 'Egg' is safe. | |
| // If we go to stage 1 (from 2), we might want to keep the stage 1 form (if we knew it). | |
| // But simplifying: just clear the ID so getNextMonster picks default for that stage. | |
| updateUserMonster(userId, potentialStage, targetId).then(() => { | |
| setTimeout(() => window.location.reload(), 500); | |
| }); | |
| // Return corrected state temporary | |
| return { | |
| ...calculateMonsterState(currentUserProgress, classSize, { ...userProfile, monster_stage: potentialStage, monster_id: targetId }), | |
| isRegressing: true | |
| }; | |
| } | |
| const canEvolve = potentialStage > actualStage; | |
| // Scale Logic | |
| const growthFactor = 0.08; | |
| const baseScale = 1.0; | |
| const currentScale = baseScale + (totalCompleted * growthFactor); | |
| // Get Monster Data | |
| let monster = getNextMonster(actualStage, 0, 0, actualMonsterId); | |
| if (actualMonsterId && actualMonsterId !== 'Egg') { | |
| const stored = MONSTER_DEFS.find(m => m.id === actualMonsterId); | |
| if (stored) monster = stored; | |
| } else { | |
| monster = getNextMonster(actualStage, totalLikes, classSize); | |
| } | |
| return { | |
| monster, | |
| currentScale, | |
| totalCompleted, | |
| totalLikes, | |
| canEvolve, | |
| actualStage, | |
| classSize, | |
| counts | |
| }; | |
| }; | |
| // 2. Render Stats HTML (Partial Update Target) | |
| const renderMonsterStats = (state) => { | |
| return ` | |
| <div class="font-bold text-white text-sm mb-1 text-center border-b border-gray-700 pb-1">${state.monster.name}</div> | |
| <div class="space-y-1 mt-1 text-center"> | |
| <div class=""><span>💖</span> <span class="text-pink-400 font-bold ml-1">${state.totalLikes}</span></div> | |
| </div> | |
| `; | |
| }; | |
| // 3. Render Full Monster Container (Initial or Full Update) | |
| // Now positioned stats to TOP | |
| const renderMonsterSection = (currentUserProgress, classSize, userProfile) => { | |
| const state = calculateMonsterState(currentUserProgress, classSize, userProfile); | |
| // Left sidebar position: Responsive | |
| // Mobile: Top Left (in header space) | |
| // Desktop: Fixed Left Sidebar | |
| return ` | |
| <div id="monster-container-fixed" data-monster-id="${state.monster.id}" data-scale="${state.currentScale}" | |
| class="fixed top-2 left-2 z-50 flex flex-col items-center group pointer-events-none sm:pointer-events-auto w-24 h-24 sm:w-32 sm:h-32 md:top-32 md:left-8"> | |
| <!-- Stats Tooltip (Moved to Top) --> | |
| <div id="monster-stats-content" class="absolute bottom-full mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-gray-900/90 backdrop-blur text-xs text-slate-300 p-3 rounded-xl border border-slate-700 text-left pointer-events-auto shadow-2xl min-w-[100px]"> | |
| ${renderMonsterStats(state)} | |
| </div> | |
| <!-- Walking Container (Handles Movement Only) --> | |
| <div class="pixel-art-container relative transform transition-transform duration-500 ease-out origin-center" | |
| style="transform: scale(${state.currentScale}); animation: patrol-move 15s linear infinite;"> | |
| <!-- Monster Sprite (Handles Flip & Breathe) --> | |
| <div class="pixel-monster w-28 h-28 drop-shadow-2xl filter" style="animation: breathe 3s infinite ease-in-out, patrol-flip 15s linear infinite;"> | |
| ${generateMonsterSVG(state.monster)} | |
| </div> | |
| <!-- Level Indicator (No Flip) --> | |
| <div class="absolute -bottom-2 -right-2 bg-gray-900/90 text-xs text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-500/50 font-mono font-bold transform scale-75 origin-top-left whitespace-nowrap"> | |
| Lv.${1 + state.totalCompleted} | |
| </div> | |
| </div> | |
| <!-- Evolution Prompt --> | |
| ${state.canEvolve ? ` | |
| <div id="evolution-prompt" class="absolute top-full mt-2 pointer-events-auto animate-bounce z-50"> | |
| <div class="flex flex-col items-center"> | |
| <div class="bg-gray-900/90 text-pink-200 text-xs py-2 px-3 rounded-xl border border-pink-500/30 shadow-lg text-center font-bold mb-1 backdrop-blur-sm whitespace-nowrap"> | |
| 咦,小怪獸的樣子<br>正在發生變化... | |
| </div> | |
| <button onclick="window.triggerEvolution(${state.actualStage}, ${state.actualStage + 1}, ${state.totalLikes}, ${classSize}, '${state.monster.id}')" | |
| class="bg-gradient-to-r from-pink-600 to-purple-600 hover:from-pink-500 hover:to-purple-500 text-white text-sm font-black py-2 px-6 rounded-full shadow-[0_0_15px_rgba(236,72,153,0.8)] border border-white/30 transition-all hover:scale-110 active:scale-95"> | |
| 進化! | |
| </button> | |
| </div> | |
| </div> | |
| ` : ''} | |
| </div> | |
| <style> | |
| @keyframes breathe { | |
| 0%, 100% { transform: translateY(0); filter: brightness(1); } | |
| 50% { transform: translateY(-3px); filter: brightness(1.1); } | |
| } | |
| @keyframes patrol-move { | |
| 0% { transform: translateX(0); } | |
| 45% { transform: translateX(120px); } | |
| 55% { transform: translateX(120px); } | |
| 95% { transform: translateX(0); } | |
| 100% { transform: translateX(0); } | |
| } | |
| @keyframes patrol-flip { | |
| 0% { transform: scaleX(1); } | |
| 49% { transform: scaleX(1); } | |
| 50% { transform: scaleX(-1); } | |
| 99% { transform: scaleX(-1); } | |
| 100% { transform: scaleX(1); } | |
| } | |
| </style> | |
| `; | |
| }; | |
| // Inject Initial Monster UI | |
| const monsterContainerId = 'monster-ui-layer'; | |
| let monsterContainer = document.getElementById(monsterContainerId); | |
| if (!monsterContainer) { | |
| monsterContainer = document.createElement('div'); | |
| monsterContainer.id = monsterContainerId; | |
| document.body.appendChild(monsterContainer); | |
| } | |
| // Initial Render | |
| let classSize = 1; | |
| let userProfile = {}; | |
| try { | |
| classSize = await getClassSize(roomCode); | |
| userProfile = await getUser(userId) || {}; | |
| } catch (e) { console.error("Fetch stats error", e); } | |
| monsterContainer.innerHTML = renderMonsterSection(userProgress, classSize, userProfile); | |
| // Setup Real-time Subscription | |
| if (window.currentProgressUnsub) window.currentProgressUnsub(); | |
| window.currentProgressUnsub = subscribeToUserProgress(userId, (newProgressMap) => { | |
| // Merge updates | |
| const updatedProgress = { ...userProgress, ...newProgressMap }; | |
| // Smart Update: Check if visual refresh is needed | |
| const newState = calculateMonsterState(updatedProgress, classSize, userProfile); | |
| const fixedContainer = document.getElementById('monster-container-fixed'); | |
| const currentMonsterId = fixedContainer?.getAttribute('data-monster-id'); | |
| // Tolerance for scale check usually not needed if we want scale to update visuals immediately? | |
| // Actually, scale change usually means totalCompleted changed. | |
| // If we want smooth growth, replacing DOM resets animation which looks slight jumpy but acceptable. | |
| // But the user complained about "position reset" (walk cycle reset). | |
| if (fixedContainer && String(currentMonsterId) === String(newState.monster.id)) { | |
| // Monster ID is same (no evolution/devolution). | |
| // Just update stats tooltip | |
| const statsContainer = document.getElementById('monster-stats-content'); | |
| if (statsContainer) { | |
| statsContainer.innerHTML = renderMonsterStats(newState); | |
| } | |
| // What if level up (scale change)? | |
| // If we don't replace DOM, scale won't update in style attribute. | |
| // We should update the style manually. | |
| const artContainer = fixedContainer.querySelector('.pixel-art-container'); | |
| if (artContainer && newState.currentScale) { | |
| // Update animation with new scale | |
| // Note: Modifying 'transform' directly might conflict with keyframes unless keyframes use relative or we update style variable. | |
| // Keyframes use: scale(${currentScale}). This is hardcoded in specific keyframes string in <style>. | |
| // We can't easily update keyframes dynamic values without replacing style block. | |
| // If totalCompleted changed (level up), user *might* accept a reset because they levelled up. | |
| // But simply giving a heart shouldn't reset. | |
| const oldTotal = parseInt(fixedContainer.querySelector('.bg-gray-900\\/90')?.textContent?.replace('Lv.', '') || '1') - 1; | |
| if (oldTotal !== newState.totalCompleted) { | |
| // Level changed -> Scale changed -> Re-render full (reset animation is fine for Level Up) | |
| monsterContainer.innerHTML = renderMonsterSection(updatedProgress, classSize, userProfile); | |
| } | |
| } | |
| } else { | |
| // Monster changed or clean slate -> Full Render | |
| monsterContainer.innerHTML = renderMonsterSection(updatedProgress, classSize, userProfile); | |
| } | |
| }); | |
| // Accordion Layout | |
| return ` | |
| <div class="min-h-screen p-4 pb-32 max-w-md mx-auto sm:max-w-4xl pt-24 sm:pt-4"> | |
| <header class="flex justify-end items-center mb-6 sticky top-0 bg-slate-900/95 backdrop-blur z-20 py-4 px-2 -mx-2 border-b border-gray-800"> | |
| <div class="flex flex-col items-end"> | |
| <div class="flex items-center space-x-2"> | |
| <div class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div> | |
| <span class="text-gray-400 text-sm truncate max-w-[150px]">${nickname}</span> | |
| </div> | |
| <div class="text-xs text-gray-500 mt-1">教室: <span class="font-mono text-cyan-400 font-bold">${roomCode}</span></div> | |
| </div> | |
| <button onclick="window.logout()" class="text-gray-500 hover:text-red-400 transition-colors p-2" title="登出"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /> | |
| </svg> | |
| </button> | |
| </header> | |
| <div class="space-y-4"> | |
| ${['beginner', 'intermediate', 'advanced'].map(level => { | |
| return renderLevelGroup(level, levelGroups[level] || [], userProgress, levelNames); | |
| }).join('')} | |
| </div> | |
| <!-- Peer Learning FAB --> | |
| <button onclick="window.openPeerModal()" class="fixed bottom-14 right-4 bg-purple-600 hover:bg-purple-500 text-white rounded-full p-4 shadow-xl shadow-purple-600/40 transition-transform hover:scale-110 active:scale-90 z-40 flex items-center space-x-2" | |
| title="查看同學作業"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0z" /> | |
| </svg> | |
| <span class="font-bold text-sm hidden sm:inline">查看同學提示詞</span> | |
| </button> | |
| <!-- Credits Footer --> | |
| <div class="fixed bottom-1 right-2 z-30 text-[10px] text-gray-400 font-mono text-right pointer-events-none sm:pointer-events-auto select-none opacity-80 hover:opacity-100 transition-opacity"> | |
| <div>程式設計者:新竹縣精華國中 藍星宇老師</div> | |
| <div>教育社群:<a href="https://www.facebook.com/groups/1554372228718393" target="_blank" class="text-gray-300 hover:text-white pointer-events-auto">萬物皆數</a></div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| export function setupStudentEvents() { | |
| // Start Level Logic | |
| window.startLevel = async (challengeId, link) => { | |
| // Open link | |
| window.open(link, '_blank'); | |
| // Call service to update status | |
| const roomCode = localStorage.getItem('vibecoding_room_code'); | |
| const userId = localStorage.getItem('vibecoding_user_id'); | |
| if (roomCode && userId) { | |
| try { | |
| await startChallenge(userId, roomCode, challengeId); | |
| // Reload view to show Input State | |
| // Ideally we should use state management, but checking URL hash or re-rendering works | |
| const app = document.querySelector('#app'); | |
| app.innerHTML = await renderStudentView(); | |
| // Re-attach events (recursion safety check needed? No, navigateTo does this usually, but here we manually re-render) | |
| // Or better: trigger a custom event or call navigateTo functionality? | |
| // Simple re-render is fine for now. | |
| } catch (e) { | |
| console.error("Start challenge failed", e); | |
| } | |
| } | |
| }; | |
| window.submitLevel = async (challengeId) => { | |
| const input = document.getElementById(`input-${challengeId}`); | |
| const errorMsg = document.getElementById(`error-${challengeId}`); | |
| const prompt = input.value; | |
| const roomCode = localStorage.getItem('vibecoding_room_code'); | |
| const userId = localStorage.getItem('vibecoding_user_id'); | |
| if (!participantDataCheck(roomCode, userId)) return; | |
| if (prompt.trim().length < 5) { | |
| errorMsg.classList.remove('hidden'); | |
| input.classList.add('border-red-500'); | |
| return; | |
| } | |
| errorMsg.classList.add('hidden'); | |
| input.classList.remove('border-red-500'); | |
| // Show loading state on button | |
| const container = input.parentElement; | |
| const btn = container.querySelector('button'); | |
| const originalText = btn.textContent; | |
| btn.textContent = "提交中..."; | |
| btn.disabled = true; | |
| try { | |
| await submitPrompt(userId, roomCode, challengeId, prompt); | |
| btn.textContent = "✓ 已通關"; | |
| btn.classList.add("bg-green-600"); | |
| // Fetch latest progress to ensure correct count | |
| const { getUserProgress } = await import("../services/classroom.js"); | |
| const newProgress = await getUserProgress(userId); | |
| // Re-render the level group to update count and all-clear status | |
| const challenge = cachedChallenges.find(c => c.id === challengeId); | |
| const level = challenge.level; | |
| const levelGroups = { | |
| beginner: cachedChallenges.filter(c => c.level === 'beginner'), | |
| intermediate: cachedChallenges.filter(c => c.level === 'intermediate'), | |
| advanced: cachedChallenges.filter(c => c.level === 'advanced') | |
| }; | |
| const levelNames = { | |
| beginner: "初級 (Beginner)", | |
| intermediate: "中級 (Intermediate)", | |
| advanced: "高級 (Advanced)" | |
| }; | |
| const newGroupHTML = renderLevelGroup(level, levelGroups[level], newProgress, levelNames); | |
| const detailEl = document.getElementById(`details-group-${level}`); | |
| if (detailEl) { | |
| detailEl.outerHTML = newGroupHTML; | |
| } else { | |
| const app = document.querySelector('#app'); | |
| app.innerHTML = await renderStudentView(); | |
| } | |
| } catch (error) { | |
| console.error(error); | |
| btn.textContent = originalText; | |
| btn.disabled = false; | |
| alert("提交失敗: " + error.message); | |
| } | |
| }; | |
| window.resetLevel = async (challengeId) => { | |
| if (!confirm("確定要重置這一題的進度嗎?(提示詞將會保留,但狀態會變回進行中)")) return; | |
| const roomCode = localStorage.getItem('vibecoding_room_code'); | |
| const userId = localStorage.getItem('vibecoding_user_id'); | |
| try { | |
| // Import and call resetProgress (Need to make sure it is imported or available globally? | |
| // Ideally import it. But setupStudentEvents is in module scope so imports are available. | |
| // Wait, import 'resetProgress' is not in the top import list yet. I need to add it.) | |
| // Let's assume I will update the import in the next step or use the global trick if needed. | |
| // But I should edit the import first. | |
| // For now, let's assume it is there. I will add it to the import list in a parallel or subsequent edit. | |
| // Checking imports above... I see 'getUserProgress' but not 'resetProgress'. I must update imports. | |
| // I'll do it in a separate edit step to be safe. | |
| // For now, just the logic: | |
| const { resetProgress } = await import("../services/classroom.js"); // Dynamic import to avoid changing top file lines again? | |
| // Or just rely on previous 'replace' having updated the file? | |
| // Actually, I should update the top import. | |
| await resetProgress(userId, roomCode, challengeId); | |
| const app = document.querySelector('#app'); | |
| app.innerHTML = await renderStudentView(); | |
| } catch (e) { | |
| console.error(e); | |
| alert("重置失敗"); | |
| } | |
| }; | |
| } | |
| function participantDataCheck(roomCode, userId) { | |
| if (!roomCode || !userId) { | |
| alert("連線資訊遺失,請重新登入"); | |
| window.location.reload(); | |
| return false; | |
| } | |
| return true; | |
| } | |
| window.logout = () => { | |
| if (!confirm("確定要登出嗎?")) return; | |
| localStorage.removeItem('vibecoding_user_id'); | |
| localStorage.removeItem('vibecoding_room_code'); | |
| localStorage.removeItem('vibecoding_nickname'); | |
| window.location.reload(); | |
| }; | |
| // Peer Learning Modal Logic | |
| function renderPeerModal() { | |
| // We need to re-fetch challenges for the dropdown? | |
| // They are cached in 'cachedChallenges' module variable | |
| let optionsHtml = '<option value="" disabled selected>選擇題目...</option>'; | |
| if (cachedChallenges.length > 0) { | |
| optionsHtml += cachedChallenges.map(c => | |
| `<option value="${c.id}">[${c.level}] ${c.title}</option>` | |
| ).join(''); | |
| } | |
| return ` | |
| <div id="peer-modal" class="fixed inset-0 bg-black bg-opacity-80 backdrop-blur-sm hidden flex items-center justify-center z-50 p-4"> | |
| <div class="bg-gray-800 rounded-2xl w-full max-w-md h-[80vh] flex flex-col border border-gray-700 shadow-2xl"> | |
| <div class="p-6 border-b border-gray-700 flex justify-between items-center"> | |
| <h3 class="text-xl font-bold text-white">同學的成功提示詞</h3> | |
| <button onclick="closePeerModal()" class="text-gray-400 hover:text-white">✕</button> | |
| </div> | |
| <div class="p-4 bg-gray-900 border-b border-gray-700"> | |
| <select id="peer-challenge-select" onchange="loadPeerPrompts(this.value)" class="w-full bg-gray-800 border border-gray-600 rounded p-2 text-white"> | |
| ${optionsHtml} | |
| </select> | |
| </div> | |
| <div class="p-4 flex-1 overflow-y-auto space-y-4" id="peer-prompts-container"> | |
| <div class="text-center text-gray-500 mt-10">請選擇一個題目來查看</div> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| window.openPeerModal = () => { | |
| const existing = document.getElementById('peer-modal'); | |
| if (existing) existing.remove(); | |
| const div = document.createElement('div'); | |
| div.innerHTML = renderPeerModal(); | |
| document.body.appendChild(div.firstElementChild); | |
| document.getElementById('peer-modal').classList.remove('hidden'); | |
| }; | |
| window.closePeerModal = () => { | |
| document.getElementById('peer-modal').classList.add('hidden'); | |
| }; | |
| // Helper to render a level group (Accordion) | |
| function renderLevelGroup(level, tasks, userProgress, levelNames) { | |
| const detailsId = `details-group-${level}`; | |
| const existingDetails = document.getElementById(detailsId); | |
| let isOpenStr = ''; | |
| if (existingDetails) { | |
| if (existingDetails.hasAttribute('open')) isOpenStr = 'open'; | |
| } else { | |
| // Initial Load defaults | |
| if (level === 'beginner') isOpenStr = 'open'; | |
| } | |
| // Count completed | |
| const completedCount = tasks.filter(t => userProgress[t.id]?.status === 'completed').length; | |
| return ` | |
| <details id="${detailsId}" class="group border border-gray-700 rounded-xl bg-gray-800/30 overflow-hidden transition-all" ${isOpenStr}> | |
| <summary class="flex items-center justify-between p-4 cursor-pointer bg-gray-800/80 hover:bg-gray-700 transition-colors select-none"> | |
| <div class="flex items-center space-x-3"> | |
| <h3 class="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-cyan-400"> | |
| ${levelNames[level]} | |
| </h3> | |
| ${completedCount === tasks.length && tasks.length > 0 ? '<span class="text-yellow-500 text-xs border border-yellow-500/50 px-2 py-0.5 rounded-full">ALL CLEAR</span>' : ''} | |
| </div> | |
| <div class="flex items-center space-x-2"> | |
| <span class="text-xs text-gray-400 bg-gray-900 px-2 py-1 rounded-full">${completedCount} / ${tasks.length}</span> | |
| <svg class="w-5 h-5 text-gray-400 transform group-open:rotate-180 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> | |
| </svg> | |
| </div> | |
| </summary> | |
| <div class="p-4 pt-0 grid grid-cols-1 gap-4 mt-4"> | |
| ${tasks.length > 0 ? tasks.map(c => renderTaskCard(c, userProgress)).join('') : '<div class="text-gray-500 text-sm italic">本區段尚無題目</div>'} | |
| </div> | |
| </details> | |
| `; | |
| } | |
| window.loadPeerPrompts = async (challengeId) => { | |
| const container = document.getElementById('peer-prompts-container'); | |
| container.innerHTML = '<div class="text-center text-gray-400 py-10">載入中...</div>'; | |
| const roomCode = localStorage.getItem('vibecoding_room_code'); | |
| const prompts = await getPeerPrompts(roomCode, challengeId); | |
| if (prompts.length === 0) { | |
| container.innerHTML = '<div class="text-center text-gray-500 py-10">尚無同學提交此關卡或您無權限查看(需相同教室代碼)</div>'; | |
| return; | |
| } | |
| container.innerHTML = prompts.map(p => { | |
| const currentUserId = localStorage.getItem('vibecoding_user_id'); | |
| const isLiked = p.likedBy && p.likedBy.includes(currentUserId); | |
| return ` | |
| <div class="bg-gray-700/30 p-4 rounded-xl border border-gray-600"> | |
| <div class="flex items-center justify-between mb-2"> | |
| <div class="flex items-center space-x-2"> | |
| <div class="w-6 h-6 rounded-full bg-cyan-600 flex items-center justify-center text-xs font-bold text-white"> | |
| ${p.nickname[0]} | |
| </div> | |
| <span class="font-bold text-cyan-300 text-sm">${p.nickname}</span> | |
| <span class="text-gray-500 text-xs">${new Date(p.timestamp.seconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span> | |
| </div> | |
| <button onclick="handleLike('${p.id}', '${p.userId}')" | |
| class="flex items-center space-x-1 px-2 py-1 rounded-full transition-colors ${isLiked ? 'bg-pink-900/50 text-pink-400' : 'bg-gray-800 text-gray-400 hover:bg-gray-700'}"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ${isLiked ? 'fill-current' : 'none'}" viewBox="0 0 24 24" stroke="currentColor" fill="${isLiked ? 'currentColor' : 'none'}"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" /> | |
| </svg> | |
| <span class="text-xs font-bold">${p.likes}</span> | |
| </button> | |
| </div> | |
| <p class="text-gray-300 font-mono text-sm bg-black/20 p-3 rounded-lg border border-gray-700/50 whitespace-pre-wrap">${p.prompt}</p> | |
| </div> | |
| `}).join(''); | |
| // Attach challenge title for notification context | |
| window.currentPeerChallengeTitle = document.querySelector(`#peer-challenge-select option[value="${challengeId}"]`).text; | |
| }; | |
| // Like Handler | |
| window.handleLike = async (progressId, targetUserId) => { | |
| const userId = localStorage.getItem('vibecoding_user_id'); | |
| const nickname = localStorage.getItem('vibecoding_nickname'); | |
| const challengeTitle = window.currentPeerChallengeTitle || '挑戰'; | |
| // Optimistic UI update could go here, but for simplicity let's re-load or just fire and forget (the view won't update until reload currently) | |
| // To make it responsive, we should probably manually toggle the class on the button immediately. | |
| // For now, let's just call service and reload the list to see updated count. | |
| // Better UX: Find button and toggle 'processing' state? | |
| // Let's just reload the list for data consistency. | |
| const { toggleLike } = await import("../services/classroom.js"); | |
| await toggleLike(progressId, userId, nickname, targetUserId, challengeTitle); | |
| // Reload to refresh count | |
| const select = document.getElementById('peer-challenge-select'); | |
| if (select && select.value) { | |
| loadPeerPrompts(select.value); | |
| } | |
| }; | |
| window.triggerEvolution = async (currentStage, nextStage, likes, classSize, currentMonsterId) => { | |
| // 1. Hide Prompt | |
| const prompt = document.getElementById('evolution-prompt'); | |
| if (prompt) prompt.style.display = 'none'; | |
| try { | |
| const { getNextMonster, generateMonsterSVG, MONSTER_STAGES } = await import("../utils/monsterUtils.js"); | |
| const { updateUserMonster, getHigherStageCount } = await import("../services/classroom.js"); | |
| // Calculate Ranking Percentile | |
| const roomCode = localStorage.getItem('vibecoding_room_code'); | |
| let percentile = 1.0; | |
| // Query how many already at next stage (or same level but evolved) | |
| // Actually, we want to know how many have *completed* this stage essentially. | |
| // Since we trigger evolution AFTER completion, anyone AT or ABOVE nextStage has completed it. | |
| // So we count how many are >= nextStage. | |
| // Wait, if I am the first to evolve to stage 1, count >= 1 is 0. | |
| // So rank is (0 + 1) / classSize. | |
| try { | |
| const higherCount = await getHigherStageCount(roomCode, nextStage); | |
| percentile = higherCount / classSize; | |
| console.log(`Evolution Rank: ${higherCount}/${classSize} (Percentile: ${percentile.toFixed(2)})`); | |
| } catch (e) { | |
| console.error("Rank calc failed", e); | |
| } | |
| // Calculate Stage-Specific Likes | |
| // We need to fetch challenges to filter by level... | |
| // cachedChallenges is available in module scope | |
| // We need user progress... | |
| // We can't easily get it here without re-fetching or passing it in. | |
| // But we have 'likes' passed in, which is TOTAL likes. | |
| // Re-fetching progress is safer. | |
| const userId = localStorage.getItem('vibecoding_user_id'); | |
| const { getUserProgress } = await import("../services/classroom.js"); | |
| const progress = await getUserProgress(userId); | |
| let stageLikes = 0; | |
| // Map stage to challenge level | |
| // Stage 0 -> 1 requires Beginner (Level 1) likes | |
| // Stage 1 -> 2 requires Intermediate (Level 2) likes | |
| // Stage 2 -> 3 requires Advanced (Level 3) likes | |
| // Note: nextStage parameter is the TARGET stage. | |
| // If nextStage is 1 (Egg->Baby), we count Beginner likes. | |
| const targetLevelMap = { | |
| 1: 'beginner', | |
| 2: 'intermediate', | |
| 3: 'advanced' | |
| }; | |
| const targetLevel = targetLevelMap[nextStage]; | |
| if (targetLevel) { | |
| stageLikes = cachedChallenges | |
| .filter(c => c.level === targetLevel) | |
| .reduce((acc, c) => acc + (progress[c.id]?.likes || 0), 0); | |
| } | |
| console.log(`Stage ${nextStage} Likes: ${stageLikes} (Total: ${likes})`); | |
| // Calculate Next Monster with Lineage & Ranking | |
| // IMPORTANT: Ensure we pass currentMonsterId to enforce lineage! | |
| // We pass 'stageLikes' instead of total 'likes' now | |
| const nextMonster = getNextMonster(nextStage, stageLikes, classSize, currentMonsterId, percentile); | |
| console.log("Evolving from:", currentMonsterId, "to:", nextMonster.name); | |
| const container = document.querySelector('#monster-container-fixed .pixel-monster'); | |
| const containerWrapper = document.querySelector('#monster-container-fixed .pixel-art-container'); | |
| // Stop breathing animation | |
| container.style.animation = 'none'; | |
| // --- ANIMATION SEQUENCE --- | |
| let count = 0; | |
| const maxFlickers = 12; // Increased duration | |
| let speed = 300; | |
| const currentMonster = MONSTER_DEFS.find(m => m.id === currentMonsterId) || getNextMonster(currentStage, 0, 0, currentMonsterId); | |
| const svgCurrent = generateMonsterSVG(currentMonster); | |
| const svgNext = generateMonsterSVG(nextMonster); | |
| const setFrame = (svg, isSilhouette) => { | |
| container.innerHTML = svg; | |
| container.style.filter = isSilhouette ? 'brightness(0)' : 'none'; | |
| }; | |
| const playFlicker = () => { | |
| const isNext = count % 2 === 1; | |
| setFrame(isNext ? svgNext : svgCurrent, true); | |
| count++; | |
| if (count < maxFlickers) { | |
| speed *= 0.85; | |
| setTimeout(playFlicker, speed); | |
| } else { | |
| // Final Reveal | |
| setTimeout(() => { | |
| setFrame(svgNext, true); // Hold silhouette | |
| setTimeout(() => { | |
| // Reveal Color with Flash | |
| containerWrapper.style.transition = 'filter 0.8s ease-out'; | |
| containerWrapper.style.filter = 'drop-shadow(0 0 30px #ffffff) brightness(1.5)'; | |
| // Force SVG update to next monster for final state | |
| setFrame(svgNext, false); | |
| setTimeout(async () => { | |
| containerWrapper.style.filter = 'none'; | |
| // DB Update with Monster ID | |
| const userId = localStorage.getItem('vibecoding_user_id'); | |
| await updateUserMonster(userId, nextStage, nextMonster.id); | |
| // Soft refresh to prevent screen flicker | |
| const app = document.querySelector('#app'); | |
| if (app) app.innerHTML = await renderStudentView(); | |
| // Also need to ensure monster container is updated (handle in renderStudentView) | |
| }, 1200); | |
| }, 1000); | |
| }, 300); | |
| } | |
| }; | |
| playFlicker(); | |
| } catch (e) { | |
| console.error(e); | |
| alert("進化失敗: " + (e.message || "未知錯誤")); | |
| // Re-render instead of reload to keep state | |
| const app = document.querySelector('#app'); | |
| if (app) app.innerHTML = await renderStudentView(); | |
| } | |
| }; | |