Spaces:
Running
Running
| import { submitPrompt, getChallenges } from "../services/classroom.js"; | |
| import { getPeerPrompts } from "../services/classroom.js"; // Import needed for modal | |
| // Cache challenges locally to avoid refetching heavily | |
| let cachedChallenges = []; | |
| export async function renderStudentView() { | |
| const nickname = localStorage.getItem('vibecoding_nickname') || 'Guest'; | |
| // 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 + ")"); | |
| } | |
| } | |
| // Group by Level (optional, but good for UI organization if we wanted headers) | |
| // For now, let's just map them. | |
| // Or if we want to keep the headers: | |
| 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 renderCard = (c) => ` | |
| <div class="group relative bg-gray-800 bg-opacity-50 border 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 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> | |
| <a href="${c.link}" target="_blank" | |
| class="w-full sm:w-auto text-center bg-gray-700 hover:bg-cyan-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center space-x-2" | |
| title="前往題目"> | |
| <span>Go to Task</span> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" 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="mt-auto pt-4 border-t border-gray-700/50"> | |
| <label class="block text-xs uppercase tracking-wider text-gray-500 mb-2">提交修復提示詞</label> | |
| <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="輸入你的 Prompt..."></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> | |
| `; | |
| // Render logic | |
| let contentHtml = ''; | |
| if (cachedChallenges.length === 0) { | |
| contentHtml = '<div class="text-center text-gray-500 py-10">目前沒有題目,請請講師至後台新增。</div>'; | |
| } else { | |
| ['beginner', 'intermediate', 'advanced'].forEach(levelId => { | |
| const tasks = levelGroups[levelId] || []; | |
| if (tasks.length > 0) { | |
| contentHtml += ` | |
| <section> | |
| <h3 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-cyan-400 mb-4 px-2"> | |
| ${levelNames[levelId]} | |
| </h3> | |
| <div class="grid grid-cols-1 gap-6"> | |
| ${tasks.map(renderCard).join('')} | |
| </div> | |
| </section> | |
| `; | |
| } | |
| }); | |
| } | |
| 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-8 sticky top-0 bg-slate-900/90 backdrop-blur z-20 py-4 px-2 -mx-2"> | |
| <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-12"> | |
| ${contentHtml} | |
| </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() { | |
| 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; | |
| // Validation Rule: Length >= 5 | |
| if (prompt.trim().length < 5) { | |
| errorMsg.classList.remove('hidden'); | |
| input.classList.add('border-red-500'); | |
| return; | |
| } | |
| // Reset error state | |
| errorMsg.classList.add('hidden'); | |
| input.classList.remove('border-red-500'); | |
| try { | |
| await submitPrompt(userId, roomCode, challengeId, prompt); | |
| // Visual feedback | |
| const container = input.parentElement; | |
| const btn = container.querySelector('button'); | |
| const originalText = btn.textContent; | |
| btn.textContent = "✓ 已提交"; | |
| btn.classList.add("bg-green-600", "from-green-600", "to-green-600"); | |
| setTimeout(() => { | |
| btn.textContent = originalText; | |
| btn.classList.remove("bg-green-600", "from-green-600", "to-green-600"); | |
| }, 2000); | |
| } catch (error) { | |
| console.error(error); | |
| alert("提交失敗: " + error.message); | |
| } | |
| }; | |
| } | |
| function participantDataCheck(roomCode, userId) { | |
| if (!roomCode || !userId) { | |
| alert("連線資訊遺失,請重新登入"); | |
| window.location.reload(); | |
| return false; | |
| } | |
| return true; | |
| } | |
| // Peer Learning Modal Logic | |
| function renderPeerModal() { | |
| // Dropdown options based on cachedChallenges | |
| 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 = () => { | |
| // Remove existing modal if any to ensure fresh render (especially for dropdown) | |
| 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 => ` | |
| <div class="bg-gray-700/30 p-4 rounded-xl border border-gray-600"> | |
| <div class="flex items-center space-x-2 mb-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 ml-auto">${new Date(p.timestamp.seconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span> | |
| </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(''); | |
| }; | |