Vibecodingex / src /views /StudentView.js
Lashtw's picture
Upload 8 files
2eb9b8e verified
raw
history blame
31.6 kB
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 `
<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">
<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 ---
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 `
<div class="fixed top-8 left-10 z-50 flex flex-col items-center group pointer-events-none sm:pointer-events-auto">
<!-- Monster -->
<div class="pixel-art-container relative transform transition-transform duration-500 ease-out origin-center hover:scale-110" style="transform: scale(${currentScale});">
<div class="pixel-monster w-28 h-28 drop-shadow-2xl filter" style="animation: breathe 3s infinite ease-in-out;">
${generateMonsterSVG(monster)}
</div>
<!-- Level Indicator (Total Quests) -->
<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 + totalCompleted}
</div>
</div>
<!-- Evolution Prompt -->
${canEvolve ? `
<div class="absolute top-full mt-4 left-1/2 -translate-x-1/2 w-48 pointer-events-auto animate-bounce">
<div class="bg-gradient-to-br from-indigo-900 to-purple-900 border-2 border-pink-500 rounded-xl p-3 shadow-[0_0_20px_rgba(236,72,153,0.6)] text-center relative">
<!-- Triangle tip -->
<div class="absolute -top-2 left-1/2 -translate-x-1/2 w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-b-[8px] border-b-pink-500"></div>
<p class="text-xs text-pink-200 mb-2 font-bold leading-tight">
咦,小怪獸的樣子正在發生變化...<br>是否要進化?
</p>
<button onclick="window.triggerEvolution(${actualStage + 1})" class="w-full bg-pink-600 hover:bg-pink-500 text-white text-xs font-bold py-1.5 rounded-lg transition-colors shadow-sm">
✨ 立即進化
</button>
</div>
</div>
` : ''}
<!-- Stats Tooltip -->
<div class="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 mt-6 text-left pointer-events-auto shadow-2xl min-w-[120px]">
<div class="font-bold text-white text-sm mb-1 text-center border-b border-gray-700 pb-1">${monster.name}</div>
<div class="space-y-1 mt-1">
<div class="flex justify-between"><span>💖 愛心:</span> <span class="text-pink-400 font-bold">${totalLikes}</span></div>
<div class="flex justify-between"><span>🏫 人數:</span> <span class="text-cyan-400 font-bold">${classSize}</span></div>
<div class="flex justify-between"><span>⚔️ 任務:</span> <span class="text-yellow-400 font-bold">${totalCompleted}</span></div>
</div>
</div>
</div>
<style>
@keyframes breathe {
0%, 100% { transform: translateY(0); filter: brightness(1); }
50% { transform: translateY(-3px); filter: brightness(1.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 };
// 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 `
<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>
<!-- Logo removed/minimized since we have Monster -->
<!-- <div>
<h1 class="text-xl font-bold italic text-white tracking-widest">VIBECODING</h1>
</div> -->
</header>
<div class="space-y-4">
${['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 `
<details class="group border border-gray-700 rounded-xl bg-gray-800/30 overflow-hidden transition-all" ${isOpen}>
<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>
`;
}).join('')}
</div>
<!-- Peer Learning FAB -->
<button onclick="window.openPeerModal()" class="fixed bottom-6 right-6 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"
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>
</button>
</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");
// 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 = '<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');
};
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 (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("進化失敗");
}
};