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
6 changes: 5 additions & 1 deletion frontend/src/components/common/GlobalSearch.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,18 @@ const GlobalSearch = () => {
<input
ref={inputRef}
type="text"
aria-label="Search courses"
value={query}
onChange={e => setQuery(e.target.value)}
onFocus={() => { if (results.length > 0) setIsOpen(true); }}
placeholder="Search courses..."
className="flex-1 bg-transparent text-xs font-medium text-slate-700 dark:text-white placeholder-slate-400 dark:placeholder-white/25 focus:outline-none min-w-0"
/>
{query && (
<button onClick={() => { setQuery(''); setResults([]); setIsOpen(false); inputRef.current?.focus(); }}>
<button
onClick={() => { setQuery(''); setResults([]); setIsOpen(false); inputRef.current?.focus(); }}
aria-label="Clear search"
>
<X size={13} className="text-slate-400 dark:text-white/30 hover:text-slate-600 dark:hover:text-white/60 transition-colors" />
</button>
)}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/common/Pagination.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const Pagination = ({ page, pages, onPage }) => (
onClick={() => onPage(page - 1)}
disabled={page <= 1}
aria-label="Previous page"
className="w-8 h-8 rounded-lg border border-slate-200/60 dark:border-white/10 flex items-center justify-center disabled:opacity-30 hover:bg-slate-100 dark:hover:bg-white/5 transition"
className="w-8 h-8 rounded-lg border border-slate-200/60 dark:border-white/10 flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent hover:bg-slate-100 dark:hover:bg-white/5 transition"
>
<ChevronLeft size={14} />
</button>
Expand All @@ -20,7 +20,7 @@ const Pagination = ({ page, pages, onPage }) => (
onClick={() => onPage(page + 1)}
disabled={page >= pages}
aria-label="Next page"
className="w-8 h-8 rounded-lg border border-slate-200/60 dark:border-white/10 flex items-center justify-center disabled:opacity-30 hover:bg-slate-100 dark:hover:bg-white/5 transition"
className="w-8 h-8 rounded-lg border border-slate-200/60 dark:border-white/10 flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent hover:bg-slate-100 dark:hover:bg-white/5 transition"
>
<ChevronRight size={14} />
</button>
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/components/course/AnnouncementModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,19 @@ const AnnouncementModal = ({ courseId, onClose }) => {
>
{/* Compose */}
<div className="space-y-3 mb-5">
<label htmlFor="announcement-title" className="sr-only">Announcement title</label>
<input
id="announcement-title"
type="text"
placeholder="Announcement title"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={200}
className="w-full px-4 py-2.5 rounded-2xl border border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 text-sm font-semibold text-slate-800 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
/>
<label htmlFor="announcement-content" className="sr-only">Announcement content</label>
<textarea
id="announcement-content"
placeholder="Write your message here…"
value={content}
onChange={(e) => setContent(e.target.value)}
Expand All @@ -72,6 +76,9 @@ const AnnouncementModal = ({ courseId, onClose }) => {
<button
type="button"
onClick={() => setIsPinned(!isPinned)}
role="switch"
aria-checked={isPinned}
aria-label="Pin this announcement"
className={`w-9 h-5 rounded-full transition-colors ${isPinned ? 'bg-indigo-500' : 'bg-slate-200 dark:bg-white/10'} flex items-center px-0.5`}
>
<span className={`w-4 h-4 rounded-full bg-white shadow transition-transform ${isPinned ? 'translate-x-4' : 'translate-x-0'}`} />
Expand Down Expand Up @@ -112,6 +119,7 @@ const AnnouncementModal = ({ courseId, onClose }) => {
<button
onClick={() => handleDelete(ann._id)}
disabled={deletingId === ann._id}
aria-label={`Delete announcement "${ann.title}"`}
className="shrink-0 p-1.5 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 text-slate-400 hover:text-red-500 transition disabled:opacity-40"
>
{deletingId === ann._id ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/course/ReviewModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,11 @@ const ReviewModal = ({ isOpen, onClose, courseId, courseTitle, onReviewed }) =>

{/* Comment */}
<div>
<label className="text-[11px] font-black uppercase tracking-widest text-slate-400 dark:text-white/30 mb-2 block">
<label htmlFor="review-comment" className="text-[11px] font-black uppercase tracking-widest text-slate-400 dark:text-white/30 mb-2 block">
Comment (optional)
</label>
<textarea
id="review-comment"
value={comment}
onChange={e => setComment(e.target.value)}
maxLength={500}
Expand Down
15 changes: 13 additions & 2 deletions frontend/src/components/gamification/Leaderboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const Leaderboard = ({ courseId }) => {
const [entries, setEntries] = useState([]);
const [period, setPeriod] = useState('weekly');
const [isLoading, setIsLoading] = useState(true);
const [isMockData, setIsMockData] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const { user: currentUser } = useCourseStore();
const { activeAvatar } = useGamificationStore();
Expand Down Expand Up @@ -52,15 +53,17 @@ const Leaderboard = ({ courseId }) => {
? `/api/progress/course/${courseId}/leaderboard?period=${period}`
: `/api/progress/leaderboard?period=${period}`;
const { data } = await api.get(endpoint);

// Map avatars/emojis if not present
const mapped = (data.leaderboard || []).map((e, idx) => ({
...e,
avatar: e.isYou ? currentUserEmoji : (MOCK_EMOJIS[idx % MOCK_EMOJIS.length])
}));
setEntries(mapped);
setIsMockData(false);
} catch {
// Mock leaderboard when API not yet implemented
// Leaderboard API unavailable — fall back to demo data, but flag it so the UI can be transparent about it
setIsMockData(true);
setEntries([
{ rank: 1, name: 'Alex K.', xp: 1850, avatar: '🥷', isYou: false },
{ rank: 2, name: 'Sarah M.', xp: 1620, avatar: '🦄', isYou: false },
Expand All @@ -87,6 +90,14 @@ const Leaderboard = ({ courseId }) => {
<Trophy size={16} className="text-amber-500 fill-amber-500/25" />
</div>
<h3 className="text-sm font-black text-slate-800 dark:text-white">Leaderboard</h3>
{!isLoading && isMockData && (
<span
className="text-[9px] font-black uppercase tracking-wider px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20"
title="Live leaderboard data is unavailable right now — showing demo data."
>
Demo
</span>
)}
</div>
{/* Period toggle */}
<div className="flex bg-slate-100 dark:bg-white/5 rounded-xl p-0.5 gap-0.5 border border-slate-200/50 dark:border-white/5">
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/layout/InstructorLayout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ const NotificationBell = () => {
<div className="flex items-start gap-2 mb-1.5">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-purple-100 to-violet-100 dark:from-purple-900/40 dark:to-violet-900/40 flex items-center justify-center text-sm flex-shrink-0">
{e.student?.avatar ? (
<img src={e.student.avatar} alt="" className="w-7 h-7 rounded-lg object-cover" />
<img src={e.student.avatar} alt={e.student?.name ? `${e.student.name}'s avatar` : 'Student avatar'} className="w-7 h-7 rounded-lg object-cover" />
) : (
<span className="text-[10px] font-black text-purple-600 dark:text-purple-400">
{e.student?.name?.[0]?.toUpperCase() || '?'}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/layout/SettingsModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import useGamificationStore, { AVATARS, INSTRUCTOR_AVATARS, TITLES } from '../..
import sounds from '../../utils/soundManager';
import api from '../../utils/api';
import useAuthStore from '../../store/authStore';
import useToastStore from '../../store/toastStore';
import BaseModal from '../common/BaseModal';

const SettingsModal = ({ isOpen, onClose, isInstructor = false }) => {
Expand All @@ -23,6 +24,7 @@ const SettingsModal = ({ isOpen, onClose, isInstructor = false }) => {
await useAuthStore.getState().initialize();
} catch (err) {
console.error('Failed to sync avatar with database:', err);
useToastStore.getState().error('Avatar not saved', 'Your avatar changed locally, but failed to sync — it may reset on your next login.');
}
}
};
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/components/quiz/QuizEditorModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,11 @@ const QuestionCard = ({ question, index, nodeId, onDelete }) => {

{/* Question text */}
<div>
<label className="block text-xs font-bold text-slate-500 dark:text-white/50 uppercase tracking-wider mb-1.5">
<label htmlFor={`question-text-${nodeId}-${index}`} className="block text-xs font-bold text-slate-500 dark:text-white/50 uppercase tracking-wider mb-1.5">
Question Text
</label>
<textarea
id={`question-text-${nodeId}-${index}`}
value={form.question}
onChange={e => setForm(f => ({ ...f, question: e.target.value }))}
rows={2}
Expand All @@ -116,11 +117,13 @@ const QuestionCard = ({ question, index, nodeId, onDelete }) => {
name={`correct-${nodeId}-${index}`}
checked={form.correctAnswerIndex === i}
onChange={() => setForm(f => ({ ...f, correctAnswerIndex: i }))}
aria-label={`Mark option ${i + 1} as correct`}
className="accent-indigo-500 w-4 h-4 shrink-0 cursor-pointer"
/>
<input
value={opt}
onChange={e => setOption(i, e.target.value)}
aria-label={`Option ${i + 1} text`}
className="flex-1 px-3 py-2 rounded-xl border border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 text-slate-800 dark:text-white text-sm focus:border-indigo-400 focus:outline-none transition-colors"
placeholder={`Option ${i + 1}`}
dir="auto"
Expand All @@ -132,10 +135,11 @@ const QuestionCard = ({ question, index, nodeId, onDelete }) => {

{/* Explanation */}
<div>
<label className="block text-xs font-bold text-slate-500 dark:text-white/50 uppercase tracking-wider mb-1.5">
<label htmlFor={`question-explanation-${nodeId}-${index}`} className="block text-xs font-bold text-slate-500 dark:text-white/50 uppercase tracking-wider mb-1.5">
Explanation (shown after answer)
</label>
<textarea
id={`question-explanation-${nodeId}-${index}`}
value={form.explanation}
onChange={e => setForm(f => ({ ...f, explanation: e.target.value }))}
rows={2}
Expand Down
13 changes: 9 additions & 4 deletions frontend/src/components/wizard/StepCoreDetails.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ const StepCoreDetails = () => {
<div className="lg:col-span-3 space-y-5">
{/* Course Name */}
<div>
<label className="block text-sm font-bold text-gray-700 mb-1.5">Course Name</label>
<label htmlFor="course-title" className="block text-sm font-bold text-gray-700 mb-1.5">Course Name</label>
<input
id="course-title"
{...register('title', { required: 'Course Name is required' })}
aria-invalid={!!errors.title}
className={`w-full px-4 py-3 rounded-xl border-2 font-medium transition-all duration-200 outline-none
${errors.title
? 'border-red-400 bg-red-50 focus:border-red-500'
Expand All @@ -52,8 +54,8 @@ const StepCoreDetails = () => {
</div>

{/* Department Grid */}
<div>
<label className="block text-sm font-bold text-gray-700 mb-2">Academic Department</label>
<div role="group" aria-labelledby="dept-group-label">
<label id="dept-group-label" className="block text-sm font-bold text-gray-700 mb-2">Academic Department</label>
<input type="hidden" {...register('department', { required: 'Please select a department' })} />
<div className="grid grid-cols-3 gap-2">
{DEPARTMENTS.map((dept) => {
Expand All @@ -63,6 +65,7 @@ const StepCoreDetails = () => {
<button
key={dept.value}
type="button"
aria-pressed={isSelected}
onClick={() => setValue('department', dept.value, { shouldValidate: true })}
className={`flex flex-col items-center gap-1.5 p-3 rounded-xl border-2 transition-all duration-200 cursor-pointer
${isSelected
Expand All @@ -86,16 +89,18 @@ const StepCoreDetails = () => {
{/* Description */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-bold text-gray-700">Description</label>
<label htmlFor="course-description" className="text-sm font-bold text-gray-700">Description</label>
<span className={`text-xs font-medium ${description.length >= 10 ? 'text-emerald-600' : 'text-gray-400'}`}>
{description.length}/200
</span>
</div>
<textarea
id="course-description"
{...register('description', {
required: 'Description is required',
minLength: { value: 10, message: 'Must be at least 10 characters' }
})}
aria-invalid={!!errors.description}
maxLength={200}
className={`w-full px-4 py-3 rounded-xl border-2 transition-all duration-200 outline-none h-28 resize-none
${errors.description
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/wizard/StepMaterials.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useCallback, useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { useDropzone } from 'react-dropzone';
import { UploadCloud, FileText, Video, X, Presentation, FileSpreadsheet, CheckCircle2, Sparkles, Eye, Info } from 'lucide-react';
import useToastStore from '../../store/toastStore';

const FILE_TYPE_META = {
pdf: { label: 'PDF', color: 'text-red-600', bg: 'bg-red-50', border: 'border-red-200' },
Expand Down Expand Up @@ -66,7 +67,7 @@ const StepMaterials = () => {
const existingFileSignatures = new Set(materials.map(f => `${f.name}_${f.size}`));
const uniqueNewFiles = acceptedFiles.filter(file => !existingFileSignatures.has(`${file.name}_${file.size}`));
if (uniqueNewFiles.length < acceptedFiles.length) {
alert(`${acceptedFiles.length - uniqueNewFiles.length} duplicate file(s) were skipped.`);
useToastStore.getState().info('Duplicate files skipped', `${acceptedFiles.length - uniqueNewFiles.length} duplicate file(s) were skipped.`);
}
const newFiles = uniqueNewFiles.map(file => Object.assign(file, { preview: URL.createObjectURL(file) }));
setValue('materials', [...materials, ...newFiles], { shouldValidate: true });
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/pages/ClassRoster.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import InstructorLayout from '../components/layout/InstructorLayout';
import { Users, Zap, Flame, CheckSquare, Square, Mail, SlidersHorizontal, TrendingUp, AlertTriangle, Clock, UserPlus, X } from 'lucide-react';
import useCourseStore from '../store/courseStore';
import useEnrollmentStore from '../store/enrollmentStore';
import useToastStore from '../store/toastStore';
import api from '../utils/api';
import { clickableProps } from '../utils/a11y';

Expand Down Expand Up @@ -44,6 +45,7 @@ const ClassRoster = () => {
setStudents(data.students || []);
} catch (err) {
console.error('Failed to fetch students', err);
useToastStore.getState().error('Failed to load roster', 'Could not fetch students for this course. Please try again.');
} finally {
setIsLoading(false);
}
Expand Down Expand Up @@ -285,7 +287,7 @@ const ClassRoster = () => {
{/* Avatar + Info */}
<div className="flex items-center gap-3 mb-4">
{student.avatar && (student.avatar.startsWith('http') || student.avatar.startsWith('/')) ? (
<img src={student.avatar} alt="" className="w-12 h-12 rounded-2xl object-cover border border-slate-200 dark:border-white/10 flex-shrink-0" />
<img src={student.avatar} alt={student.name ? `${student.name}'s avatar` : 'Student avatar'} className="w-12 h-12 rounded-2xl object-cover border border-slate-200 dark:border-white/10 flex-shrink-0" />
) : (
<div className="w-12 h-12 rounded-2xl bg-slate-100 dark:bg-white/10 border border-slate-200 dark:border-white/5 flex items-center justify-center text-2xl flex-shrink-0 select-none">
{student.avatar || '🎓'}
Expand Down
Loading
Loading