`;
}
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 `
`;
}
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 `