Vibecodingex / src /views /StudentView.js
Lashtw's picture
Upload 9 files
78849f8 verified
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 `
<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 flex justify-center">
<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 ---
// --- Monster Section Helper Functions ---
// 1. Calculate Monster State (Separated logic)
const calculateMonsterState = (currentUserProgress, classSize, userProfile) => {
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];
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;
const actualStage = userProfile.monster_stage || 0;
const actualMonsterId = userProfile.monster_id || 'Egg';
// Regression Logic Check
if (actualStage > potentialStage) {
// Regression Logic: Pass 'Egg' (or null) to enforce downgrade
const targetId = potentialStage === 0 ? 'Egg' : null; // If going to 0, force Egg. Else keep null to let system decide? Or should we query?
// Actually, if we devolve, we lose the specific form. So we should probably reset ID to something generic or clear it.
// If we go to stage 0, 'Egg' is safe.
// If we go to stage 1 (from 2), we might want to keep the stage 1 form (if we knew it).
// But simplifying: just clear the ID so getNextMonster picks default for that stage.
updateUserMonster(userId, potentialStage, targetId).then(() => {
setTimeout(() => window.location.reload(), 500);
});
// Return corrected state temporary
return {
...calculateMonsterState(currentUserProgress, classSize, { ...userProfile, monster_stage: potentialStage, monster_id: targetId }),
isRegressing: true
};
}
const canEvolve = potentialStage > actualStage;
// Scale Logic
const growthFactor = 0.08;
const baseScale = 1.0;
const currentScale = baseScale + (totalCompleted * growthFactor);
// Get Monster Data
let monster = getNextMonster(actualStage, 0, 0, actualMonsterId);
if (actualMonsterId && actualMonsterId !== 'Egg') {
const stored = MONSTER_DEFS.find(m => m.id === actualMonsterId);
if (stored) monster = stored;
} else {
monster = getNextMonster(actualStage, totalLikes, classSize);
}
return {
monster,
currentScale,
totalCompleted,
totalLikes,
canEvolve,
actualStage,
classSize,
counts
};
};
// 2. Render Stats HTML (Partial Update Target)
const renderMonsterStats = (state) => {
return `
<div class="font-bold text-white text-sm mb-1 text-center border-b border-gray-700 pb-1">${state.monster.name}</div>
<div class="space-y-1 mt-1 text-center">
<div class=""><span>💖</span> <span class="text-pink-400 font-bold ml-1">${state.totalLikes}</span></div>
</div>
`;
};
// 3. Render Full Monster Container (Initial or Full Update)
// Now positioned stats to TOP
const renderMonsterSection = (currentUserProgress, classSize, userProfile) => {
const state = calculateMonsterState(currentUserProgress, classSize, userProfile);
// Left sidebar position: Responsive
// Mobile: Top Left (in header space)
// Desktop: Fixed Left Sidebar
return `
<div id="monster-container-fixed" data-monster-id="${state.monster.id}" data-scale="${state.currentScale}"
class="fixed top-2 left-2 z-50 flex flex-col items-center group pointer-events-none sm:pointer-events-auto w-24 h-24 sm:w-32 sm:h-32 md:top-32 md:left-8">
<!-- Stats Tooltip (Moved to Top) -->
<div id="monster-stats-content" class="absolute bottom-full mb-2 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 text-left pointer-events-auto shadow-2xl min-w-[100px]">
${renderMonsterStats(state)}
</div>
<!-- Walking Container (Handles Movement Only) -->
<div class="pixel-art-container relative transform transition-transform duration-500 ease-out origin-center"
style="transform: scale(${state.currentScale}); animation: patrol-move 15s linear infinite;">
<!-- Monster Sprite (Handles Flip & Breathe) -->
<div class="pixel-monster w-28 h-28 drop-shadow-2xl filter" style="animation: breathe 3s infinite ease-in-out, patrol-flip 15s linear infinite;">
${generateMonsterSVG(state.monster)}
</div>
<!-- Level Indicator (No Flip) -->
<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 + state.totalCompleted}
</div>
</div>
<!-- Evolution Prompt -->
${state.canEvolve ? `
<div id="evolution-prompt" class="absolute top-full mt-2 pointer-events-auto animate-bounce z-50">
<div class="flex flex-col items-center">
<div class="bg-gray-900/90 text-pink-200 text-xs py-2 px-3 rounded-xl border border-pink-500/30 shadow-lg text-center font-bold mb-1 backdrop-blur-sm whitespace-nowrap">
咦,小怪獸的樣子<br>正在發生變化...
</div>
<button onclick="window.triggerEvolution(${state.actualStage}, ${state.actualStage + 1}, ${state.totalLikes}, ${classSize}, '${state.monster.id}')"
class="bg-gradient-to-r from-pink-600 to-purple-600 hover:from-pink-500 hover:to-purple-500 text-white text-sm font-black py-2 px-6 rounded-full shadow-[0_0_15px_rgba(236,72,153,0.8)] border border-white/30 transition-all hover:scale-110 active:scale-95">
進化!
</button>
</div>
</div>
` : ''}
</div>
<style>
@keyframes breathe {
0%, 100% { transform: translateY(0); filter: brightness(1); }
50% { transform: translateY(-3px); filter: brightness(1.1); }
}
@keyframes patrol-move {
0% { transform: translateX(0); }
45% { transform: translateX(120px); }
55% { transform: translateX(120px); }
95% { transform: translateX(0); }
100% { transform: translateX(0); }
}
@keyframes patrol-flip {
0% { transform: scaleX(1); }
49% { transform: scaleX(1); }
50% { transform: scaleX(-1); }
99% { transform: scaleX(-1); }
100% { transform: scaleX(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 };
// Smart Update: Check if visual refresh is needed
const newState = calculateMonsterState(updatedProgress, classSize, userProfile);
const fixedContainer = document.getElementById('monster-container-fixed');
const currentMonsterId = fixedContainer?.getAttribute('data-monster-id');
// Tolerance for scale check usually not needed if we want scale to update visuals immediately?
// Actually, scale change usually means totalCompleted changed.
// If we want smooth growth, replacing DOM resets animation which looks slight jumpy but acceptable.
// But the user complained about "position reset" (walk cycle reset).
if (fixedContainer && String(currentMonsterId) === String(newState.monster.id)) {
// Monster ID is same (no evolution/devolution).
// Just update stats tooltip
const statsContainer = document.getElementById('monster-stats-content');
if (statsContainer) {
statsContainer.innerHTML = renderMonsterStats(newState);
}
// What if level up (scale change)?
// If we don't replace DOM, scale won't update in style attribute.
// We should update the style manually.
const artContainer = fixedContainer.querySelector('.pixel-art-container');
if (artContainer && newState.currentScale) {
// Update animation with new scale
// Note: Modifying 'transform' directly might conflict with keyframes unless keyframes use relative or we update style variable.
// Keyframes use: scale(${currentScale}). This is hardcoded in specific keyframes string in <style>.
// We can't easily update keyframes dynamic values without replacing style block.
// If totalCompleted changed (level up), user *might* accept a reset because they levelled up.
// But simply giving a heart shouldn't reset.
const oldTotal = parseInt(fixedContainer.querySelector('.bg-gray-900\\/90')?.textContent?.replace('Lv.', '') || '1') - 1;
if (oldTotal !== newState.totalCompleted) {
// Level changed -> Scale changed -> Re-render full (reset animation is fine for Level Up)
monsterContainer.innerHTML = renderMonsterSection(updatedProgress, classSize, userProfile);
}
}
} else {
// Monster changed or clean slate -> Full Render
monsterContainer.innerHTML = renderMonsterSection(updatedProgress, classSize, userProfile);
}
});
// 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>
<button onclick="window.logout()" class="text-gray-500 hover:text-red-400 transition-colors p-2" 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 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</header>
<div class="space-y-4">
${['beginner', 'intermediate', 'advanced'].map(level => {
return renderLevelGroup(level, levelGroups[level] || [], userProgress, levelNames);
}).join('')}
</div>
<!-- Peer Learning FAB -->
<button onclick="window.openPeerModal()" class="fixed bottom-14 right-4 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 flex items-center space-x-2"
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>
<span class="font-bold text-sm hidden sm:inline">查看同學提示詞</span>
</button>
<!-- Credits Footer -->
<div class="fixed bottom-1 right-2 z-30 text-[10px] text-gray-400 font-mono text-right pointer-events-none sm:pointer-events-auto select-none opacity-80 hover:opacity-100 transition-opacity">
<div>程式設計者:新竹縣精華國中 藍星宇老師</div>
<div>教育社群:<a href="https://www.facebook.com/groups/1554372228718393" target="_blank" class="text-gray-300 hover:text-white pointer-events-auto">萬物皆數</a></div>
</div>
</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");
// Fetch latest progress to ensure correct count
const { getUserProgress } = await import("../services/classroom.js");
const newProgress = await getUserProgress(userId);
// Re-render the level group to update count and all-clear status
const challenge = cachedChallenges.find(c => c.id === challengeId);
const level = challenge.level;
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)"
};
const newGroupHTML = renderLevelGroup(level, levelGroups[level], newProgress, levelNames);
const detailEl = document.getElementById(`details-group-${level}`);
if (detailEl) {
detailEl.outerHTML = newGroupHTML;
} else {
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;
}
window.logout = () => {
if (!confirm("確定要登出嗎?")) return;
localStorage.removeItem('vibecoding_user_id');
localStorage.removeItem('vibecoding_room_code');
localStorage.removeItem('vibecoding_nickname');
window.location.reload();
};
// 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');
};
// Helper to render a level group (Accordion)
function renderLevelGroup(level, tasks, userProgress, levelNames) {
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;
return `
<details id="${detailsId}" class="group border border-gray-700 rounded-xl bg-gray-800/30 overflow-hidden transition-all" ${isOpenStr}>
<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>
`;
}
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 (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, getHigherStageCount } = await import("../services/classroom.js");
// Calculate Ranking Percentile
const roomCode = localStorage.getItem('vibecoding_room_code');
let percentile = 1.0;
// Query how many already at next stage (or same level but evolved)
// Actually, we want to know how many have *completed* this stage essentially.
// Since we trigger evolution AFTER completion, anyone AT or ABOVE nextStage has completed it.
// So we count how many are >= nextStage.
// Wait, if I am the first to evolve to stage 1, count >= 1 is 0.
// So rank is (0 + 1) / classSize.
try {
const higherCount = await getHigherStageCount(roomCode, nextStage);
percentile = higherCount / classSize;
console.log(`Evolution Rank: ${higherCount}/${classSize} (Percentile: ${percentile.toFixed(2)})`);
} catch (e) {
console.error("Rank calc failed", e);
}
// Calculate Stage-Specific Likes
// We need to fetch challenges to filter by level...
// cachedChallenges is available in module scope
// We need user progress...
// We can't easily get it here without re-fetching or passing it in.
// But we have 'likes' passed in, which is TOTAL likes.
// Re-fetching progress is safer.
const userId = localStorage.getItem('vibecoding_user_id');
const { getUserProgress } = await import("../services/classroom.js");
const progress = await getUserProgress(userId);
let stageLikes = 0;
// Map stage to challenge level
// Stage 0 -> 1 requires Beginner (Level 1) likes
// Stage 1 -> 2 requires Intermediate (Level 2) likes
// Stage 2 -> 3 requires Advanced (Level 3) likes
// Note: nextStage parameter is the TARGET stage.
// If nextStage is 1 (Egg->Baby), we count Beginner likes.
const targetLevelMap = {
1: 'beginner',
2: 'intermediate',
3: 'advanced'
};
const targetLevel = targetLevelMap[nextStage];
if (targetLevel) {
stageLikes = cachedChallenges
.filter(c => c.level === targetLevel)
.reduce((acc, c) => acc + (progress[c.id]?.likes || 0), 0);
}
console.log(`Stage ${nextStage} Likes: ${stageLikes} (Total: ${likes})`);
// Calculate Next Monster with Lineage & Ranking
// IMPORTANT: Ensure we pass currentMonsterId to enforce lineage!
// We pass 'stageLikes' instead of total 'likes' now
const nextMonster = getNextMonster(nextStage, stageLikes, classSize, currentMonsterId, percentile);
console.log("Evolving from:", currentMonsterId, "to:", nextMonster.name);
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 currentMonster = MONSTER_DEFS.find(m => m.id === currentMonsterId) || getNextMonster(currentStage, 0, 0, currentMonsterId);
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)';
// Force SVG update to next monster for final state
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);
alert("進化失敗: " + (e.message || "未知錯誤"));
// Re-render instead of reload to keep state
const app = document.querySelector('#app');
if (app) app.innerHTML = await renderStudentView();
}
};