Lashtw commited on
Commit
66269b4
·
verified ·
1 Parent(s): 901982e

Upload 8 files

Browse files
src/main.js CHANGED
@@ -1,22 +1,36 @@
1
  import { renderLandingView, setupLandingEvents } from './views/LandingView.js';
2
  import { renderInstructorView, setupInstructorEvents } from './views/InstructorView.js';
3
  import { renderStudentView, setupStudentEvents } from './views/StudentView.js';
 
4
 
5
  const app = document.querySelector('#app');
6
 
7
  function navigateTo(view) {
 
8
  switch (view) {
9
  case 'landing':
10
  app.innerHTML = renderLandingView();
11
  setupLandingEvents(navigateTo);
12
  break;
13
  case 'instructor':
14
- app.innerHTML = renderInstructorView();
15
- setupInstructorEvents();
 
 
 
 
16
  break;
17
  case 'student':
18
- app.innerHTML = renderStudentView();
19
- setupStudentEvents();
 
 
 
 
 
 
 
 
20
  break;
21
  default:
22
  app.innerHTML = renderLandingView();
@@ -24,5 +38,27 @@ function navigateTo(view) {
24
  }
25
  }
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  // Initial Load
28
- navigateTo('landing');
 
1
  import { renderLandingView, setupLandingEvents } from './views/LandingView.js';
2
  import { renderInstructorView, setupInstructorEvents } from './views/InstructorView.js';
3
  import { renderStudentView, setupStudentEvents } from './views/StudentView.js';
4
+ import { renderAdminView, setupAdminEvents } from './views/AdminView.js';
5
 
6
  const app = document.querySelector('#app');
7
 
8
  function navigateTo(view) {
9
+ // Update hash maybe? For now simple switch
10
  switch (view) {
11
  case 'landing':
12
  app.innerHTML = renderLandingView();
13
  setupLandingEvents(navigateTo);
14
  break;
15
  case 'instructor':
16
+ app.innerHTML = '載入中...';
17
+ // Async render because Instructor view fetches challenges for column headers
18
+ renderInstructorView().then(html => {
19
+ app.innerHTML = html;
20
+ setupInstructorEvents();
21
+ });
22
  break;
23
  case 'student':
24
+ app.innerHTML = '載入中...';
25
+ // Async render because Student view fetches challenges
26
+ renderStudentView().then(html => {
27
+ app.innerHTML = html;
28
+ setupStudentEvents();
29
+ });
30
+ break;
31
+ case 'admin':
32
+ app.innerHTML = renderAdminView();
33
+ setupAdminEvents();
34
  break;
35
  default:
36
  app.innerHTML = renderLandingView();
 
38
  }
39
  }
40
 
41
+ // Route Handler
42
+ function handleRoute() {
43
+ const hash = window.location.hash.slice(1);
44
+ if (hash === 'admin') {
45
+ navigateTo('admin');
46
+ return;
47
+ }
48
+
49
+ // Auto-login check
50
+ const roomCode = localStorage.getItem('vibecoding_room_code');
51
+ const userId = localStorage.getItem('vibecoding_user_id'); // Changed key to match new logic
52
+
53
+ if (roomCode && userId && !hash) {
54
+ navigateTo('student');
55
+ } else {
56
+ navigateTo('landing');
57
+ }
58
+ }
59
+
60
+ // Listen to hash changes
61
+ window.addEventListener('hashchange', handleRoute);
62
+
63
  // Initial Load
64
+ handleRoute();
src/services/classroom.js CHANGED
@@ -9,11 +9,17 @@ import {
9
  serverTimestamp,
10
  query,
11
  where,
12
- getDocs
 
 
 
13
  } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js";
14
 
15
  // Collection references
16
  const ROOMS_COLLECTION = "classrooms";
 
 
 
17
 
18
  /**
19
  * Creates a new classroom room
@@ -32,12 +38,13 @@ export async function createRoom() {
32
  }
33
 
34
  /**
35
- * Joins an existing room
36
  * @param {string} roomCode
37
  * @param {string} nickname
38
- * @returns {Promise<string>} The student ID
39
  */
40
  export async function joinRoom(roomCode, nickname) {
 
41
  const roomRef = doc(db, ROOMS_COLLECTION, roomCode);
42
  const roomSnap = await getDoc(roomRef);
43
 
@@ -45,83 +52,200 @@ export async function joinRoom(roomCode, nickname) {
45
  throw new Error("教室代碼不存在");
46
  }
47
 
48
- // Create student document within the room
49
- const studentsRef = collection(roomRef, "students");
50
- const studentDoc = await addDoc(studentsRef, {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  nickname,
 
 
52
  joinedAt: serverTimestamp(),
53
- progress: {}
54
  });
55
 
56
- return studentDoc.id;
57
  }
58
 
59
  /**
60
  * Submits a prompt for a specific level
 
61
  * @param {string} roomCode
62
- * @param {string} studentId
63
- * @param {string} levelId
64
  * @param {string} prompt
65
  */
66
- export async function submitPrompt(roomCode, studentId, levelId, prompt) {
67
  const text = prompt.trim();
68
  if (!text) return;
69
 
70
- const studentRef = doc(db, ROOMS_COLLECTION, roomCode, "students", studentId);
71
-
72
- // Update using merge to preserve other progress
73
- await setDoc(studentRef, {
74
- progress: {
75
- [levelId]: {
76
- status: "completed",
77
- prompt: text,
78
- timestamp: serverTimestamp()
79
- }
80
- },
81
- lastActive: serverTimestamp()
82
- }, { merge: true });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  }
84
 
85
  /**
86
- * Subscribes to room updates (for Instructor)
87
  * @param {string} roomCode
88
- * @param {Function} callback
89
  * @returns {Function} Unsubscribe function
90
  */
91
  export function subscribeToRoom(roomCode, callback) {
92
- const studentsRef = collection(db, ROOMS_COLLECTION, roomCode, "students");
 
 
 
 
 
 
 
 
 
93
 
94
- return onSnapshot(studentsRef, (snapshot) => {
95
- const students = [];
96
- snapshot.forEach((doc) => {
97
- students.push({ id: doc.id, ...doc.data() });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  });
99
- callback(students);
100
  });
 
 
 
 
 
101
  }
102
 
103
  /**
104
- * Fetches prompts for a specific level from all students in the room
105
- * @param {string} roomCode
106
- * @param {string} levelId
107
- * @returns {Promise<Array>} Array of { nickname, prompt }
108
  */
109
- export async function getPeerPrompts(roomCode, levelId) {
110
- const studentsRef = collection(db, ROOMS_COLLECTION, roomCode, "students");
111
- const snapshot = await getDocs(studentsRef);
112
-
113
- const prompts = [];
114
- snapshot.forEach(doc => {
 
 
 
 
 
 
 
 
 
 
 
 
115
  const data = doc.data();
116
- const levelData = data.progress?.[levelId];
117
- if (levelData && levelData.status === 'completed' && levelData.prompt) {
118
- prompts.push({
119
- nickname: data.nickname,
120
- prompt: levelData.prompt,
121
- timestamp: levelData.timestamp
 
 
 
122
  });
123
  }
124
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
- return prompts;
 
127
  }
 
9
  serverTimestamp,
10
  query,
11
  where,
12
+ getDocs,
13
+ orderBy,
14
+ deleteDoc,
15
+ updateDoc
16
  } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js";
17
 
18
  // Collection references
19
  const ROOMS_COLLECTION = "classrooms";
20
+ const USERS_COLLECTION = "users";
21
+ const PROGRESS_COLLECTION = "progress";
22
+ const CHALLENGES_COLLECTION = "challenges";
23
 
24
  /**
25
  * Creates a new classroom room
 
38
  }
39
 
40
  /**
41
+ * Joins a room with Dual-Role Auth / Session Persistence logic
42
  * @param {string} roomCode
43
  * @param {string} nickname
44
+ * @returns {Promise<string>} The student ID (userId)
45
  */
46
  export async function joinRoom(roomCode, nickname) {
47
+ // 1. Verify Room Exists
48
  const roomRef = doc(db, ROOMS_COLLECTION, roomCode);
49
  const roomSnap = await getDoc(roomRef);
50
 
 
52
  throw new Error("教室代碼不存在");
53
  }
54
 
55
+ // 2. Check if user already exists in this room (Cross-device sync)
56
+ const usersRef = collection(db, USERS_COLLECTION);
57
+ const q = query(
58
+ usersRef,
59
+ where("current_room", "==", roomCode),
60
+ where("nickname", "==", nickname)
61
+ );
62
+
63
+ const querySnapshot = await getDocs(q);
64
+
65
+ if (!querySnapshot.empty) {
66
+ // User exists, return existing ID
67
+ const userDoc = querySnapshot.docs[0];
68
+ // Update last active
69
+ await updateDoc(userDoc.ref, { last_active: serverTimestamp() });
70
+ return userDoc.id;
71
+ }
72
+
73
+ // 3. Create new user if not exists
74
+ const newUserRef = await addDoc(usersRef, {
75
  nickname,
76
+ current_room: roomCode,
77
+ role: 'student',
78
  joinedAt: serverTimestamp(),
79
+ last_active: serverTimestamp()
80
  });
81
 
82
+ return newUserRef.id;
83
  }
84
 
85
  /**
86
  * Submits a prompt for a specific level
87
+ * @param {string} userId
88
  * @param {string} roomCode
89
+ * @param {string} challengeId
 
90
  * @param {string} prompt
91
  */
92
+ export async function submitPrompt(userId, roomCode, challengeId, prompt) {
93
  const text = prompt.trim();
94
  if (!text) return;
95
 
96
+ // Check if submission already exists to update it, or add new
97
+ // For simplicity, we can just use a composite ID or query first.
98
+ // Let's use addDoc for history, or setDoc with custom ID for latest state.
99
+ // Requirement says "progress/{docId}", let's query first to see if we should update.
100
+
101
+ const progressRef = collection(db, PROGRESS_COLLECTION);
102
+ const q = query(
103
+ progressRef,
104
+ where("userId", "==", userId),
105
+ where("challengeId", "==", challengeId)
106
+ );
107
+ const snapshot = await getDocs(q);
108
+
109
+ if (!snapshot.empty) {
110
+ // Update existing
111
+ const docRef = snapshot.docs[0].ref;
112
+ await updateDoc(docRef, {
113
+ status: "completed",
114
+ submission_prompt: text,
115
+ timestamp: serverTimestamp()
116
+ });
117
+ } else {
118
+ // Create new
119
+ await addDoc(progressRef, {
120
+ userId,
121
+ roomCode, // Added for easier querying by instructor
122
+ challengeId,
123
+ status: "completed",
124
+ submission_prompt: text,
125
+ timestamp: serverTimestamp()
126
+ });
127
+ }
128
+
129
+ // Update user last active
130
+ const userRef = doc(db, USERS_COLLECTION, userId);
131
+ await updateDoc(userRef, { last_active: serverTimestamp() });
132
  }
133
 
134
  /**
135
+ * Subscribes to room users and their progress
136
  * @param {string} roomCode
137
+ * @param {Function} callback (studentsWithProgress) => void
138
  * @returns {Function} Unsubscribe function
139
  */
140
  export function subscribeToRoom(roomCode, callback) {
141
+ // Listen to users in the room
142
+ const usersQuery = query(collection(db, USERS_COLLECTION), where("current_room", "==", roomCode));
143
+
144
+ // We also need progress. Real-time listening to TWO collections and joining them
145
+ // is complex in NoSQL.
146
+ // Strategy: Listen to Users. When Users change, fetch all progress for this room (or listen to it).
147
+ // Simpler efficient approach for dashboard:
148
+ // Listen to Progress independent of users? No, we need user list.
149
+
150
+ // Let's listen to Users, and inside, listen to Progress for this room.
151
 
152
+ let unsubscribeProgress = () => { };
153
+
154
+ const unsubscribeUsers = onSnapshot(usersQuery, (userSnap) => {
155
+ const users = [];
156
+ userSnap.forEach((doc) => {
157
+ users.push({ id: doc.id, ...doc.data() });
158
+ });
159
+
160
+ // Now listen to progress for this room
161
+ const progressQuery = query(collection(db, PROGRESS_COLLECTION), where("roomCode", "==", roomCode));
162
+
163
+ unsubscribeProgress(); // Detach previous listener if any
164
+
165
+ unsubscribeProgress = onSnapshot(progressQuery, (progressSnap) => {
166
+ const progressMap = {}; // { userId: { challengeId: { status, prompt } } }
167
+
168
+ progressSnap.forEach(doc => {
169
+ const data = doc.data();
170
+ if (!progressMap[data.userId]) progressMap[data.userId] = {};
171
+ progressMap[data.userId][data.challengeId] = {
172
+ status: data.status,
173
+ prompt: data.submission_prompt,
174
+ timestamp: data.timestamp
175
+ };
176
+ });
177
+
178
+ // Merge back to users
179
+ const combinedData = users.map(user => ({
180
+ ...user,
181
+ progress: progressMap[user.id] || {}
182
+ }));
183
+
184
+ callback(combinedData);
185
  });
 
186
  });
187
+
188
+ return () => {
189
+ unsubscribeUsers();
190
+ unsubscribeProgress();
191
+ };
192
  }
193
 
194
  /**
195
+ * Fetches prompts for Peer Learning
 
 
 
196
  */
197
+ export async function getPeerPrompts(roomCode, challengeId) {
198
+ const progressRef = collection(db, PROGRESS_COLLECTION);
199
+ // We need nickname too. progress collection only has userId.
200
+ // We might need to fetch user info or store nickname in progress (denormalization).
201
+ // Let's store nickname in progress for easier read? Or fetch users.
202
+ // For now, let's fetch matching progress, then unique userIds, then fetch those users.
203
+
204
+ const q = query(
205
+ progressRef,
206
+ where("roomCode", "==", roomCode),
207
+ where("challengeId", "==", challengeId),
208
+ where("status", "==", "completed")
209
+ );
210
+
211
+ const snapshot = await getDocs(q);
212
+ const entries = [];
213
+
214
+ for (const doc of snapshot.docs) {
215
  const data = doc.data();
216
+ // Fetch nickname (not efficient N+1, but okay for prototype with small rooms)
217
+ // Optimization: Cache users or store nickname in progress.
218
+ // Let's assume we can fetch user.
219
+ const userSnap = await getDoc(doc(db, USERS_COLLECTION, data.userId));
220
+ if (userSnap.exists()) {
221
+ entries.push({
222
+ nickname: userSnap.data().nickname,
223
+ prompt: data.submission_prompt,
224
+ timestamp: data.timestamp
225
  });
226
  }
227
+ }
228
+
229
+ return entries;
230
+ }
231
+
232
+ // --- Admin / Challenge Services ---
233
+
234
+ export async function getChallenges() {
235
+ const q = query(collection(db, CHALLENGES_COLLECTION), orderBy("order", "asc"));
236
+ const snapshot = await getDocs(q);
237
+ return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
238
+ }
239
+
240
+ export async function createChallenge(data) {
241
+ // data: { level, title, description, link, order }
242
+ await addDoc(collection(db, CHALLENGES_COLLECTION), data);
243
+ }
244
+
245
+ export async function updateChallenge(id, data) {
246
+ await updateDoc(doc(db, CHALLENGES_COLLECTION, id), data);
247
+ }
248
 
249
+ export async function deleteChallenge(id) {
250
+ await deleteDoc(doc(db, CHALLENGES_COLLECTION, id));
251
  }
src/views/AdminView.js ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getChallenges, createChallenge, updateChallenge, deleteChallenge } from "../services/classroom.js";
2
+
3
+ export function renderAdminView() {
4
+ return `
5
+ <div class="min-h-screen p-6 pb-20">
6
+ <header class="flex justify-between items-center mb-10 bg-gray-800 bg-opacity-50 p-4 rounded-xl border border-gray-700 backdrop-blur-sm">
7
+ <h1 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-red-400 to-orange-600">
8
+ 後台管理系統 Admin Panel
9
+ </h1>
10
+ <button id="add-challenge-btn" class="bg-green-600 hover:bg-green-500 text-white font-bold py-2 px-6 rounded-lg transition-all shadow-lg">
11
+ + 新增題目
12
+ </button>
13
+ </header>
14
+
15
+ <div id="challenges-list" class="space-y-4">
16
+ <!-- Questions loaded here -->
17
+ <div class="text-center text-gray-500 py-10">載入中...</div>
18
+ </div>
19
+
20
+ <!-- Edit/Add Modal -->
21
+ <div id="challenge-modal" class="fixed inset-0 bg-black bg-opacity-80 backdrop-blur-sm hidden flex items-center justify-center z-50 p-4">
22
+ <div class="bg-gray-800 rounded-xl w-full max-w-2xl border border-gray-700 shadow-2xl overflow-y-auto max-h-[90vh]">
23
+ <div class="p-6 border-b border-gray-700">
24
+ <h3 id="modal-title" class="text-xl font-bold text-white">編輯題目</h3>
25
+ </div>
26
+ <div class="p-6 space-y-4">
27
+ <input type="hidden" id="edit-id">
28
+ <div>
29
+ <label class="block text-gray-400 mb-1">標題 (Title)</label>
30
+ <input type="text" id="edit-title" class="w-full bg-gray-900 border border-gray-600 rounded p-2 text-white">
31
+ </div>
32
+ <div>
33
+ <label class="block text-gray-400 mb-1">難度 (Level)</label>
34
+ <select id="edit-level" class="w-full bg-gray-900 border border-gray-600 rounded p-2 text-white">
35
+ <option value="beginner">初級 (Beginner)</option>
36
+ <option value="intermediate">中級 (Intermediate)</option>
37
+ <option value="advanced">高級 (Advanced)</option>
38
+ </select>
39
+ </div>
40
+ <div>
41
+ <label class="block text-gray-400 mb-1">描述 (Description)</label>
42
+ <textarea id="edit-desc" rows="3" class="w-full bg-gray-900 border border-gray-600 rounded p-2 text-white"></textarea>
43
+ </div>
44
+ <div>
45
+ <label class="block text-gray-400 mb-1">連結 (GeminiCanvas Link/Code)</label>
46
+ <input type="text" id="edit-link" class="w-full bg-gray-900 border border-gray-600 rounded p-2 text-white">
47
+ </div>
48
+ <div>
49
+ <label class="block text-gray-400 mb-1">排序 (Order)</label>
50
+ <input type="number" id="edit-order" class="w-full bg-gray-900 border border-gray-600 rounded p-2 text-white" value="1">
51
+ </div>
52
+ </div>
53
+ <div class="p-6 border-t border-gray-700 flex justify-end space-x-3">
54
+ <button onclick="closeChallengeModal()" class="px-4 py-2 text-gray-400 hover:text-white">取消</button>
55
+ <button id="save-challenge-btn" class="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2 rounded">儲存</button>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ </div>
60
+ `;
61
+ }
62
+
63
+ export function setupAdminEvents() {
64
+ loadChallenges();
65
+
66
+ document.getElementById('add-challenge-btn').addEventListener('click', () => {
67
+ openModal();
68
+ });
69
+
70
+ document.getElementById('save-challenge-btn').addEventListener('click', async () => {
71
+ const id = document.getElementById('edit-id').value;
72
+ const data = {
73
+ title: document.getElementById('edit-title').value,
74
+ level: document.getElementById('edit-level').value,
75
+ description: document.getElementById('edit-desc').value,
76
+ link: document.getElementById('edit-link').value,
77
+ order: parseInt(document.getElementById('edit-order').value) || 0
78
+ };
79
+
80
+ if (id) {
81
+ await updateChallenge(id, data);
82
+ } else {
83
+ await createChallenge(data);
84
+ }
85
+
86
+ closeChallengeModal();
87
+ loadChallenges();
88
+ });
89
+ }
90
+
91
+ async function loadChallenges() {
92
+ const list = document.getElementById('challenges-list');
93
+ const challenges = await getChallenges();
94
+
95
+ list.innerHTML = challenges.map(c => `
96
+ <div class="bg-gray-800 p-4 rounded-lg border border-gray-700 flex justify-between items-center group hover:border-gray-500 transition-colors">
97
+ <div>
98
+ <span class="inline-block px-2 py-1 text-xs rounded bg-gray-700 text-gray-300 mr-2">${c.level}</span>
99
+ <span class="font-bold text-white text-lg">${c.title}</span>
100
+ <p class="text-gray-400 text-sm mt-1 truncate max-w-md">${c.description}</p>
101
+ </div>
102
+ <div class="flex space-x-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity">
103
+ <button onclick="window.editChallenge('${c.id}')" class="bg-cyan-600/20 text-cyan-400 px-3 py-1 rounded hover:bg-cyan-600 hover:text-white transition-colors">編輯</button>
104
+ <button onclick="window.deleteChallenge('${c.id}')" class="bg-red-600/20 text-red-400 px-3 py-1 rounded hover:bg-red-600 hover:text-white transition-colors">刪除</button>
105
+ </div>
106
+ </div>
107
+ `).join('');
108
+
109
+ // Expose helpers globally for onclick
110
+ window.editChallenge = (id) => {
111
+ const c = challenges.find(x => x.id === id);
112
+ if (c) openModal(c);
113
+ };
114
+ window.deleteChallenge = async (id) => {
115
+ if (confirm('確定刪除?')) {
116
+ await deleteChallenge(id);
117
+ loadChallenges();
118
+ }
119
+ };
120
+ }
121
+
122
+ function openModal(challenge = null) {
123
+ const modal = document.getElementById('challenge-modal');
124
+ const title = document.getElementById('modal-title');
125
+
126
+ // Reset or Fill
127
+ document.getElementById('edit-id').value = challenge ? challenge.id : '';
128
+ document.getElementById('edit-title').value = challenge ? challenge.title : '';
129
+ document.getElementById('edit-level').value = challenge ? challenge.level : 'beginner';
130
+ document.getElementById('edit-desc').value = challenge ? challenge.description : '';
131
+ document.getElementById('edit-link').value = challenge ? challenge.link : '';
132
+ document.getElementById('edit-order').value = challenge ? challenge.order : '1';
133
+
134
+ title.textContent = challenge ? '編輯題目' : '新增題目';
135
+ modal.classList.remove('hidden');
136
+ }
137
+
138
+ window.closeChallengeModal = () => {
139
+ document.getElementById('challenge-modal').classList.add('hidden');
140
+ };
src/views/InstructorView.js CHANGED
@@ -1,10 +1,23 @@
1
- import { createRoom, subscribeToRoom } from "../services/classroom.js";
 
 
 
 
 
 
2
 
3
- export function renderInstructorView() {
4
  return `
5
- <div class="min-h-screen p-6">
 
 
 
 
 
 
 
 
6
  <!-- Header -->
7
- <header class="flex justify-between items-center mb-10 bg-gray-800 bg-opacity-50 p-4 rounded-xl border border-gray-700 backdrop-blur-sm">
8
  <h1 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-600">
9
  講師儀表板 Instructor Dashboard
10
  </h1>
@@ -21,7 +34,7 @@ export function renderInstructorView() {
21
 
22
  <!-- Student List -->
23
  <div id="dashboard-content" class="hidden">
24
- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" id="students-grid">
25
  <!-- Student Cards will go here -->
26
  <div class="text-center text-gray-500 col-span-full py-20">
27
  等待學員加入...
@@ -33,6 +46,25 @@ export function renderInstructorView() {
33
  }
34
 
35
  export function setupInstructorEvents() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  const createBtn = document.getElementById('create-room-btn');
37
  const roomInfo = document.getElementById('room-info');
38
  const createContainer = document.getElementById('create-room-container');
@@ -72,43 +104,47 @@ function renderStudentCards(students, container) {
72
  return;
73
  }
74
 
 
 
 
75
  container.innerHTML = students.map(student => {
76
- const progress = student.progress || {};
77
- // Define levels we care about
78
- const levels = [
79
- { id: 'beginner', label: '初級', color: 'cyan' },
80
- { id: 'intermediate', label: '中級', color: 'blue' },
81
- { id: 'advanced', label: '高級', color: 'purple' }
82
- ];
83
-
84
- let badgesHtml = levels.map(level => {
85
- const isCompleted = progress[level.id]?.status === 'completed';
86
- const statusClass = isCompleted
87
- ? `bg-${level.color}-500/20 text-${level.color}-300 border-${level.color}-500`
88
- : 'bg-gray-700/50 text-gray-500 border-gray-700';
 
89
 
90
  return `
91
- <div class="flex items-center justify-between p-2 rounded border ${statusClass} transition-all">
92
- <span class="text-xs font-bold">${level.label}</span>
93
- <span class="text-xs">${isCompleted ? '✓' : '...'}</span>
94
- </div>
95
  `;
96
  }).join('');
97
 
98
  return `
99
- <div class="bg-gray-800 bg-opacity-40 backdrop-blur rounded-xl border border-gray-700 p-4 hover:border-gray-500 transition-all">
100
  <div class="flex items-center justify-between mb-4">
101
  <div class="flex items-center space-x-3">
102
- <div class="w-10 h-10 rounded-full bg-gradient-to-br from-gray-700 to-gray-600 flex items-center justify-center text-lg font-bold text-white">
103
  ${student.nickname[0]}
104
  </div>
105
  <div>
106
  <h3 class="font-bold text-white">${student.nickname}</h3>
107
- <p class="text-xs text-gray-400">Online</p>
108
  </div>
109
  </div>
110
  </div>
111
- <div class="space-y-2">
 
112
  ${badgesHtml}
113
  </div>
114
  </div>
 
1
+ import { createRoom, subscribeToRoom, getChallenges } from "../services/classroom.js";
2
+
3
+ let cachedChallenges = [];
4
+
5
+ export async function renderInstructorView() {
6
+ // Pre-fetch challenges for table headers
7
+ cachedChallenges = await getChallenges();
8
 
 
9
  return `
10
+ <div id="auth-modal" class="fixed inset-0 bg-black bg-opacity-90 backdrop-blur z-50 flex items-center justify-center">
11
+ <div class="bg-gray-800 p-8 rounded-xl border border-gray-600 shadow-2xl max-w-sm w-full">
12
+ <h2 class="text-xl font-bold text-center mb-6 text-white">🔒 講師身分驗證</h2>
13
+ <input type="password" id="instructor-password" class="w-full bg-gray-900 border border-gray-700 rounded p-3 text-white text-center text-lg tracking-widest mb-4 focus:border-cyan-500 focus:outline-none" placeholder="輸入密碼">
14
+ <button id="auth-btn" class="w-full bg-purple-600 hover:bg-purple-500 text-white font-bold py-3 rounded-lg transition-colors">確認進入</button>
15
+ </div>
16
+ </div>
17
+
18
+ <div class="min-h-screen p-6 pb-20">
19
  <!-- Header -->
20
+ <header class="flex flex-col md:flex-row justify-between items-center mb-10 bg-gray-800 bg-opacity-50 p-4 rounded-xl border border-gray-700 backdrop-blur-sm space-y-4 md:space-y-0">
21
  <h1 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-600">
22
  講師儀表板 Instructor Dashboard
23
  </h1>
 
34
 
35
  <!-- Student List -->
36
  <div id="dashboard-content" class="hidden">
37
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6" id="students-grid">
38
  <!-- Student Cards will go here -->
39
  <div class="text-center text-gray-500 col-span-full py-20">
40
  等待學員加入...
 
46
  }
47
 
48
  export function setupInstructorEvents() {
49
+ // Auth Logic
50
+ const authBtn = document.getElementById('auth-btn');
51
+ const pwdInput = document.getElementById('instructor-password');
52
+ const authModal = document.getElementById('auth-modal');
53
+
54
+ authBtn.addEventListener('click', () => checkPassword());
55
+ pwdInput.addEventListener('keypress', (e) => {
56
+ if (e.key === 'Enter') checkPassword();
57
+ });
58
+
59
+ function checkPassword() {
60
+ if (pwdInput.value === '88300') {
61
+ authModal.classList.add('hidden');
62
+ } else {
63
+ alert('密碼錯誤');
64
+ pwdInput.value = '';
65
+ }
66
+ }
67
+
68
  const createBtn = document.getElementById('create-room-btn');
69
  const roomInfo = document.getElementById('room-info');
70
  const createContainer = document.getElementById('create-room-container');
 
104
  return;
105
  }
106
 
107
+ // Sort students by join time (if available) or random
108
+ // students.sort((a,b) => a.joinedAt - b.joinedAt);
109
+
110
  container.innerHTML = students.map(student => {
111
+ const progress = student.progress || {}; // Map of challengeId -> {status, prompt ...}
112
+
113
+ // Progress Summary
114
+ let totalCompleted = 0;
115
+ let badgesHtml = cachedChallenges.map(c => {
116
+ const isCompleted = progress[c.id]?.status === 'completed';
117
+ if (isCompleted) totalCompleted++;
118
+
119
+ // Only show completed dots/badges or progress bar to save space?
120
+ // User requested "Card showing status". 15 items is a lot for small badges.
121
+ // Let's us simple dots color-coded by level.
122
+
123
+ const colors = { beginner: 'cyan', intermediate: 'blue', advanced: 'purple' };
124
+ const color = colors[c.level] || 'gray';
125
 
126
  return `
127
+ <div class="w-3 h-3 rounded-full ${isCompleted ? `bg-${color}-500 shadow-[0_0_5px_${color}]` : 'bg-gray-700'}
128
+ title="${c.title} (${c.level})"
129
+ ></div>
 
130
  `;
131
  }).join('');
132
 
133
  return `
134
+ <div class="bg-gray-800 bg-opacity-40 backdrop-blur rounded-xl border border-gray-700 p-4 hover:border-gray-500 transition-all flex flex-col">
135
  <div class="flex items-center justify-between mb-4">
136
  <div class="flex items-center space-x-3">
137
+ <div class="w-10 h-10 rounded-full bg-gradient-to-br from-gray-700 to-gray-600 flex items-center justify-center text-lg font-bold text-white uppercase">
138
  ${student.nickname[0]}
139
  </div>
140
  <div>
141
  <h3 class="font-bold text-white">${student.nickname}</h3>
142
+ <p class="text-xs text-gray-400">完成度: ${totalCompleted} / ${cachedChallenges.length}</p>
143
  </div>
144
  </div>
145
  </div>
146
+
147
+ <div class="flex flex-wrap gap-2 mt-auto">
148
  ${badgesHtml}
149
  </div>
150
  </div>
src/views/LandingView.js CHANGED
@@ -50,10 +50,14 @@ export function setupLandingEvents(navigateTo) {
50
  try {
51
  joinBtn.textContent = '加入中...';
52
  joinBtn.disabled = true;
 
53
  const studentId = await joinRoom(roomCode, nickname);
54
- localStorage.setItem('vibecoding_student_id', studentId);
 
 
55
  localStorage.setItem('vibecoding_room_code', roomCode);
56
  localStorage.setItem('vibecoding_nickname', nickname);
 
57
  navigateTo('student');
58
  } catch (error) {
59
  alert('加入失敗: ' + error.message);
 
50
  try {
51
  joinBtn.textContent = '加入中...';
52
  joinBtn.disabled = true;
53
+
54
  const studentId = await joinRoom(roomCode, nickname);
55
+
56
+ // Save Session
57
+ localStorage.setItem('vibecoding_user_id', studentId);
58
  localStorage.setItem('vibecoding_room_code', roomCode);
59
  localStorage.setItem('vibecoding_nickname', nickname);
60
+
61
  navigateTo('student');
62
  } catch (error) {
63
  alert('加入失敗: ' + error.message);