Spaces:
Running
Running
File size: 17,219 Bytes
eabc26b 5d86f22 eabc26b 5d86f22 901982e 5d86f22 901982e e25bcd3 eabc26b 901982e 5d86f22 eb6f7c1 eabc26b eb6f7c1 5d86f22 eabc26b 5d86f22 eabc26b 5d86f22 eabc26b 5d86f22 eabc26b 5d86f22 eabc26b 5d86f22 eabc26b 901982e 5d86f22 eabc26b 8c465da 901982e 5d86f22 901982e eabc26b 901982e 5d86f22 901982e eabc26b 5d86f22 901982e 5d86f22 901982e 5d86f22 901982e eabc26b 901982e 5d86f22 901982e eabc26b 5d86f22 eabc26b 901982e eabc26b 5d86f22 901982e 5d86f22 901982e eabc26b 5d86f22 901982e 5d86f22 901982e 5d86f22 901982e 5d86f22 901982e 5d86f22 901982e 5d86f22 901982e 5d86f22 901982e 5d86f22 901982e 5d86f22 901982e eabc26b 901982e 5d86f22 901982e 5d86f22 901982e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 | import { submitPrompt, getChallenges, startChallenge, getUserProgress } from "../services/classroom.js";
import { getPeerPrompts } 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">
<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);
}
};
}
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 => `
<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('');
};
|