Spaces:
Running
Running
Upload 8 files
Browse files- src/main.js +41 -5
- src/services/classroom.js +174 -50
- src/views/AdminView.js +140 -0
- src/views/InstructorView.js +62 -26
- src/views/LandingView.js +5 -1
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 =
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
break;
|
| 17 |
case 'student':
|
| 18 |
-
app.innerHTML =
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
break;
|
| 21 |
default:
|
| 22 |
app.innerHTML = renderLandingView();
|
|
@@ -24,5 +38,27 @@ function navigateTo(view) {
|
|
| 24 |
}
|
| 25 |
}
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
// Initial Load
|
| 28 |
-
|
|
|
|
| 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
|
| 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 |
-
//
|
| 49 |
-
const
|
| 50 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
nickname,
|
|
|
|
|
|
|
| 52 |
joinedAt: serverTimestamp(),
|
| 53 |
-
|
| 54 |
});
|
| 55 |
|
| 56 |
-
return
|
| 57 |
}
|
| 58 |
|
| 59 |
/**
|
| 60 |
* Submits a prompt for a specific level
|
|
|
|
| 61 |
* @param {string} roomCode
|
| 62 |
-
* @param {string}
|
| 63 |
-
* @param {string} levelId
|
| 64 |
* @param {string} prompt
|
| 65 |
*/
|
| 66 |
-
export async function submitPrompt(
|
| 67 |
const text = prompt.trim();
|
| 68 |
if (!text) return;
|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
//
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
}
|
| 84 |
|
| 85 |
/**
|
| 86 |
-
* Subscribes to room
|
| 87 |
* @param {string} roomCode
|
| 88 |
-
* @param {Function} callback
|
| 89 |
* @returns {Function} Unsubscribe function
|
| 90 |
*/
|
| 91 |
export function subscribeToRoom(roomCode, callback) {
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
});
|
| 99 |
-
callback(students);
|
| 100 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
}
|
| 102 |
|
| 103 |
/**
|
| 104 |
-
* Fetches prompts for
|
| 105 |
-
* @param {string} roomCode
|
| 106 |
-
* @param {string} levelId
|
| 107 |
-
* @returns {Promise<Array>} Array of { nickname, prompt }
|
| 108 |
*/
|
| 109 |
-
export async function getPeerPrompts(roomCode,
|
| 110 |
-
const
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
const data = doc.data();
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
| 122 |
});
|
| 123 |
}
|
| 124 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
-
|
|
|
|
| 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="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
| 89 |
|
| 90 |
return `
|
| 91 |
-
<div class="
|
| 92 |
-
|
| 93 |
-
|
| 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">
|
| 108 |
</div>
|
| 109 |
</div>
|
| 110 |
</div>
|
| 111 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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);
|