Vibecodingex / src /views /StudentView.js
Lashtw's picture
Upload 8 files
9215d48 verified
raw
history blame
21.6 kB
import { submitPrompt, getChallenges, startChallenge, getUserProgress, getPeerPrompts, resetProgress, toggleLike, subscribeToNotifications, markNotificationRead } from "../services/classroom.js";
// Cache challenges locally
let cachedChallenges = [];
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)"
};
function renderTaskCard(c) {
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 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
// If Started: Show "Prompt Input" area.
// If Not Started: Show "Start Task" button only.
return `
<div 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>
<p class="text-gray-400 text-sm whitespace-pre-line">${c.description}</p>
</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>
`;
}
// Accordion Layout
return `
<div class="min-h-screen p-4 pb-32 max-w-md mx-auto sm:max-w-4xl">
<header class="flex justify-between 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">
<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>
<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)).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");
// Re-render to show Trophy state after short delay
setTimeout(async () => {
const app = document.querySelector('#app');
app.innerHTML = await renderStudentView();
}, 1000);
} 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);
}
};