Spaces:
Running
Running
| import { submitPrompt, getChallenges, startChallenge, getUserProgress, getPeerPrompts, resetProgress, toggleLike, subscribeToNotifications, markNotificationRead, getClassSize, updateUserStage, getUser, subscribeToUserProgress } from "../services/classroom.js"; | |
| import { generateMonsterSVG, getNextMonster, MONSTER_STAGES } 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"> | |
| <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 --- | |
| const renderMonsterSection = (currentUserProgress, classSize, userProfile) => { | |
| // Calculate Stats | |
| 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]; | |
| // 1. Calculate Potential Stage | |
| 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; | |
| // 2. Get Actual Stage | |
| const actualStage = userProfile.monster_stage || 0; | |
| // 3. Display Logic | |
| const canEvolve = potentialStage > actualStage; | |
| // Growth: Base Scale 1.0. | |
| // Grows with TOTAL completed tasks. e.g. 0.05 per task. | |
| // Also resets effective growth if we evolve? | |
| // User said: "Monster size should grow every time a question is answered correctly" | |
| // And "Level also rise". | |
| // Let's make base scale depend on tasks completed SINCE last evolution? | |
| // Or just total tasks. | |
| // If I evolve, I probably want to start small-ish again? | |
| // But "Stage 2" monster should probably be bigger than "Stage 1 Egg". | |
| // Let's use a simple global scalar: | |
| const growthFactor = 0.08; | |
| const baseScale = 1.0; | |
| // Adjust for stage so high stage monsters aren't tiny initially? | |
| // Actually, let's just make it grow linearly based on total questions. | |
| // But if I evolve, does it shrink? | |
| // User request: "If don't evolve... keep getting bigger" | |
| // Implicitly, evolving might reset the 'extra' growth or change the base form. | |
| // Let's just use Total Completed for scale. | |
| const currentScale = baseScale + (totalCompleted * growthFactor); | |
| const monster = getNextMonster(actualStage, totalLikes, classSize); | |
| return ` | |
| <div class="fixed top-8 left-10 z-50 flex flex-col items-center group pointer-events-none sm:pointer-events-auto"> | |
| <!-- Monster --> | |
| <div class="pixel-art-container relative transform transition-transform duration-500 ease-out origin-center hover:scale-110" style="transform: scale(${currentScale});"> | |
| <div class="pixel-monster w-28 h-28 drop-shadow-2xl filter" style="animation: breathe 3s infinite ease-in-out;"> | |
| ${generateMonsterSVG(monster)} | |
| </div> | |
| <!-- Level Indicator (Total Quests) --> | |
| <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 + totalCompleted} | |
| </div> | |
| </div> | |
| <!-- Evolution Prompt --> | |
| ${canEvolve ? ` | |
| <div class="absolute top-full mt-4 left-1/2 -translate-x-1/2 w-48 pointer-events-auto animate-bounce"> | |
| <div class="bg-gradient-to-br from-indigo-900 to-purple-900 border-2 border-pink-500 rounded-xl p-3 shadow-[0_0_20px_rgba(236,72,153,0.6)] text-center relative"> | |
| <!-- Triangle tip --> | |
| <div class="absolute -top-2 left-1/2 -translate-x-1/2 w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-b-[8px] border-b-pink-500"></div> | |
| <p class="text-xs text-pink-200 mb-2 font-bold leading-tight"> | |
| 咦,小怪獸的樣子正在發生變化...<br>是否要進化? | |
| </p> | |
| <button onclick="window.triggerEvolution(${actualStage + 1})" class="w-full bg-pink-600 hover:bg-pink-500 text-white text-xs font-bold py-1.5 rounded-lg transition-colors shadow-sm"> | |
| ✨ 立即進化 | |
| </button> | |
| </div> | |
| </div> | |
| ` : ''} | |
| <!-- Stats Tooltip --> | |
| <div class="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 mt-6 text-left pointer-events-auto shadow-2xl min-w-[120px]"> | |
| <div class="font-bold text-white text-sm mb-1 text-center border-b border-gray-700 pb-1">${monster.name}</div> | |
| <div class="space-y-1 mt-1"> | |
| <div class="flex justify-between"><span>💖 愛心:</span> <span class="text-pink-400 font-bold">${totalLikes}</span></div> | |
| <div class="flex justify-between"><span>🏫 人數:</span> <span class="text-cyan-400 font-bold">${classSize}</span></div> | |
| <div class="flex justify-between"><span>⚔️ 任務:</span> <span class="text-yellow-400 font-bold">${totalCompleted}</span></div> | |
| </div> | |
| </div> | |
| </div> | |
| <style> | |
| @keyframes breathe { | |
| 0%, 100% { transform: translateY(0); filter: brightness(1); } | |
| 50% { transform: translateY(-3px); filter: brightness(1.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 }; | |
| // Re-render only Monster Section | |
| monsterContainer.innerHTML = renderMonsterSection(updatedProgress, classSize, userProfile); | |
| // Update userProgress ref for other logic? | |
| // Note: 'userProgress' variable in this scope won't update for 'renderTaskCard' unless we reload. | |
| // But for Monster UI it's fine. | |
| }); | |
| // 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> | |
| <!-- Logo removed/minimized since we have Monster --> | |
| <!-- <div> | |
| <h1 class="text-xl font-bold italic text-white tracking-widest">VIBECODING</h1> | |
| </div> --> | |
| </header> | |
| <div class="space-y-4"> | |
| ${['beginner', 'intermediate', 'advanced'].map(level => { | |
| const tasks = levelGroups[level] || []; | |
| const isOpen = level === 'beginner' ? 'open' : ''; | |
| // Count completed | |
| const completedCount = tasks.filter(t => userProgress[t.id]?.status === 'completed').length; | |
| return ` | |
| <details class="group border border-gray-700 rounded-xl bg-gray-800/30 overflow-hidden transition-all" ${isOpen}> | |
| <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> | |
| `; | |
| }).join('')} | |
| </div> | |
| <!-- Peer Learning FAB --> | |
| <button onclick="window.openPeerModal()" class="fixed bottom-6 right-6 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" | |
| 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> | |
| </button> | |
| </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"); | |
| // NEW: Partial Update Strategy directly | |
| // 1. Find the container | |
| // The card is the great-great-grandparent of the button (button -> div -> div -> div -> card) | |
| // Or simpler: give ID to card wrapper in renderTaskCard. | |
| // Let's assume renderTaskCard now adds id="card-${c.id}" | |
| // 2. Re-render just this card | |
| const challenge = cachedChallenges.find(c => c.id === challengeId); | |
| const newProgress = { [challengeId]: { status: 'completed', submission_prompt: prompt } }; | |
| // We need to merge with existing progress to pass to renderTaskCard? | |
| // Actually renderTaskCard takes the whole userProgress map. | |
| // But for this specific card, we can just pass a map containing just this one, because renderTaskCard(c, map) only looks up map[c.id]. | |
| const newCardHTML = renderTaskCard(challenge, newProgress); | |
| // 3. Replace in DOM | |
| const oldCard = document.getElementById(`card-${challengeId}`); | |
| if (oldCard) { | |
| oldCard.outerHTML = newCardHTML; | |
| } else { | |
| // Fallback if ID not found (should not happen if we update renderTaskCard) | |
| 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; | |
| } | |
| // 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'); | |
| }; | |
| 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 (nextStage) => { | |
| const userId = localStorage.getItem('vibecoding_user_id'); | |
| if (!confirm(`是否要進化到階段 ${nextStage}?\n(進化後怪獸大小將重置)`)) return; | |
| try { | |
| const { updateUserStage } = await import("../services/classroom.js"); | |
| await updateUserStage(userId, nextStage); | |
| alert("✨ 進化成功!"); | |
| // Reload view | |
| const app = document.querySelector('#app'); | |
| app.innerHTML = await renderStudentView(); | |
| } catch (e) { | |
| console.error(e); | |
| alert("進化失敗"); | |
| } | |
| }; | |