Lashtw commited on
Commit
eabc26b
·
verified ·
1 Parent(s): e25bcd3

Upload 8 files

Browse files
Files changed (2) hide show
  1. src/services/classroom.js +61 -0
  2. src/views/StudentView.js +159 -74
src/services/classroom.js CHANGED
@@ -131,6 +131,49 @@ export async function submitPrompt(userId, roomCode, challengeId, prompt) {
131
  await updateDoc(userRef, { last_active: serverTimestamp() });
132
  }
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  /**
135
  * Subscribes to room users and their progress
136
  * @param {string} roomCode
@@ -229,6 +272,24 @@ export async function getPeerPrompts(roomCode, challengeId) {
229
  return entries;
230
  }
231
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  // --- Admin / Challenge Services ---
233
 
234
  export async function getChallenges() {
 
131
  await updateDoc(userRef, { last_active: serverTimestamp() });
132
  }
133
 
134
+ /**
135
+ * Records that a user has started a challenge
136
+ * @param {string} userId
137
+ * @param {string} roomCode
138
+ * @param {string} challengeId
139
+ */
140
+ export async function startChallenge(userId, roomCode, challengeId) {
141
+ const progressRef = collection(db, PROGRESS_COLLECTION);
142
+ const q = query(
143
+ progressRef,
144
+ where("userId", "==", userId),
145
+ where("challengeId", "==", challengeId)
146
+ );
147
+ const snapshot = await getDocs(q);
148
+
149
+ if (!snapshot.empty) {
150
+ // Already exists (maybe started or completed)
151
+ const docData = snapshot.docs[0].data();
152
+ if (docData.status === 'completed') return; // Don't overwrite completed status
153
+
154
+ // If already started, maybe just update timestamp? Or do nothing.
155
+ // Let's update timestamp to reflect "last worked on"
156
+ await updateDoc(snapshot.docs[0].ref, {
157
+ status: 'started',
158
+ timestamp: serverTimestamp()
159
+ });
160
+ } else {
161
+ // Create new progress entry with 'started' status
162
+ await addDoc(progressRef, {
163
+ userId,
164
+ roomCode,
165
+ challengeId,
166
+ status: 'started',
167
+ startedAt: serverTimestamp(), // Keep original start time if we want to track duration
168
+ timestamp: serverTimestamp() // Last update time
169
+ });
170
+ }
171
+
172
+ // Update user last active
173
+ const userRef = doc(db, USERS_COLLECTION, userId);
174
+ await updateDoc(userRef, { last_active: serverTimestamp() });
175
+ }
176
+
177
  /**
178
  * Subscribes to room users and their progress
179
  * @param {string} roomCode
 
272
  return entries;
273
  }
274
 
275
+ /**
276
+ * Validates and gets progress for a specific user
277
+ * @param {string} userId
278
+ * @returns {Promise<Object>} Map of challengeId -> { status, prompt, timestamp }
279
+ */
280
+ export async function getUserProgress(userId) {
281
+ const progressRef = collection(db, PROGRESS_COLLECTION);
282
+ const q = query(progressRef, where("userId", "==", userId));
283
+ const snapshot = await getDocs(q);
284
+
285
+ const progressMap = {};
286
+ snapshot.forEach(doc => {
287
+ const data = doc.data();
288
+ progressMap[data.challengeId] = data;
289
+ });
290
+ return progressMap;
291
+ }
292
+
293
  // --- Admin / Challenge Services ---
294
 
295
  export async function getChallenges() {
src/views/StudentView.js CHANGED
@@ -1,12 +1,13 @@
1
- import { submitPrompt, getChallenges } from "../services/classroom.js";
2
- import { getPeerPrompts } from "../services/classroom.js"; // Import needed for modal
3
 
4
- // Cache challenges locally to avoid refetching heavily
5
  let cachedChallenges = [];
6
 
7
  export async function renderStudentView() {
8
  const nickname = localStorage.getItem('vibecoding_nickname') || 'Guest';
9
  const roomCode = localStorage.getItem('vibecoding_room_code') || 'Unknown';
 
10
 
11
  // Fetch challenges if empty
12
  if (cachedChallenges.length === 0) {
@@ -14,13 +15,20 @@ export async function renderStudentView() {
14
  cachedChallenges = await getChallenges();
15
  } catch (e) {
16
  console.error("Failed to fetch challenges", e);
17
- throw new Error("無法讀取題目列表,請檢查網路連線 (Error: " + e.message + ")");
 
 
 
 
 
 
 
 
 
 
18
  }
19
  }
20
 
21
- // Group by Level (optional, but good for UI organization if we wanted headers)
22
- // For now, let's just map them.
23
- // Or if we want to keep the headers:
24
  const levelGroups = {
25
  beginner: cachedChallenges.filter(c => c.level === 'beginner'),
26
  intermediate: cachedChallenges.filter(c => c.level === 'intermediate'),
@@ -33,70 +41,92 @@ export async function renderStudentView() {
33
  advanced: "高級 (Advanced)"
34
  };
35
 
36
- const renderCard = (c) => `
37
- <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">
38
- <div class="absolute top-0 left-0 w-1 h-full bg-gray-600 group-hover:bg-cyan-400 transition-colors"></div>
39
-
40
- <div class="p-6 pl-8 flex-1 flex flex-col">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  <div class="flex flex-col sm:flex-row justify-between items-start mb-4 gap-4">
42
  <div>
43
  <h2 class="text-xl font-bold text-white mb-1">${c.title}</h2>
44
  <p class="text-gray-400 text-sm whitespace-pre-line">${c.description}</p>
45
  </div>
46
- <a href="${c.link}" target="_blank"
47
- 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"
48
- title="前往題目">
49
- <span>Go to Task</span>
50
- <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
51
- <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" />
52
- </svg>
53
- </a>
54
  </div>
55
-
56
- <div class="mt-auto pt-4 border-t border-gray-700/50">
57
- <label class="block text-xs uppercase tracking-wider text-gray-500 mb-2">提交修復提示詞</label>
58
- <div class="flex flex-col space-y-2">
59
- <textarea id="input-${c.id}" rows="2"
60
- 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"
61
- placeholder="輸入你的 Prompt..."></textarea>
62
-
63
- <div id="error-${c.id}" class="text-red-500 text-xs hidden">提示詞太短囉,請多寫一點細節!</div>
64
 
65
- <button onclick="window.submitLevel('${c.id}')"
66
- 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">
67
- 提交
 
 
 
68
  </button>
69
  </div>
70
- </div>
71
- </div>
72
- </div>
73
- `;
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
- // Render logic
76
- let contentHtml = '';
77
- if (cachedChallenges.length === 0) {
78
- contentHtml = '<div class="text-center text-gray-500 py-10">目前沒有題目,請請講師至後台新增。</div>';
79
- } else {
80
- ['beginner', 'intermediate', 'advanced'].forEach(levelId => {
81
- const tasks = levelGroups[levelId] || [];
82
- if (tasks.length > 0) {
83
- contentHtml += `
84
- <section>
85
- <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">
86
- ${levelNames[levelId]}
87
- </h3>
88
- <div class="grid grid-cols-1 gap-6">
89
- ${tasks.map(renderCard).join('')}
90
  </div>
91
- </section>
92
- `;
93
- }
94
- });
 
95
  }
96
 
 
97
  return `
98
  <div class="min-h-screen p-4 pb-32 max-w-md mx-auto sm:max-w-4xl">
99
- <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">
100
  <div class="flex flex-col">
101
  <div class="flex items-center space-x-2">
102
  <div class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
@@ -109,8 +139,35 @@ export async function renderStudentView() {
109
  </div>
110
  </header>
111
 
112
- <div class="space-y-12">
113
- ${contentHtml}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  </div>
115
 
116
  <!-- Peer Learning FAB -->
@@ -125,6 +182,31 @@ export async function renderStudentView() {
125
  }
126
 
127
  export function setupStudentEvents() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  window.submitLevel = async (challengeId) => {
129
  const input = document.getElementById(`input-${challengeId}`);
130
  const errorMsg = document.getElementById(`error-${challengeId}`);
@@ -134,35 +216,38 @@ export function setupStudentEvents() {
134
 
135
  if (!participantDataCheck(roomCode, userId)) return;
136
 
137
- // Validation Rule: Length >= 5
138
  if (prompt.trim().length < 5) {
139
  errorMsg.classList.remove('hidden');
140
  input.classList.add('border-red-500');
141
  return;
142
  }
143
 
144
- // Reset error state
145
  errorMsg.classList.add('hidden');
146
  input.classList.remove('border-red-500');
147
 
 
 
 
 
 
 
 
148
  try {
149
  await submitPrompt(userId, roomCode, challengeId, prompt);
150
 
151
- // Visual feedback
152
- const container = input.parentElement;
153
- const btn = container.querySelector('button');
154
- const originalText = btn.textContent;
155
-
156
- btn.textContent = "✓ 已提交";
157
- btn.classList.add("bg-green-600", "from-green-600", "to-green-600");
158
 
159
- setTimeout(() => {
160
- btn.textContent = originalText;
161
- btn.classList.remove("bg-green-600", "from-green-600", "to-green-600");
162
- }, 2000);
 
163
 
164
  } catch (error) {
165
  console.error(error);
 
 
166
  alert("提交失敗: " + error.message);
167
  }
168
  };
@@ -179,7 +264,8 @@ function participantDataCheck(roomCode, userId) {
179
 
180
  // Peer Learning Modal Logic
181
  function renderPeerModal() {
182
- // Dropdown options based on cachedChallenges
 
183
  let optionsHtml = '<option value="" disabled selected>選擇題目...</option>';
184
  if (cachedChallenges.length > 0) {
185
  optionsHtml += cachedChallenges.map(c =>
@@ -210,7 +296,6 @@ function renderPeerModal() {
210
  }
211
 
212
  window.openPeerModal = () => {
213
- // Remove existing modal if any to ensure fresh render (especially for dropdown)
214
  const existing = document.getElementById('peer-modal');
215
  if (existing) existing.remove();
216
 
@@ -233,7 +318,7 @@ window.loadPeerPrompts = async (challengeId) => {
233
  const prompts = await getPeerPrompts(roomCode, challengeId);
234
 
235
  if (prompts.length === 0) {
236
- container.innerHTML = '<div class="text-center text-gray-500 py-10">尚無同學提交此關卡</div>';
237
  return;
238
  }
239
 
 
1
+ import { submitPrompt, getChallenges, startChallenge, getUserProgress } from "../services/classroom.js";
2
+ import { getPeerPrompts } from "../services/classroom.js";
3
 
4
+ // Cache challenges locally
5
  let cachedChallenges = [];
6
 
7
  export async function renderStudentView() {
8
  const nickname = localStorage.getItem('vibecoding_nickname') || 'Guest';
9
  const roomCode = localStorage.getItem('vibecoding_room_code') || 'Unknown';
10
+ const userId = localStorage.getItem('vibecoding_user_id');
11
 
12
  // Fetch challenges if empty
13
  if (cachedChallenges.length === 0) {
 
15
  cachedChallenges = await getChallenges();
16
  } catch (e) {
17
  console.error("Failed to fetch challenges", e);
18
+ throw new Error("無法讀取題目列表 (Error: " + e.message + ")");
19
+ }
20
+ }
21
+
22
+ // Fetch User Progress
23
+ let userProgress = {};
24
+ if (userId) {
25
+ try {
26
+ userProgress = await getUserProgress(userId);
27
+ } catch (e) {
28
+ console.error("Failed to fetch progress", e);
29
  }
30
  }
31
 
 
 
 
32
  const levelGroups = {
33
  beginner: cachedChallenges.filter(c => c.level === 'beginner'),
34
  intermediate: cachedChallenges.filter(c => c.level === 'intermediate'),
 
41
  advanced: "高級 (Advanced)"
42
  };
43
 
44
+ function renderTaskCard(c) {
45
+ const p = userProgress[c.id] || {};
46
+ const isCompleted = p.status === 'completed';
47
+ const isStarted = p.status === 'started';
48
+
49
+ // 1. Completed State: Collapsed with Trophy
50
+ if (isCompleted) {
51
+ return `
52
+ <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">
53
+ <div class="flex items-center space-x-3">
54
+ <div class="text-2xl">🏆</div>
55
+ <h3 class="font-bold text-gray-300 group-hover:text-white transition-colors">${c.title}</h3>
56
+ </div>
57
+ <div class="flex items-center space-x-3">
58
+ <span class="text-xs text-green-400 font-mono bg-green-900/30 px-2 py-1 rounded">已通關</span>
59
+ <button onclick="document.getElementById('detail-${c.id}').classList.toggle('hidden')" class="text-gray-500 hover:text-white">
60
+ <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>
61
+ </button>
62
+ </div>
63
+ </div>
64
+ <!-- Hidden detail for reference -->
65
+ <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">
66
+ <p class="mb-2">您的提示詞:</p>
67
+ <div class="font-mono bg-black p-2 rounded text-gray-300 border border-gray-800">${p.submission_prompt}</div>
68
+ </div>
69
+ `;
70
+ }
71
+
72
+ // 2. Started or Not Started
73
+ // If Started: Show "Prompt Input" area.
74
+ // If Not Started: Show "Start Task" button only.
75
+
76
+ return `
77
+ <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">
78
+ <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>
79
+
80
+ <div class="p-6 pl-8 flex-1 flex flex-col">
81
  <div class="flex flex-col sm:flex-row justify-between items-start mb-4 gap-4">
82
  <div>
83
  <h2 class="text-xl font-bold text-white mb-1">${c.title}</h2>
84
  <p class="text-gray-400 text-sm whitespace-pre-line">${c.description}</p>
85
  </div>
 
 
 
 
 
 
 
 
86
  </div>
 
 
 
 
 
 
 
 
 
87
 
88
+ ${!isStarted ? `
89
+ <!-- Not Started State -->
90
+ <div class="mt-4">
91
+ <button onclick="window.startLevel('${c.id}', '${c.link}')"
92
+ 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">
93
+ <span>🚀 開始任務 (Start Task)</span>
94
  </button>
95
  </div>
96
+ ` : `
97
+ <!-- Started State: Input Area -->
98
+ <div class="mt-4 pt-4 border-t border-gray-700/50">
99
+ <div class="flex justify-between items-center mb-2">
100
+ <label class="block text-xs uppercase tracking-wider text-cyan-400 animate-pulse">任務進行中</label>
101
+ <a href="${c.link}" target="_blank" class="text-xs text-gray-500 hover:text-white flex items-center space-x-1">
102
+ <span>再次開啟 GeminCanvas</span>
103
+ <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>
104
+ </a>
105
+ </div>
106
+
107
+ <div class="flex flex-col space-y-2">
108
+ <textarea id="input-${c.id}" rows="2"
109
+ 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"
110
+ placeholder="貼上您的修復提示詞..."></textarea>
111
+
112
+ <div id="error-${c.id}" class="text-red-500 text-xs hidden">提示詞太短囉,請多寫一點細節!</div>
113
 
114
+ <button onclick="window.submitLevel('${c.id}')"
115
+ 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">
116
+ 提交解答
117
+ </button>
 
 
 
 
 
 
 
 
 
 
 
118
  </div>
119
+ </div>
120
+ `}
121
+ </div>
122
+ </div>
123
+ `;
124
  }
125
 
126
+ // Accordion Layout
127
  return `
128
  <div class="min-h-screen p-4 pb-32 max-w-md mx-auto sm:max-w-4xl">
129
+ <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">
130
  <div class="flex flex-col">
131
  <div class="flex items-center space-x-2">
132
  <div class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
 
139
  </div>
140
  </header>
141
 
142
+ <div class="space-y-4">
143
+ ${['beginner', 'intermediate', 'advanced'].map(level => {
144
+ const tasks = levelGroups[level] || [];
145
+ const isOpen = level === 'beginner' ? 'open' : '';
146
+ // Count completed
147
+ const completedCount = tasks.filter(t => userProgress[t.id]?.status === 'completed').length;
148
+
149
+ return `
150
+ <details class="group border border-gray-700 rounded-xl bg-gray-800/30 overflow-hidden transition-all" ${isOpen}>
151
+ <summary class="flex items-center justify-between p-4 cursor-pointer bg-gray-800/80 hover:bg-gray-700 transition-colors select-none">
152
+ <div class="flex items-center space-x-3">
153
+ <h3 class="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-cyan-400">
154
+ ${levelNames[level]}
155
+ </h3>
156
+ ${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>' : ''}
157
+ </div>
158
+ <div class="flex items-center space-x-2">
159
+ <span class="text-xs text-gray-400 bg-gray-900 px-2 py-1 rounded-full">${completedCount} / ${tasks.length}</span>
160
+ <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">
161
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
162
+ </svg>
163
+ </div>
164
+ </summary>
165
+ <div class="p-4 pt-0 grid grid-cols-1 gap-4 mt-4">
166
+ ${tasks.length > 0 ? tasks.map(c => renderTaskCard(c)).join('') : '<div class="text-gray-500 text-sm italic">本區段尚無題目</div>'}
167
+ </div>
168
+ </details>
169
+ `;
170
+ }).join('')}
171
  </div>
172
 
173
  <!-- Peer Learning FAB -->
 
182
  }
183
 
184
  export function setupStudentEvents() {
185
+ // Start Level Logic
186
+ window.startLevel = async (challengeId, link) => {
187
+ // Open link
188
+ window.open(link, '_blank');
189
+
190
+ // Call service to update status
191
+ const roomCode = localStorage.getItem('vibecoding_room_code');
192
+ const userId = localStorage.getItem('vibecoding_user_id');
193
+
194
+ if (roomCode && userId) {
195
+ try {
196
+ await startChallenge(userId, roomCode, challengeId);
197
+ // Reload view to show Input State
198
+ // Ideally we should use state management, but checking URL hash or re-rendering works
199
+ const app = document.querySelector('#app');
200
+ app.innerHTML = await renderStudentView();
201
+ // Re-attach events (recursion safety check needed? No, navigateTo does this usually, but here we manually re-render)
202
+ // Or better: trigger a custom event or call navigateTo functionality?
203
+ // Simple re-render is fine for now.
204
+ } catch (e) {
205
+ console.error("Start challenge failed", e);
206
+ }
207
+ }
208
+ };
209
+
210
  window.submitLevel = async (challengeId) => {
211
  const input = document.getElementById(`input-${challengeId}`);
212
  const errorMsg = document.getElementById(`error-${challengeId}`);
 
216
 
217
  if (!participantDataCheck(roomCode, userId)) return;
218
 
 
219
  if (prompt.trim().length < 5) {
220
  errorMsg.classList.remove('hidden');
221
  input.classList.add('border-red-500');
222
  return;
223
  }
224
 
 
225
  errorMsg.classList.add('hidden');
226
  input.classList.remove('border-red-500');
227
 
228
+ // Show loading state on button
229
+ const container = input.parentElement;
230
+ const btn = container.querySelector('button');
231
+ const originalText = btn.textContent;
232
+ btn.textContent = "提交中...";
233
+ btn.disabled = true;
234
+
235
  try {
236
  await submitPrompt(userId, roomCode, challengeId, prompt);
237
 
238
+ btn.textContent = "✓ 已通關";
239
+ btn.classList.add("bg-green-600");
 
 
 
 
 
240
 
241
+ // Re-render to show Trophy state after short delay
242
+ setTimeout(async () => {
243
+ const app = document.querySelector('#app');
244
+ app.innerHTML = await renderStudentView();
245
+ }, 1000);
246
 
247
  } catch (error) {
248
  console.error(error);
249
+ btn.textContent = originalText;
250
+ btn.disabled = false;
251
  alert("提交失敗: " + error.message);
252
  }
253
  };
 
264
 
265
  // Peer Learning Modal Logic
266
  function renderPeerModal() {
267
+ // We need to re-fetch challenges for the dropdown?
268
+ // They are cached in 'cachedChallenges' module variable
269
  let optionsHtml = '<option value="" disabled selected>選擇題目...</option>';
270
  if (cachedChallenges.length > 0) {
271
  optionsHtml += cachedChallenges.map(c =>
 
296
  }
297
 
298
  window.openPeerModal = () => {
 
299
  const existing = document.getElementById('peer-modal');
300
  if (existing) existing.remove();
301
 
 
318
  const prompts = await getPeerPrompts(roomCode, challengeId);
319
 
320
  if (prompts.length === 0) {
321
+ container.innerHTML = '<div class="text-center text-gray-500 py-10">尚無同學提交此關卡或您無權限查看(需相同教室代碼)</div>';
322
  return;
323
  }
324