Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions client/src/components/CourseView/AssignmentCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,17 @@ function AssignmentCard({
<label className="flex items-center gap-3 px-4 py-3 bg-[var(--surface-elevated)] border border-[var(--border)] rounded-2xl cursor-pointer hover:border-[var(--accent)]/40 transition-all duration-300">
<span className="text-lg">📎</span>
<span className="text-sm text-[var(--muted)] flex-1 truncate">
{submissionFile ? submissionFile.name : "Attach PDF or DOCX (optional)"}
{submissionFile
? submissionFile.name
: "Attach PDF or DOCX (optional)"}
</span>
<input
type="file"
accept=".pdf,.doc,.docx"
className="hidden"
onChange={(e) => onSubmit(assignment.id, e.target.files[0] || null, "file")}
onChange={(e) =>
onSubmit(assignment.id, e.target.files[0] || null, "file")
}
/>
</label>
{submissionFile && (
Expand Down
9 changes: 7 additions & 2 deletions client/src/components/CourseView/AssignmentModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,17 @@ function AssignmentModal({
{/* Optional document attachment */}
<div>
<label className="block text-[11px] font-bold text-[var(--text)] uppercase tracking-wider mb-2 ml-1 opacity-80">
Attach Document <span className="normal-case font-normal opacity-60">(PDF or DOCX, optional)</span>
Attach Document{" "}
<span className="normal-case font-normal opacity-60">
(PDF or DOCX, optional)
</span>
</label>
<label className="flex items-center gap-3 px-4 py-3 bg-[var(--surface-elevated)] border border-[var(--border)] rounded-2xl cursor-pointer hover:border-[var(--accent)]/40 transition-all duration-300">
<span className="text-xl">📎</span>
<span className="text-sm text-[var(--muted)] flex-1 truncate">
{form.attachmentFile ? form.attachmentFile.name : "Click to choose file…"}
{form.attachmentFile
? form.attachmentFile.name
: "Click to choose file…"}
</span>
<input
type="file"
Expand Down
111 changes: 67 additions & 44 deletions client/src/components/CourseView/QuizzesTab.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { useNavigate } from "react-router-dom";

function QuizzesTab({ quizzes, isTeacher, myQuizResults = {}, onDelete, onAddClick }) {
function QuizzesTab({
quizzes,
isTeacher,
myQuizResults = {},
onDelete,
onAddClick,
}) {
const navigate = useNavigate();

return (
Expand Down Expand Up @@ -121,54 +127,71 @@ function QuizzesTab({ quizzes, isTeacher, myQuizResults = {}, onDelete, onAddCli
</span>
)}
{/* Score pill — shown after student completes quiz */}
{!isTeacher && myQuizResults[q.id] && (() => {
const r = myQuizResults[q.id];
const pct = r.percentage ?? 0;
const colorCls = pct >= 70
? "bg-emerald-500/12 text-emerald-400 border-emerald-500/20"
: pct >= 40
? "bg-amber-500/12 text-amber-400 border-amber-500/20"
: "bg-red-500/12 text-red-400 border-red-500/20";
return (
<span className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] font-black border ${colorCls}`}>
🏆 {r.score}/{r.totalPoints} pts · {pct}%
</span>
);
})()}
{!isTeacher &&
myQuizResults[q.id] &&
(() => {
const r = myQuizResults[q.id];
const pct = r.percentage ?? 0;
const colorCls =
pct >= 70
? "bg-emerald-500/12 text-emerald-400 border-emerald-500/20"
: pct >= 40
? "bg-amber-500/12 text-amber-400 border-amber-500/20"
: "bg-red-500/12 text-red-400 border-red-500/20";
return (
<span
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] font-black border ${colorCls}`}
>
🏆 {r.score}/{r.totalPoints} pts · {pct}%
</span>
);
})()}
</div>

{/* Actions */}
<div className="flex items-center gap-2.5">
{!isTeacher && (() => {
const taken = !!myQuizResults[q.id];
return (
<button
onClick={() => !taken && navigate(`/quiz/${q.id}`)}
disabled={taken}
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-xs font-bold transition-all active:scale-95
${taken
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 cursor-not-allowed opacity-80"
: "sc-btn-glow cursor-pointer"
{!isTeacher &&
(() => {
const taken = !!myQuizResults[q.id];
return (
<button
onClick={() => !taken && navigate(`/quiz/${q.id}`)}
disabled={taken}
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-xs font-bold transition-all active:scale-95
${
taken
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 cursor-not-allowed opacity-80"
: "sc-btn-glow cursor-pointer"
}`}
>
{taken ? (
<>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z" />
</svg>
Quiz Taken
</>
) : (
<>
Take Quiz
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path d="M8.22 2.97a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06l2.97-2.97H3.75a.75.75 0 010-1.5h7.44L8.22 4.03a.75.75 0 010-1.06z" />
</svg>
</>
)}
</button>
);
})()}
>
{taken ? (
<>
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z" />
</svg>
Quiz Taken
</>
) : (
<>
Take Quiz
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M8.22 2.97a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06l2.97-2.97H3.75a.75.75 0 010-1.5h7.44L8.22 4.03a.75.75 0 010-1.06z" />
</svg>
</>
)}
</button>
);
})()}
{isTeacher && (
<>
<button
Expand Down
19 changes: 14 additions & 5 deletions client/src/pages/CourseView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@
loadLiveClasses();
loadStudents();
loadProgress();
}, [id]);

Check warning on line 173 in client/src/pages/CourseView.jsx

View workflow job for this annotation

GitHub Actions / Lint & Format Check

React Hook useEffect has missing dependencies: 'loadAssignments', 'loadCourse', 'loadLiveClasses', 'loadMaterials', 'loadProgress', 'loadQuizzes', and 'loadStudents'. Either include them or remove the dependency array

Check warning on line 173 in client/src/pages/CourseView.jsx

View workflow job for this annotation

GitHub Actions / Lint & Format Check

React Hook useEffect has missing dependencies: 'loadAssignments', 'loadCourse', 'loadLiveClasses', 'loadMaterials', 'loadProgress', 'loadQuizzes', and 'loadStudents'. Either include them or remove the dependency array

Check warning on line 173 in client/src/pages/CourseView.jsx

View workflow job for this annotation

GitHub Actions / Frontend — Lint & Format

React Hook useEffect has missing dependencies: 'loadAssignments', 'loadCourse', 'loadLiveClasses', 'loadMaterials', 'loadProgress', 'loadQuizzes', and 'loadStudents'. Either include them or remove the dependency array

// Keep ref in sync so async socket handlers can read latest expanded state
useEffect(() => {
Expand Down Expand Up @@ -269,7 +269,7 @@
socket.off("quiz:updated", onQuizUpdated);
socket.off("quiz:deleted", onQuizDeleted);
};
}, [id, user.id]);

Check warning on line 272 in client/src/pages/CourseView.jsx

View workflow job for this annotation

GitHub Actions / Lint & Format Check

React Hook useEffect has a missing dependency: 'isTeacher'. Either include it or remove the dependency array

Check warning on line 272 in client/src/pages/CourseView.jsx

View workflow job for this annotation

GitHub Actions / Lint & Format Check

React Hook useEffect has a missing dependency: 'isTeacher'. Either include it or remove the dependency array

Check warning on line 272 in client/src/pages/CourseView.jsx

View workflow job for this annotation

GitHub Actions / Frontend — Lint & Format

React Hook useEffect has a missing dependency: 'isTeacher'. Either include it or remove the dependency array

useEffect(() => {
if (isTeacher || assignments.length === 0) return;
Expand Down Expand Up @@ -382,17 +382,26 @@
const fd = new FormData();
fd.append("file", assForm.attachmentFile);
fd.append("teacherId", user.id);
const attRes = await apiFetch(`/api/assignments/${data.id}/attachments`, {
method: "POST",
body: fd,
});
const attRes = await apiFetch(
`/api/assignments/${data.id}/attachments`,
{
method: "POST",
body: fd,
},
);
if (attRes.ok) data = await attRes.json();
}
setAssignments((p) =>
p.some((a) => String(a.id) === String(data.id)) ? p : [data, ...p],
);
setModal(null);
setAssForm({ title: "", description: "", dueDate: "", maxScore: 100, attachmentFile: null });
setAssForm({
title: "",
description: "",
dueDate: "",
maxScore: 100,
attachmentFile: null,
});
}
setSaving(false);
};
Expand Down
94 changes: 80 additions & 14 deletions client/src/pages/LiveClassRoom.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
const [studentCamOn, setStudentCamOn] = useState(false);
const [streamActive, setStreamActive] = useState(false);
const [teacherHasScreen, setTeacherHasScreen] = useState(false);
const [audioBlocked, setAudioBlocked] = useState(false);

// ── student tiles (teacher's view of all connected students) ────────────────
// Map<viewerSocketId, { stream: MediaStream|null, userId, userName, camOn: bool }>
Expand All @@ -171,11 +172,13 @@
const studentMicStreamRef = useRef(null); // student: mic stream
const studentCamStreamRef = useRef(null); // student: camera stream
const pendingScreenRef = useRef(null); // student: pending screen stream before mount
const screenStreamIdRef = useRef(null); // student: expected screen stream.id from teacher

// ── video element refs ──────────────────────────────────────────────────────
// ── video / audio element refs ──────────────────────────────────────────────
const localCameraRef = useRef(null); // teacher: self camera preview
const localScreenRef = useRef(null); // teacher: self screen preview
const remoteCameraRef = useRef(null); // student: teacher's camera
const remoteCameraRef = useRef(null); // student: teacher's camera (video only)
const remoteAudioRef = useRef(null); // student: teacher's audio (dedicated element)
const remoteScreenRef = useRef(null); // student: teacher's screen
const studentSelfVideoRef = useRef(null); // student: self camera preview

Expand Down Expand Up @@ -355,12 +358,15 @@
const sender = pc.addTrack(st, stream);
screenSendersRef.current.set(vid, sender);
});
socket.emit("screen-share-started", { liveClassId: id });
socket.emit("screen-share-started", {
liveClassId: id,
screenStreamId: stream.id,
});
st.onended = stopScreenShare;
} catch {
/* user cancelled */
}
}, [id, socket]);

Check warning on line 369 in client/src/pages/LiveClassRoom.jsx

View workflow job for this annotation

GitHub Actions / Lint & Format Check

React Hook useCallback has a missing dependency: 'stopScreenShare'. Either include it or remove the dependency array

Check warning on line 369 in client/src/pages/LiveClassRoom.jsx

View workflow job for this annotation

GitHub Actions / Lint & Format Check

React Hook useCallback has a missing dependency: 'stopScreenShare'. Either include it or remove the dependency array

Check warning on line 369 in client/src/pages/LiveClassRoom.jsx

View workflow job for this annotation

GitHub Actions / Frontend — Lint & Format

React Hook useCallback has a missing dependency: 'stopScreenShare'. Either include it or remove the dependency array

const stopScreenShare = useCallback(() => {
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
Expand Down Expand Up @@ -715,26 +721,52 @@
const pc = new RTCPeerConnection(ICE_CONFIG);
peerConnRef.current = pc;

pc.ontrack = ({ track }) => {
pc.ontrack = ({ track, streams }) => {
if (track.kind === "audio") {
const s = remoteCameraRef.current?.srcObject ?? new MediaStream();
s.addTrack(track);
if (remoteCameraRef.current) remoteCameraRef.current.srcObject = s;
// Dedicated audio element — avoids autoplay blocking on the video element
const audioEl = remoteAudioRef.current;
if (audioEl) {
const s =
audioEl.srcObject instanceof MediaStream
? audioEl.srcObject
: new MediaStream();
if (!s.getAudioTracks().find((t) => t.id === track.id)) {
s.addTrack(track);
audioEl.srcObject = s;
audioEl.play().catch(() => setAudioBlocked(true));
}
}
return;
}
if (track.contentHint === "detail") {
const stream = new MediaStream([track]);
pendingScreenRef.current = stream;
// Video: distinguish screen vs camera by stream ID
// (contentHint is a local hint and is NOT transmitted over WebRTC)
const incomingStreamId = streams[0]?.id;
const isScreenTrack =
incomingStreamId &&
screenStreamIdRef.current &&
incomingStreamId === screenStreamIdRef.current;

if (isScreenTrack) {
const screenStream = streams[0] ?? new MediaStream([track]);
pendingScreenRef.current = screenStream;
setTeacherHasScreen(true);
setStreamActive(true);
track.onended = () => {
setTeacherHasScreen(false);
pendingScreenRef.current = null;
if (remoteScreenRef.current)
remoteScreenRef.current.srcObject = null;
};
} else {
const s = remoteCameraRef.current?.srcObject ?? new MediaStream();
s.addTrack(track);
if (remoteCameraRef.current) remoteCameraRef.current.srcObject = s;
// Camera video track
const s =
remoteCameraRef.current?.srcObject instanceof MediaStream
? remoteCameraRef.current.srcObject
: new MediaStream();
if (!s.getVideoTracks().find((t) => t.id === track.id)) {
s.addTrack(track);
if (remoteCameraRef.current) remoteCameraRef.current.srcObject = s;
}
setStreamActive(true);
}
};
Expand Down Expand Up @@ -839,13 +871,21 @@
const onBroadcasterLeft = () => {
setStreamActive(false);
setTeacherHasScreen(false);
screenStreamIdRef.current = null;
pendingScreenRef.current = null;
if (remoteCameraRef.current) remoteCameraRef.current.srcObject = null;
if (remoteAudioRef.current) remoteAudioRef.current.srcObject = null;
if (remoteScreenRef.current) remoteScreenRef.current.srcObject = null;
peerConnRef.current?.close();
peerConnRef.current = null;
};

const onScreenStarted = () => setTeacherHasScreen(true);
const onScreenStarted = ({ screenStreamId }) => {
if (screenStreamId) screenStreamIdRef.current = screenStreamId;
setTeacherHasScreen(true);
};
const onScreenStopped = () => {
screenStreamIdRef.current = null;
setTeacherHasScreen(false);
if (remoteScreenRef.current) remoteScreenRef.current.srcObject = null;
pendingScreenRef.current = null;
Expand Down Expand Up @@ -894,7 +934,7 @@
if (isTeacher) {
cameraStreamRef.current?.getTracks().forEach((t) => t.stop());
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
peerConnsRef.current.forEach((pc) => pc.close());

Check warning on line 937 in client/src/pages/LiveClassRoom.jsx

View workflow job for this annotation

GitHub Actions / Lint & Format Check

The ref value 'peerConnsRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'peerConnsRef.current' to a variable inside the effect, and use that variable in the cleanup function

Check warning on line 937 in client/src/pages/LiveClassRoom.jsx

View workflow job for this annotation

GitHub Actions / Lint & Format Check

The ref value 'peerConnsRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'peerConnsRef.current' to a variable inside the effect, and use that variable in the cleanup function

Check warning on line 937 in client/src/pages/LiveClassRoom.jsx

View workflow job for this annotation

GitHub Actions / Frontend — Lint & Format

The ref value 'peerConnsRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'peerConnsRef.current' to a variable inside the effect, and use that variable in the cleanup function
socket.emit("broadcaster-stop", { liveClassId: id });
// Stop speech recognition if active
if (subtitleOnRef.current) {
Expand Down Expand Up @@ -1086,12 +1126,23 @@
/>
)}

{/* Student: dedicated hidden audio element for teacher voice */}
{!isTeacher && (
<audio
ref={remoteAudioRef}
autoPlay
playsInline
className="hidden"
/>
)}

{/* Student: teacher's camera */}
{!isTeacher && (
<video
ref={remoteCameraRef}
autoPlay
playsInline
muted
className={`object-cover
${
teacherHasScreen
Expand All @@ -1101,6 +1152,21 @@
/>
)}

{/* Student: audio blocked prompt */}
{!isTeacher && audioBlocked && (
<button
onClick={() => {
remoteAudioRef.current
?.play()
.then(() => setAudioBlocked(false))
.catch(() => {});
}}
className="absolute top-4 left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 px-4 py-2.5 rounded-xl bg-amber-500/20 text-amber-300 border border-amber-500/30 text-xs font-bold cursor-pointer hover:bg-amber-500/30 transition-colors animate-pulse"
>
🔇 Click to enable audio
</button>
)}

{/* Student: self-view preview (own camera on) */}
{!isTeacher && studentCamOn && (
<div className="absolute bottom-4 left-4 z-20">
Expand Down
Loading