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

${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 & ID const actualStage = userProfile.monster_stage || 0; const actualMonsterId = userProfile.monster_id || 'Egg'; // --- REGRESSION LOGIC (Auto-Devolve) --- // If actual stage > potential stage, it means tasks were reset/rejected. // We must devolve. if (actualStage > potentialStage) { // Devolve immediately (or show animation? Immediate for compliance) // We need to call DB update. But we are in render function. // Side-effect in render is bad, but necessary for self-correction. // Let's debounce or check if we haven't already corrected. // console.warn("Devolving from", actualStage, "to", potentialStage); // Trigger update async updateUserMonster(userId, potentialStage, null).then(() => { // Reload to reflect // window.location.reload(); // Might cause loop if not careful? // If update succeeds, the subscription will fire? // No, subscription listens to PROGRESS, not USER profile updates unless we subscribe to USER too. // We currently verify profile on load. // Let's just force a reload once to correct it. /* setTimeout(() => window.location.reload(), 500); */ }); // For THIS render, show potential stage // return renderMonsterSection(currentUserProgress, classSize, { ...userProfile, monster_stage: potentialStage }); } // 3. Display Logic const canEvolve = potentialStage > actualStage; // Scale Logic const growthFactor = 0.08; const baseScale = 1.0; const currentScale = baseScale + (totalCompleted * growthFactor); // Get Monster Data (Preserve Lineage) // If we have an actual ID, use it for display until evolution. // If we are about to evolve, we preview next? No, current. let monster = getNextMonster(actualStage, 0, 0, actualMonsterId); // Just lookup current by ID/Stage? if (actualMonsterId && actualMonsterId !== 'Egg') { // If we have a specific ID stored, try to use it directly const stored = MONSTER_DEFS.find(m => m.id === actualMonsterId); if (stored) monster = stored; } else { // Fallback for Egg or legacy data without ID monster = getNextMonster(actualStage, totalLikes, classSize); } // Left sidebar position: Fixed Left-8 or similar. // User wants it in the empty left area. 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] || []; // State Preservation Logic: // Check DOM for existing details element and its open attribute // ID strategy: details-{level} // If not found, default to open for 'beginner', closed for others. // But if we are re-rendering, we want to keep current state. 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; const allClear = completedCount === tasks.length && tasks.length > 0; 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, 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 } = await import("../services/classroom.js"); // Calculate Next Monster with Lineage const currentMonster = getNextMonster(currentStage, likes, classSize, currentMonsterId); const nextMonster = getNextMonster(nextStage, likes, classSize, currentMonsterId); 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 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)'; 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); console.error(e); alert("進化失敗..."); // Re-render instead of reload to keep state const app = document.querySelector('#app'); if (app) app.innerHTML = await renderStudentView(); } };