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 `
🏆

${c.title}

已通關
`; } // 2. Started or Not Started return `

${c.title}

任務說明

${c.description}

${!isStarted ? `
` : `
`}
`; } 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 `
${generateMonsterSVG(monster)}
Lv.${1 + totalCompleted}
${canEvolve ? `

咦,小怪獸的樣子正在發生變化...
是否要進化?

` : ''}
${monster.name}
💖 愛心: ${totalLikes}
🏫 人數: ${classSize}
⚔️ 任務: ${totalCompleted}
`; }; // 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 `
${nickname}
教室: ${roomCode}
${['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 `

${levelNames[level]}

${completedCount === tasks.length && tasks.length > 0 ? 'ALL CLEAR' : ''}
${completedCount} / ${tasks.length}
${tasks.length > 0 ? tasks.map(c => renderTaskCard(c, userProgress)).join('') : '
本區段尚無題目
'}
`; }).join('')}
`; } 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 = ''; if (cachedChallenges.length > 0) { optionsHtml += cachedChallenges.map(c => `` ).join(''); } return ` `; } 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 = '
載入中...
'; const roomCode = localStorage.getItem('vibecoding_room_code'); const prompts = await getPeerPrompts(roomCode, challengeId); if (prompts.length === 0) { container.innerHTML = '
尚無同學提交此關卡或您無權限查看(需相同教室代碼)
'; return; } container.innerHTML = prompts.map(p => { const currentUserId = localStorage.getItem('vibecoding_user_id'); const isLiked = p.likedBy && p.likedBy.includes(currentUserId); return `
${p.nickname[0]}
${p.nickname} ${new Date(p.timestamp.seconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}

${p.prompt}

`}).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("進化失敗"); } };