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 (currentStage, nextStage, likes, classSize) => { // 1. Hide Prompt const prompt = document.getElementById('evolution-prompt'); if (prompt) prompt.style.display = 'none'; // 2. Prepare Animation Data // We need Next Monster Data // We can't easily import logic here if not exposed, but we exported getNextMonster. // We need to re-import or use the one in scope if available. // Fortunately setupStudentEvents is a module, but this function is on window. // We need to pass data or use a helper. // Ideally we should move getNextMonster to a global helper or fetch it. // Let's use dynamic import to be safe and robust. try { const { getNextMonster, generateMonsterSVG, MONSTER_STAGES } = await import("../utils/monsterUtils.js"); const { updateUserStage } = await import("../services/classroom.js"); const currentMonster = getNextMonster(currentStage, likes, classSize); const nextMonster = getNextMonster(nextStage, likes, classSize); 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 --- // flicker count let count = 0; const maxFlickers = 10; let speed = 300; // start slow const svgCurrent = generateMonsterSVG(currentMonster); const svgNext = generateMonsterSVG(nextMonster); // Helper to set Content and Style const setFrame = (svg, isSilhouette) => { container.innerHTML = svg; container.style.filter = isSilhouette ? 'brightness(0) invert(1)' : 'none'; // White silhouette? User said 'silhouette' usually black or white. Let's try Black (brightness 0) if (isSilhouette) container.style.filter = 'brightness(0)'; }; const playFlicker = () => { // Alternate const isNext = count % 2 === 1; setFrame(isNext ? svgNext : svgCurrent, true); count++; if (count < maxFlickers) { // Speed up speed *= 0.8; setTimeout(playFlicker, speed); } else { // Final Reveal setTimeout(() => { // Pause on Next Silhouette setFrame(svgNext, true); setTimeout(() => { // Reveal Color with flash containerWrapper.style.transition = 'filter 0.5s ease-out'; containerWrapper.style.filter = 'drop-shadow(0 0 20px #ffffff)'; // Flash setFrame(svgNext, false); // Color setTimeout(async () => { containerWrapper.style.filter = 'none'; // DB Update const userId = localStorage.getItem('vibecoding_user_id'); await updateUserStage(userId, nextStage); // Reload const app = document.querySelector('#app'); // We need to re-import renderStudentView? It's exported. // But we are inside window function. // Just generic reload for now or try to re-render if accessible. // renderStudentView is not global. // Let's reload page to be cleanest or rely on the subscribeToUserProgress which might flicker? // subscribeToUserProgress listens to PROGRESS collection, not USERS collection (where monster scale is). // So we MUST reload or manually fetch profile. // Simple reload: // window.location.reload(); // Or better: triggering the view re-render. // Note: We don't have access to 'renderStudentView' function here easily unless we attached it to window. // Let's reload to ensure clean state. window.location.reload(); }, 1000); }, 800); }, 200); } }; playFlicker(); } catch (e) { console.error(e); alert("進化失敗..."); window.location.reload(); } };