feat: Round 23 - Practice exercises, enhanced virtual character, inter-school federation, voice chat
- Practice exercises: AI-generated multiple choice questions, practice mode with score tracking - Enhanced virtual character: More animations, accessories, AI chat integration, XP system - Inter-school federation: School leaderboard, team scores, federation competitions - Enhanced communication: Voice chat, file sharing, note sharing, teacher notes panel - Added isFederation and federationSchedule fields to Competition model - 170+ new i18n keys - All lints pass
This commit is contained in:
1 parent
2f266a2143
commit
44ff170fca
12 files changed
+3655
-476
No files matched your search
@@ -24,6 +24,14 @@ import {
|
||||
AlertTriangle,
|
||||
Volume2,
|
||||
Notebook,
|
||||
Upload,
|
||||
Play,
|
||||
Pause,
|
||||
Trash2,
|
||||
Save,
|
||||
File,
|
||||
Image,
|
||||
FileIcon,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -76,6 +84,20 @@ interface Message {
|
||||
sender?: { id: string; firstName: string; lastName: string; role: string };
|
||||
}
|
||||
|
||||
interface NotebookItem {
|
||||
id: string;
|
||||
title: string;
|
||||
color: string;
|
||||
notebookType: string;
|
||||
pages?: Array<{ id: string; title: string | null; pageNumber: number }>;
|
||||
}
|
||||
|
||||
interface TeacherNoteItem {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function CommunicationView() {
|
||||
const currentUser = useAppStore((s) => s.currentUser);
|
||||
const [rooms, setRooms] = useState<CommunicationRoom[]>([]);
|
||||
@@ -96,7 +118,6 @@ export default function CommunicationView() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchRooms();
|
||||
// Polling every 5 seconds for new messages
|
||||
const interval = setInterval(fetchRooms, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchRooms]);
|
||||
@@ -328,7 +349,6 @@ function RoomListItem({
|
||||
<div className="h-8 w-8 rounded-full bg-emerald-100 dark:bg-emerald-950/50 flex items-center justify-center">
|
||||
<User className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
{/* Online/offline status indicator */}
|
||||
<div className={`absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-white dark:border-gray-900 ${
|
||||
room.status === 'active' ? 'bg-emerald-500' : room.status === 'requested' ? 'bg-amber-500' : 'bg-gray-400'
|
||||
}`} />
|
||||
@@ -343,7 +363,6 @@ function RoomListItem({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{/* Unread count badge */}
|
||||
{room.messages && room.messages.length > 0 && room.status === 'active' && (
|
||||
<span className="flex items-center justify-center h-4 min-w-4 px-1 rounded-full bg-emerald-500 text-white text-[10px] font-bold">
|
||||
{room.messages.length}
|
||||
@@ -379,10 +398,23 @@ function ChatArea({
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [room, setRoom] = useState<CommunicationRoom | null>(null);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [teacherNotes, setTeacherNotes] = useState('');
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [showTeacherNotes, setShowTeacherNotes] = useState(false);
|
||||
const [teacherNotes, setTeacherNotes] = useState<TeacherNoteItem[]>([]);
|
||||
const [newTeacherNote, setNewTeacherNote] = useState('');
|
||||
const [showShareNotesDialog, setShowShareNotesDialog] = useState(false);
|
||||
const [notebooks, setNotebooks] = useState<NotebookItem[]>([]);
|
||||
const [isSharingNote, setIsSharingNote] = useState(false);
|
||||
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<BlobPart[]>([]);
|
||||
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fetchMessages = useCallback(async () => {
|
||||
try {
|
||||
@@ -404,24 +436,55 @@ function ChatArea({
|
||||
}
|
||||
}, [roomId]);
|
||||
|
||||
const fetchTeacherNotes = useCallback(async () => {
|
||||
if (role !== 'TEACHER') return;
|
||||
try {
|
||||
const data = await apiGet<{ sharedNotes: Array<{ id: string; content: string; createdAt: string; metadata: string | null }> }>(`/api/communication-rooms/${roomId}/share-notes`);
|
||||
// Convert shared notes to teacher note items for display
|
||||
const notes = data.sharedNotes.map((n) => {
|
||||
let meta: Record<string, unknown> = {};
|
||||
try { meta = n.metadata ? JSON.parse(n.metadata) : {}; } catch { /* ignore */ }
|
||||
return {
|
||||
id: n.id,
|
||||
content: `[${t('communication.note_shared')}: ${meta.notebookTitle || n.content}]`,
|
||||
createdAt: n.createdAt,
|
||||
};
|
||||
});
|
||||
setTeacherNotes(notes);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [roomId, role]);
|
||||
|
||||
const fetchNotebooks = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiGet<Array<{ id: string; title: string; color: string; notebookType: string; pages?: Array<{ id: string; title: string | null; pageNumber: number }> }>>('/api/notebooks');
|
||||
setNotebooks(data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMessages();
|
||||
fetchRoom();
|
||||
fetchTeacherNotes();
|
||||
const interval = setInterval(fetchMessages, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchMessages, fetchRoom]);
|
||||
}, [fetchMessages, fetchRoom, fetchTeacherNotes]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!newMessage.trim()) return;
|
||||
const sendMessage = async (content: string, messageType: string = 'text', metadata?: string) => {
|
||||
if (!content && messageType === 'text') return;
|
||||
setIsSending(true);
|
||||
try {
|
||||
await apiPost(`/api/communication-rooms/${roomId}/messages`, {
|
||||
content: newMessage,
|
||||
messageType: 'text',
|
||||
content,
|
||||
messageType,
|
||||
metadata: metadata || null,
|
||||
});
|
||||
setNewMessage('');
|
||||
fetchMessages();
|
||||
@@ -465,38 +528,47 @@ function ChatArea({
|
||||
}
|
||||
};
|
||||
|
||||
// Voice recording with Web Audio API
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
const audioChunks: BlobPart[] = [];
|
||||
audioChunksRef.current = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
audioChunks.push(event.data);
|
||||
audioChunksRef.current.push(event.data);
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
|
||||
// In a real app, you'd upload this to a server
|
||||
// For now, just send a text message indicating voice note
|
||||
try {
|
||||
await apiPost(`/api/communication-rooms/${roomId}/messages`, {
|
||||
content: t('communication.voice_message'),
|
||||
messageType: 'voice',
|
||||
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = async () => {
|
||||
const base64Audio = reader.result as string;
|
||||
const metadata = JSON.stringify({
|
||||
audioData: base64Audio,
|
||||
duration: recordingDuration,
|
||||
mimeType: 'audio/webm',
|
||||
});
|
||||
fetchMessages();
|
||||
onRefresh();
|
||||
} catch {
|
||||
toast.error(t('communication.error_send'));
|
||||
}
|
||||
await sendMessage(
|
||||
t('communication.voice_message'),
|
||||
'voice',
|
||||
metadata,
|
||||
);
|
||||
};
|
||||
reader.readAsDataURL(audioBlob);
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
mediaRecorder.start();
|
||||
setIsRecording(true);
|
||||
setRecordingDuration(0);
|
||||
|
||||
recordingTimerRef.current = setInterval(() => {
|
||||
setRecordingDuration((prev) => prev + 1);
|
||||
}, 1000);
|
||||
} catch {
|
||||
toast.error('Microphone access denied');
|
||||
toast.error(t('communication.voice_recording_error'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -504,6 +576,124 @@ function ChatArea({
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
if (recordingTimerRef.current) {
|
||||
clearInterval(recordingTimerRef.current);
|
||||
recordingTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Voice playback
|
||||
const playVoiceMessage = (msg: Message) => {
|
||||
if (playingVoiceId === msg.id) {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
setPlayingVoiceId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let meta: Record<string, unknown> = {};
|
||||
try { meta = msg.metadata ? JSON.parse(msg.metadata) : {}; } catch { /* ignore */ }
|
||||
|
||||
const audioData = meta.audioData as string;
|
||||
if (audioData) {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
const audio = new Audio(audioData);
|
||||
audio.onended = () => setPlayingVoiceId(null);
|
||||
audio.play();
|
||||
audioRef.current = audio;
|
||||
setPlayingVoiceId(msg.id);
|
||||
}
|
||||
} catch {
|
||||
// If audio playback fails, just show the message
|
||||
}
|
||||
};
|
||||
|
||||
// File sharing
|
||||
const handleFileUpload = async (file: File) => {
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error(t('communication.file_size_limit'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = async () => {
|
||||
const base64Data = reader.result as string;
|
||||
const metadata = JSON.stringify({
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
fileType: file.type,
|
||||
fileData: base64Data,
|
||||
});
|
||||
await sendMessage(file.name, 'file', metadata);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch {
|
||||
toast.error('Error uploading file');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFileUpload(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
// Note sharing
|
||||
const handleShareNote = async (notebookId: string) => {
|
||||
setIsSharingNote(true);
|
||||
try {
|
||||
await apiPost(`/api/communication-rooms/${roomId}/share-notes`, { notebookId });
|
||||
toast.success(t('communication.note_shared'));
|
||||
setShowShareNotesDialog(false);
|
||||
fetchMessages();
|
||||
onRefresh();
|
||||
} catch {
|
||||
toast.error('Error sharing notebook');
|
||||
} finally {
|
||||
setIsSharingNote(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Save teacher note
|
||||
const handleSaveTeacherNote = async () => {
|
||||
if (!newTeacherNote.trim()) return;
|
||||
try {
|
||||
await apiPost('/api/teacher-notes', {
|
||||
studentId: room?.studentId,
|
||||
category: 'GENERAL',
|
||||
content: newTeacherNote.trim(),
|
||||
isPrivate: true,
|
||||
});
|
||||
toast.success(t('communication.notes_saved'));
|
||||
setTeacherNotes((prev) => [
|
||||
{ id: Date.now().toString(), content: newTeacherNote.trim(), createdAt: new Date().toISOString() },
|
||||
...prev,
|
||||
]);
|
||||
setNewTeacherNote('');
|
||||
} catch {
|
||||
toast.error('Error saving note');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -525,6 +715,18 @@ function ChatArea({
|
||||
return `${date.toLocaleDateString()} ${time}`;
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const getFileIcon = (fileType: string) => {
|
||||
if (fileType.startsWith('image/')) return <Image className="h-4 w-4" alt="" />;
|
||||
if (fileType === 'application/pdf') return <FileText className="h-4 w-4" />;
|
||||
return <FileIcon className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`flex-1 flex flex-col min-h-0 ${isMobile ? 'rounded-none border-0' : ''}`}>
|
||||
{/* Chat Header */}
|
||||
@@ -539,7 +741,6 @@ function ChatArea({
|
||||
<div className="h-10 w-10 rounded-full bg-emerald-100 dark:bg-emerald-950/50 flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
{/* Online status */}
|
||||
<div className={`absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-white dark:border-gray-900 ${
|
||||
room?.status === 'active' ? 'bg-emerald-500' : room?.status === 'requested' ? 'bg-amber-500' : 'bg-gray-400'
|
||||
}`} />
|
||||
@@ -607,14 +808,59 @@ function ChatArea({
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="border-b bg-amber-50 dark:bg-amber-950/20 p-4"
|
||||
>
|
||||
<Label className="text-sm font-medium">{t('communication.teacher_notes')}</Label>
|
||||
<Textarea
|
||||
value={teacherNotes}
|
||||
onChange={(e) => setTeacherNotes(e.target.value)}
|
||||
placeholder={t('communication.teacher_notes_placeholder')}
|
||||
rows={3}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label className="text-sm font-medium flex items-center gap-2">
|
||||
<StickyNote className="h-4 w-4 text-amber-500" />
|
||||
{t('communication.teacher_notes_panel')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Textarea
|
||||
value={newTeacherNote}
|
||||
onChange={(e) => setNewTeacherNote(e.target.value)}
|
||||
placeholder={t('communication.teacher_notes_placeholder')}
|
||||
rows={2}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveTeacherNote}
|
||||
disabled={!newTeacherNote.trim()}
|
||||
className="bg-emerald-500 hover:bg-emerald-600 text-white self-end"
|
||||
size="sm"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{teacherNotes.length > 0 && (
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{teacherNotes.map((note) => (
|
||||
<div key={note.id} className="flex items-start gap-2 p-2 rounded bg-white/50 dark:bg-gray-800/50 text-xs">
|
||||
<FileText className="h-3 w-3 mt-0.5 text-amber-500 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-muted-foreground">{note.content}</p>
|
||||
<p className="text-muted-foreground mt-0.5">{formatTime(note.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Drag and drop overlay */}
|
||||
<AnimatePresence>
|
||||
{dragOver && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 z-10 bg-emerald-50/80 dark:bg-emerald-950/50 flex items-center justify-center"
|
||||
>
|
||||
<div className="text-center">
|
||||
<Upload className="h-12 w-12 text-emerald-500 mx-auto mb-2" />
|
||||
<p className="text-lg font-medium text-emerald-700 dark:text-emerald-300">{t('communication.drop_zone')}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -641,7 +887,12 @@ function ChatArea({
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
<div
|
||||
className="flex-1 overflow-y-auto p-4 space-y-3"
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{isLoadingMessages ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||
@@ -657,6 +908,9 @@ function ChatArea({
|
||||
const isFile = msg.messageType === 'file';
|
||||
const isNoteShare = msg.messageType === 'note_share';
|
||||
|
||||
let msgMeta: Record<string, unknown> = {};
|
||||
try { msgMeta = msg.metadata ? JSON.parse(msg.metadata) : {}; } catch { /* ignore */ }
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={msg.id}
|
||||
@@ -679,29 +933,64 @@ function ChatArea({
|
||||
}`}
|
||||
>
|
||||
{isVoice && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Volume2 className="h-4 w-4" />
|
||||
<span>{t('communication.voice_message')}</span>
|
||||
<div className="flex items-center gap-2 min-w-[160px]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-8 w-8 shrink-0 ${isOwn ? 'text-white hover:bg-white/20' : 'text-emerald-600 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:bg-emerald-900/30'}`}
|
||||
onClick={() => playVoiceMessage(msg)}
|
||||
>
|
||||
{playingVoiceId === msg.id ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-1 rounded-full ${isOwn ? 'bg-white/60' : 'bg-emerald-300 dark:bg-emerald-700'}`}
|
||||
style={{ height: `${Math.max(4, Math.random() * 16)}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className={`text-xs mt-0.5 ${isOwn ? 'text-white/70' : 'text-muted-foreground'}`}>
|
||||
{msgMeta.duration ? formatDuration(msgMeta.duration as number) : '0:00'}
|
||||
</p>
|
||||
</div>
|
||||
<Volume2 className={`h-4 w-4 ${isOwn ? 'text-white/60' : 'text-emerald-500'}`} />
|
||||
</div>
|
||||
)}
|
||||
{isFile && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4" />
|
||||
<span>{t('communication.file_shared')}</span>
|
||||
<div className="flex items-center gap-2 min-w-[200px]">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg shrink-0 ${isOwn ? 'bg-white/20' : 'bg-emerald-50 dark:bg-emerald-900/30'}`}>
|
||||
{getFileIcon(msgMeta.fileType as string || 'application/octet-stream')}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{msgMeta.fileName as string || msg.content}</p>
|
||||
<p className={`text-xs ${isOwn ? 'text-white/70' : 'text-muted-foreground'}`}>
|
||||
{msgMeta.fileSize ? `${(Number(msgMeta.fileSize) / 1024).toFixed(1)} KB` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isNoteShare && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Notebook className="h-4 w-4" />
|
||||
<span>{t('communication.note_shared')}</span>
|
||||
<div className="flex items-center gap-2 min-w-[180px]">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg shrink-0 ${isOwn ? 'bg-white/20' : 'bg-emerald-50 dark:bg-emerald-900/30'}`}>
|
||||
<Notebook className={`h-5 w-5 ${isOwn ? 'text-white' : 'text-emerald-600 dark:text-emerald-400'}`} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{t('communication.note_shared')}</p>
|
||||
<p className={`text-xs ${isOwn ? 'text-white/70' : 'text-muted-foreground'}`}>
|
||||
{msgMeta.notebookTitle as string || msg.content}
|
||||
</p>
|
||||
{msgMeta.pageCount && (
|
||||
<p className={`text-xs ${isOwn ? 'text-white/60' : 'text-muted-foreground'}`}>
|
||||
{msgMeta.pageCount as number} {t('communication.notebook_pages')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isVoice && !isFile && !isNoteShare && <p className="text-sm">{msg.content}</p>}
|
||||
{msg.fileUrl && (
|
||||
<a href={msg.fileUrl} target="_blank" rel="noopener noreferrer" className="text-xs underline mt-1 block opacity-80">
|
||||
{t('communication.file_shared')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<p className={`text-xs text-muted-foreground mt-1 ${isOwn ? 'text-right mr-1' : 'ml-1'}`}>
|
||||
{formatTime(msg.createdAt)}
|
||||
@@ -717,93 +1006,99 @@ function ChatArea({
|
||||
{/* Message Input */}
|
||||
{!isAdmin && room?.status === 'active' && (
|
||||
<div className="p-4 border-t">
|
||||
<div className="flex items-center gap-2">
|
||||
{role === 'TEACHER' && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={() => {
|
||||
apiPost(`/api/communication-rooms/${roomId}/messages`, {
|
||||
content: t('communication.note_shared'),
|
||||
messageType: 'note_share',
|
||||
}).then(() => {
|
||||
fetchMessages();
|
||||
onRefresh();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<StickyNote className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{role === 'STUDENT' && (
|
||||
{isRecording ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div className="h-3 w-3 rounded-full bg-rose-500 animate-pulse" />
|
||||
<span className="text-sm text-rose-500 font-medium">{t('communication.recording')}</span>
|
||||
<span className="text-sm text-muted-foreground">{formatDuration(recordingDuration)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-rose-600 border-rose-200 hover:bg-rose-50"
|
||||
onClick={stopRecording}
|
||||
>
|
||||
<MicOff className="h-4 w-4 mr-1" />
|
||||
{t('communication.stop_recording')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Share Notes Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={() => {
|
||||
apiPost(`/api/communication-rooms/${roomId}/messages`, {
|
||||
content: t('communication.note_shared'),
|
||||
messageType: 'note_share',
|
||||
}).then(() => {
|
||||
fetchMessages();
|
||||
onRefresh();
|
||||
});
|
||||
fetchNotebooks();
|
||||
setShowShareNotesDialog(true);
|
||||
}}
|
||||
title={t('communication.share_notes')}
|
||||
>
|
||||
<StickyNote className="h-4 w-4" />
|
||||
<Notebook className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
>
|
||||
{isRecording ? (
|
||||
<MicOff className="h-4 w-4 text-rose-500" />
|
||||
) : (
|
||||
{/* File Upload Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title={t('communication.attach_file')}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||
) : (
|
||||
<Paperclip className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pdf,.png,.jpg,.jpeg,.gif,.doc,.docx,.txt,.xls,.xlsx"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileUpload(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{/* Voice Record Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={startRecording}
|
||||
title={t('communication.record_voice')}
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
{isRecording && (
|
||||
<div className="flex items-center gap-2 text-sm text-rose-500">
|
||||
<div className="h-2 w-2 rounded-full bg-rose-500 animate-pulse" />
|
||||
{t('communication.recording')}
|
||||
</div>
|
||||
)}
|
||||
{!isRecording && (
|
||||
<>
|
||||
<Input
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
placeholder={t('communication.type_message')}
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={sendMessage}
|
||||
disabled={isSending || !newMessage.trim()}
|
||||
className="bg-emerald-500 hover:bg-emerald-600 text-white h-9 shrink-0"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{/* Text Input */}
|
||||
<Input
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
placeholder={t('communication.type_message')}
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage(newMessage);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => sendMessage(newMessage)}
|
||||
disabled={isSending || !newMessage.trim()}
|
||||
className="bg-emerald-500 hover:bg-emerald-600 text-white h-9 shrink-0"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
||||
<Paperclip className="h-3 w-3" />
|
||||
<span>{t('communication.supported_types')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -827,6 +1122,57 @@ function ChatArea({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Share Notes Dialog */}
|
||||
<Dialog open={showShareNotesDialog} onOpenChange={setShowShareNotesDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Notebook className="h-5 w-5 text-emerald-500" />
|
||||
{t('communication.share_notes')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('communication.select_notebook')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{notebooks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">{t('communication.no_notebooks')}</p>
|
||||
) : (
|
||||
notebooks.map((nb) => (
|
||||
<motion.button
|
||||
key={nb.id}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className="w-full text-left p-3 rounded-lg border hover:border-emerald-300 dark:hover:border-emerald-700 transition-colors"
|
||||
onClick={() => handleShareNote(nb.id)}
|
||||
disabled={isSharingNote}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg shrink-0"
|
||||
style={{ backgroundColor: nb.color || '#10b981' }}
|
||||
>
|
||||
<Notebook className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{nb.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{nb.pages?.length || 0} {t('communication.notebook_pages')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowShareNotesDialog(false)}>
|
||||
{t('action.cancel')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,12 @@ import {
|
||||
Notebook,
|
||||
History,
|
||||
Loader2,
|
||||
Building2,
|
||||
Calculator,
|
||||
Microscope,
|
||||
Languages,
|
||||
Brain,
|
||||
CalendarDays,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -111,10 +117,13 @@ import {
|
||||
createCompetitionReward,
|
||||
fetchRewardClaims,
|
||||
claimReward,
|
||||
fetchFederationCompetitions,
|
||||
createFederationCompetition,
|
||||
type CompetitionData,
|
||||
type CompetitionLeaderboardEntry,
|
||||
type CompetitionRewardData,
|
||||
type RewardClaimData,
|
||||
type FederationLeaderboardEntry,
|
||||
} from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -770,6 +779,27 @@ export default function CompetitionsView() {
|
||||
// Register
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
|
||||
// Federation state
|
||||
const [federationCompetitions, setFederationCompetitions] = useState<CompetitionData[]>([]);
|
||||
const [federationLeaderboard, setFederationLeaderboard] = useState<FederationLeaderboardEntry[]>([]);
|
||||
const [federationLoading, setFederationLoading] = useState(true);
|
||||
const [federationCategory, setFederationCategory] = useState<string>('all');
|
||||
const [federationSchedule, setFederationSchedule] = useState<string>('all');
|
||||
const [federationCreateOpen, setFederationCreateOpen] = useState(false);
|
||||
const [isCreatingFederation, setIsCreatingFederation] = useState(false);
|
||||
const [federationForm, setFederationForm] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
category: 'math_olympiad',
|
||||
schedule: 'monthly',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
registrationDeadline: '',
|
||||
maxParticipants: '',
|
||||
rules: '',
|
||||
isPublic: true,
|
||||
});
|
||||
|
||||
const isAdmin = currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||
const isTeacher = currentUser?.role === 'TEACHER';
|
||||
const isStudent = currentUser?.role === 'STUDENT';
|
||||
@@ -800,6 +830,95 @@ export default function CompetitionsView() {
|
||||
loadCompetitions();
|
||||
}, [loadCompetitions]);
|
||||
|
||||
/* ── Fetch federation competitions ──────────────────────────────── */
|
||||
const loadFederation = useCallback(async () => {
|
||||
setFederationLoading(true);
|
||||
try {
|
||||
const result = await fetchFederationCompetitions(
|
||||
federationCategory !== 'all' ? federationCategory : undefined,
|
||||
federationSchedule !== 'all' ? federationSchedule : undefined,
|
||||
undefined,
|
||||
100,
|
||||
);
|
||||
setFederationCompetitions(result.competitions);
|
||||
setFederationLeaderboard(result.schoolLeaderboard);
|
||||
} catch (err) {
|
||||
console.error('Failed to load federation competitions:', err);
|
||||
} finally {
|
||||
setFederationLoading(false);
|
||||
}
|
||||
}, [federationCategory, federationSchedule]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFederation();
|
||||
}, [loadFederation]);
|
||||
|
||||
/* ── Create federation competition ─────────────────────────────── */
|
||||
const handleCreateFederation = useCallback(async () => {
|
||||
if (!schoolId) return;
|
||||
if (!federationForm.title.trim()) {
|
||||
toast.error('Title is required');
|
||||
return;
|
||||
}
|
||||
if (!federationForm.startDate || !federationForm.endDate) {
|
||||
toast.error('Start and end dates are required');
|
||||
return;
|
||||
}
|
||||
setIsCreatingFederation(true);
|
||||
try {
|
||||
await createFederationCompetition({
|
||||
schoolId,
|
||||
title: federationForm.title.trim(),
|
||||
description: federationForm.description.trim() || null,
|
||||
category: federationForm.category,
|
||||
schedule: federationForm.schedule,
|
||||
startDate: new Date(federationForm.startDate).toISOString(),
|
||||
endDate: new Date(federationForm.endDate).toISOString(),
|
||||
registrationDeadline: federationForm.registrationDeadline ? new Date(federationForm.registrationDeadline).toISOString() : null,
|
||||
maxParticipants: federationForm.maxParticipants ? parseInt(federationForm.maxParticipants, 10) : null,
|
||||
rules: federationForm.rules.trim() || null,
|
||||
isPublic: federationForm.isPublic,
|
||||
});
|
||||
toast.success(t('federation.create_success'));
|
||||
setFederationCreateOpen(false);
|
||||
setFederationForm({
|
||||
title: '',
|
||||
description: '',
|
||||
category: 'math_olympiad',
|
||||
schedule: 'monthly',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
registrationDeadline: '',
|
||||
maxParticipants: '',
|
||||
rules: '',
|
||||
isPublic: true,
|
||||
});
|
||||
loadFederation();
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('federation.error_create');
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsCreatingFederation(false);
|
||||
}
|
||||
}, [schoolId, federationForm, loadFederation]);
|
||||
|
||||
/* ── Join federation competition ────────────────────────────────── */
|
||||
const handleJoinFederation = useCallback(async (comp: CompetitionData) => {
|
||||
if (!currentUser || !schoolId) return;
|
||||
try {
|
||||
await registerCompetitionParticipant(comp.id, {
|
||||
participantType: 'user',
|
||||
participantId: currentUser.id,
|
||||
userId: currentUser.id,
|
||||
});
|
||||
toast.success(t('federation.register_success'));
|
||||
loadFederation();
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('federation.error_join');
|
||||
toast.error(message);
|
||||
}
|
||||
}, [currentUser, schoolId, loadFederation]);
|
||||
|
||||
/* ── Fetch my claims ─────────────────────────────────────────── */
|
||||
const loadMyClaims = useCallback(async () => {
|
||||
try {
|
||||
@@ -1228,6 +1347,10 @@ export default function CompetitionsView() {
|
||||
<TabsTrigger value="leaderboard" className="min-h-[44px]">{t('competition.leaderboard')}</TabsTrigger>
|
||||
<TabsTrigger value="rewards" className="min-h-[44px]">{t('competition.rewards')}</TabsTrigger>
|
||||
<TabsTrigger value="catalog" className="min-h-[44px]">{t('rewards.title')}</TabsTrigger>
|
||||
<TabsTrigger value="federation" className="min-h-[44px]">
|
||||
<Building2 className="h-4 w-4 mr-1" />
|
||||
{t('federation.title')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
@@ -1801,9 +1924,209 @@ export default function CompetitionsView() {
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Federation Tab */}
|
||||
<TabsContent value="federation" className="space-y-6 mt-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<Building2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t('federation.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('federation.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<Button
|
||||
className="min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
|
||||
onClick={() => setFederationCreateOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t('federation.create')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Federation Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Select value={federationCategory} onValueChange={setFederationCategory}>
|
||||
<SelectTrigger className="w-full sm:w-[200px] min-h-[44px]">
|
||||
<SelectValue placeholder={t('federation.category_label')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('federation.category_label')}</SelectItem>
|
||||
<SelectItem value="math_olympiad">{t('federation.math_olympiad')}</SelectItem>
|
||||
<SelectItem value="science_bowl">{t('federation.science_bowl')}</SelectItem>
|
||||
<SelectItem value="language_quiz">{t('federation.language_quiz')}</SelectItem>
|
||||
<SelectItem value="general_knowledge">{t('federation.general_knowledge')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={federationSchedule} onValueChange={setFederationSchedule}>
|
||||
<SelectTrigger className="w-full sm:w-[160px] min-h-[44px]">
|
||||
<SelectValue placeholder={t('federation.schedule')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('federation.schedule')}</SelectItem>
|
||||
<SelectItem value="weekly">{t('federation.weekly')}</SelectItem>
|
||||
<SelectItem value="monthly">{t('federation.monthly')}</SelectItem>
|
||||
<SelectItem value="quarterly">{t('federation.quarterly')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* School Leaderboard */}
|
||||
{federationLeaderboard.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Crown className="h-5 w-5 text-amber-500" />
|
||||
{t('federation.school_ranking')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<table className="w-full">
|
||||
<thead className="sticky top-0 bg-background">
|
||||
<tr className="border-b text-left text-xs text-muted-foreground">
|
||||
<th className="pb-2 pl-3 w-16">{t('federation.school_rank')}</th>
|
||||
<th className="pb-2">{t('federation.district_schools')}</th>
|
||||
<th className="pb-2 text-center">{t('federation.participants')}</th>
|
||||
<th className="pb-2 pr-3 text-right">{t('federation.team_score')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{federationLeaderboard.map((entry, idx) => (
|
||||
<motion.tr
|
||||
key={entry.schoolId}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: idx * 0.03 }}
|
||||
className={`border-b last:border-0 hover:bg-muted/50 transition-colors ${
|
||||
entry.schoolId === schoolId ? 'bg-emerald-50/50 dark:bg-emerald-900/10' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-2.5 pl-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{entry.rank === 1 ? (
|
||||
<Crown className="h-5 w-5 text-amber-500" />
|
||||
) : entry.rank === 2 ? (
|
||||
<Medal className="h-5 w-5 text-gray-400" />
|
||||
) : entry.rank === 3 ? (
|
||||
<Medal className="h-5 w-5 text-amber-700" />
|
||||
) : (
|
||||
<span className="text-sm font-medium text-muted-foreground w-5 text-center">{entry.rank}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded bg-emerald-100 dark:bg-emerald-900/30 shrink-0">
|
||||
<Building2 className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">{entry.schoolName}</span>
|
||||
{entry.schoolId === schoolId && (
|
||||
<Badge variant="secondary" className="text-xs bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
|
||||
{t('federation.your_school')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2.5 text-center">
|
||||
<span className="text-sm text-muted-foreground">{entry.participantCount}</span>
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-right">
|
||||
<span className="text-sm font-semibold text-emerald-600 dark:text-emerald-400">{entry.teamScore}</span>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Federation Competitions List */}
|
||||
{federationLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}><CardContent className="p-4"><Skeleton className="h-32" /></CardContent></Card>
|
||||
))}
|
||||
</div>
|
||||
) : federationCompetitions.length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex flex-col items-center justify-center py-16 text-muted-foreground"
|
||||
>
|
||||
<Building2 className="h-16 w-16 mb-4 opacity-20" />
|
||||
<p className="text-lg font-medium">{t('federation.no_competitions')}</p>
|
||||
<p className="text-sm mt-1">{t('federation.no_district')}</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
{/* Active Federation Competitions */}
|
||||
{federationCompetitions.filter((c) => c.status === 'active' || c.status === 'registration').length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Flame className="h-5 w-5 text-emerald-500" />
|
||||
<h3 className="text-lg font-semibold">{t('federation.active_federation')}</h3>
|
||||
<Badge variant="secondary" className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300">
|
||||
{federationCompetitions.filter((c) => c.status === 'active' || c.status === 'registration').length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<AnimatePresence>
|
||||
{federationCompetitions
|
||||
.filter((c) => c.status === 'active' || c.status === 'registration')
|
||||
.map((comp) => (
|
||||
<FederationCompetitionCard
|
||||
key={comp.id}
|
||||
competition={comp}
|
||||
onView={handleViewCompetition}
|
||||
onJoin={handleJoinFederation}
|
||||
isStudent={isStudent}
|
||||
currentSchoolId={schoolId}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Completed Federation Competitions */}
|
||||
{federationCompetitions.filter((c) => c.status === 'completed').length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-amber-500" />
|
||||
<h3 className="text-lg font-semibold">{t('federation.completed')}</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<AnimatePresence>
|
||||
{federationCompetitions
|
||||
.filter((c) => c.status === 'completed')
|
||||
.map((comp) => (
|
||||
<FederationCompetitionCard
|
||||
key={comp.id}
|
||||
competition={comp}
|
||||
onView={handleViewCompetition}
|
||||
onJoin={handleJoinFederation}
|
||||
isStudent={isStudent}
|
||||
currentSchoolId={schoolId}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* ── Competition Detail Dialog ──────────────────────────── */}
|
||||
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
{selectedCompetition && (
|
||||
@@ -2458,10 +2781,287 @@ export default function CompetitionsView() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Federation Create Dialog ───────────────────────────────── */}
|
||||
<Dialog open={federationCreateOpen} onOpenChange={setFederationCreateOpen}>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5 text-emerald-500" />
|
||||
{t('federation.create')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('federation.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div>
|
||||
<Label>{t('competition.name')} *</Label>
|
||||
<Input
|
||||
value={federationForm.title}
|
||||
onChange={(e) => setFederationForm((f) => ({ ...f, title: e.target.value }))}
|
||||
placeholder={t('federation.create') + '...'}
|
||||
className="mt-1 min-h-[44px]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('competition.description')}</Label>
|
||||
<Textarea
|
||||
value={federationForm.description}
|
||||
onChange={(e) => setFederationForm((f) => ({ ...f, description: e.target.value }))}
|
||||
placeholder={t('federation.description')}
|
||||
className="mt-1"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>{t('federation.category_label')} *</Label>
|
||||
<Select
|
||||
value={federationForm.category}
|
||||
onValueChange={(v) => setFederationForm((f) => ({ ...f, category: v }))}
|
||||
>
|
||||
<SelectTrigger className="mt-1 min-h-[44px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="math_olympiad">
|
||||
<span className="flex items-center gap-2"><Calculator className="h-4 w-4" /> {t('federation.math_olympiad')}</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="science_bowl">
|
||||
<span className="flex items-center gap-2"><Microscope className="h-4 w-4" /> {t('federation.science_bowl')}</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="language_quiz">
|
||||
<span className="flex items-center gap-2"><Languages className="h-4 w-4" /> {t('federation.language_quiz')}</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="general_knowledge">
|
||||
<span className="flex items-center gap-2"><Brain className="h-4 w-4" /> {t('federation.general_knowledge')}</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('federation.schedule')} *</Label>
|
||||
<Select
|
||||
value={federationForm.schedule}
|
||||
onValueChange={(v) => setFederationForm((f) => ({ ...f, schedule: v }))}
|
||||
>
|
||||
<SelectTrigger className="mt-1 min-h-[44px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="weekly">
|
||||
<span className="flex items-center gap-2"><CalendarDays className="h-4 w-4" /> {t('federation.weekly')}</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="monthly">
|
||||
<span className="flex items-center gap-2"><Calendar className="h-4 w-4" /> {t('federation.monthly')}</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="quarterly">
|
||||
<span className="flex items-center gap-2"><Calendar className="h-4 w-4" /> {t('federation.quarterly')}</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>{t('competition.start_date')} *</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={federationForm.startDate}
|
||||
onChange={(e) => setFederationForm((f) => ({ ...f, startDate: e.target.value }))}
|
||||
className="mt-1 min-h-[44px]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('competition.end_date')} *</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={federationForm.endDate}
|
||||
onChange={(e) => setFederationForm((f) => ({ ...f, endDate: e.target.value }))}
|
||||
className="mt-1 min-h-[44px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('competition.max_participants')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={federationForm.maxParticipants}
|
||||
onChange={(e) => setFederationForm((f) => ({ ...f, maxParticipants: e.target.value }))}
|
||||
placeholder={t('competition.unlimited')}
|
||||
className="mt-1 min-h-[44px]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('competition.rules')}</Label>
|
||||
<Textarea
|
||||
value={federationForm.rules}
|
||||
onChange={(e) => setFederationForm((f) => ({ ...f, rules: e.target.value }))}
|
||||
placeholder={t('competition.rules') + '...'}
|
||||
className="mt-1"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" className="min-h-[44px]" onClick={() => setFederationCreateOpen(false)}>
|
||||
{t('action.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
|
||||
onClick={handleCreateFederation}
|
||||
disabled={isCreatingFederation}
|
||||
>
|
||||
{isCreatingFederation ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t('federation.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Federation Competition Card ──────────────────────────────────── */
|
||||
|
||||
function FederationCompetitionCard({
|
||||
competition,
|
||||
onView,
|
||||
onJoin,
|
||||
isStudent,
|
||||
currentSchoolId,
|
||||
}: {
|
||||
competition: CompetitionData;
|
||||
onView: (c: CompetitionData) => void;
|
||||
onJoin: (c: CompetitionData) => void;
|
||||
isStudent: boolean;
|
||||
currentSchoolId: string | null;
|
||||
}) {
|
||||
const progress = getTimeProgress(competition.startDate, competition.endDate);
|
||||
const isRegistration = competition.status === 'registration';
|
||||
const isActive = competition.status === 'active';
|
||||
const participantCount = competition._count?.participants ?? 0;
|
||||
|
||||
const getFederationCategoryIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case 'math_olympiad': return <Calculator className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />;
|
||||
case 'science_bowl': return <Microscope className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />;
|
||||
case 'language_quiz': return <Languages className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />;
|
||||
case 'general_knowledge': return <Brain className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />;
|
||||
default: return <Trophy className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getFederationCategoryLabel = (category: string) => {
|
||||
switch (category) {
|
||||
case 'math_olympiad': return t('federation.math_olympiad');
|
||||
case 'science_bowl': return t('federation.science_bowl');
|
||||
case 'language_quiz': return t('federation.language_quiz');
|
||||
case 'general_knowledge': return t('federation.general_knowledge');
|
||||
default: return category;
|
||||
}
|
||||
};
|
||||
|
||||
const getScheduleLabel = (schedule: string | null) => {
|
||||
switch (schedule) {
|
||||
case 'weekly': return t('federation.weekly');
|
||||
case 'monthly': return t('federation.monthly');
|
||||
case 'quarterly': return t('federation.quarterly');
|
||||
default: return schedule || '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
whileHover={{ y: -2 }}
|
||||
className="h-full"
|
||||
>
|
||||
<Card className="border-l-4 border-l-emerald-500 h-full flex flex-col cursor-pointer transition-shadow hover:shadow-md" onClick={() => onView(competition)}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 dark:bg-emerald-900/30 shrink-0">
|
||||
{getFederationCategoryIcon(competition.category)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-base leading-tight truncate">{competition.title}</CardTitle>
|
||||
<CardDescription className="text-xs mt-0.5 flex items-center gap-1">
|
||||
<Building2 className="h-3 w-3" />
|
||||
{getFederationCategoryLabel(competition.category)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={`${getStatusColor(competition.status)} text-xs shrink-0`}>
|
||||
{getStatusLabel(competition.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 pb-3 space-y-3">
|
||||
{competition.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{competition.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{participantCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
{getScheduleLabel(competition.federationSchedule)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
{competition.school?.name}
|
||||
</span>
|
||||
</div>
|
||||
{(isActive || isRegistration) && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{getTimeRemaining(competition.endDate)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{progress}%</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-1.5" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="pt-0 pb-4 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 min-h-[44px]"
|
||||
onClick={(e) => { e.stopPropagation(); onView(competition); }}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
{t('competition.details')}
|
||||
</Button>
|
||||
{isRegistration && isStudent && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
|
||||
onClick={(e) => { e.stopPropagation(); onJoin(competition); }}
|
||||
>
|
||||
<UserPlus className="h-4 w-4 mr-1" />
|
||||
{t('federation.join')}
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── CompetitionRewardsSection (for rewards tab) ────────────────── */
|
||||
|
||||
function CompetitionRewardsSection({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Palette, Dumbbell, Map, Settings, FileText, MessageSquare, Shield,
|
||||
Loader2, Sparkles, RefreshCw, Filter, ToggleLeft, ToggleRight, Send,
|
||||
AlertTriangle, CheckCircle2, Clock, XCircle, ChevronDown, Info,
|
||||
PartyPopper, Trophy, RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -30,6 +31,13 @@ import { toast } from 'sonner';
|
||||
|
||||
/* ── Types ─────────────────────────────────────────────────────────── */
|
||||
|
||||
interface PracticeQuestion {
|
||||
question: string;
|
||||
options: string[];
|
||||
correctAnswer: number;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
interface SubjectCategory {
|
||||
id: string;
|
||||
schoolId: string;
|
||||
@@ -198,10 +206,14 @@ export default function SubjectsView() {
|
||||
// Practice state
|
||||
const [isPracticing, setIsPracticing] = useState(false);
|
||||
const [currentQuestion, setCurrentQuestion] = useState(0);
|
||||
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null);
|
||||
const [selectedAnswer, setSelectedAnswer] = useState<number | null>(null);
|
||||
const [showResult, setShowResult] = useState(false);
|
||||
const [practiceScore, setPracticeScore] = useState(0);
|
||||
const [practiceComplete, setPracticeComplete] = useState(false);
|
||||
const [practiceQuestions, setPracticeQuestions] = useState<PracticeQuestion[]>([]);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [answerHistory, setAnswerHistory] = useState<Array<{ questionIndex: number; selectedAnswer: number; isCorrect: boolean }>>([]);
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
|
||||
// Dialog state
|
||||
const [showCategoryDialog, setShowCategoryDialog] = useState(false);
|
||||
@@ -343,6 +355,8 @@ export default function SubjectsView() {
|
||||
const handleTopicClick = (topic: SubjectContentItem) => {
|
||||
setSelectedTopic(topic);
|
||||
setIsPracticing(false);
|
||||
setPracticeQuestions([]);
|
||||
setAnswerHistory([]);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
@@ -365,34 +379,127 @@ export default function SubjectsView() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartPractice = () => {
|
||||
const handleStartPractice = async () => {
|
||||
if (!selectedTopic) return;
|
||||
setIsGenerating(true);
|
||||
|
||||
try {
|
||||
// First try to load existing questions
|
||||
const data = await apiGet<{ questions: PracticeQuestion[]; questionCount: number }>(
|
||||
`/api/subject-contents/exercises?contentId=${selectedTopic.id}`
|
||||
);
|
||||
|
||||
if (data.questions && data.questions.length > 0) {
|
||||
setPracticeQuestions(data.questions);
|
||||
setIsPracticing(true);
|
||||
setCurrentQuestion(0);
|
||||
setPracticeScore(0);
|
||||
setSelectedAnswer(null);
|
||||
setShowResult(false);
|
||||
setPracticeComplete(false);
|
||||
setAnswerHistory([]);
|
||||
setShowConfetti(false);
|
||||
} else {
|
||||
// No existing questions, generate new ones
|
||||
await handleGenerateQuestions();
|
||||
}
|
||||
} catch {
|
||||
// Fallback: try to generate
|
||||
await handleGenerateQuestions();
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateQuestions = async () => {
|
||||
if (!selectedTopic) return;
|
||||
setIsGenerating(true);
|
||||
|
||||
try {
|
||||
const data = await apiPost<{
|
||||
questions: PracticeQuestion[];
|
||||
requestsToday: number;
|
||||
maxRequests: number;
|
||||
}>('/api/subject-contents/exercises', {
|
||||
contentId: selectedTopic.id,
|
||||
topic: selectedTopic.title,
|
||||
count: 5,
|
||||
difficulty: selectedTopic.difficulty || 'medium',
|
||||
});
|
||||
|
||||
if (data.questions && data.questions.length > 0) {
|
||||
setPracticeQuestions(data.questions);
|
||||
setIsPracticing(true);
|
||||
setCurrentQuestion(0);
|
||||
setPracticeScore(0);
|
||||
setSelectedAnswer(null);
|
||||
setShowResult(false);
|
||||
setPracticeComplete(false);
|
||||
setAnswerHistory([]);
|
||||
setShowConfetti(false);
|
||||
toast.success(t('practice.xp_earned', { xp: '10' }));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const error = err as { message?: string };
|
||||
if (error.message?.includes('429') || error.message?.includes('limit')) {
|
||||
toast.error(t('practice.rate_limit_reached'));
|
||||
} else {
|
||||
toast.error(t('practice.generation_error'));
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnswer = (answerIndex: number) => {
|
||||
if (showResult || !practiceQuestions[currentQuestion]) return;
|
||||
setSelectedAnswer(answerIndex);
|
||||
setShowResult(true);
|
||||
|
||||
const isCorrect = answerIndex === practiceQuestions[currentQuestion].correctAnswer;
|
||||
if (isCorrect) {
|
||||
setPracticeScore((s) => s + 1);
|
||||
}
|
||||
setAnswerHistory((prev) => [
|
||||
...prev,
|
||||
{ questionIndex: currentQuestion, selectedAnswer: answerIndex, isCorrect },
|
||||
]);
|
||||
};
|
||||
|
||||
const handleNextQuestion = () => {
|
||||
if (currentQuestion + 1 >= practiceQuestions.length) {
|
||||
setPracticeComplete(true);
|
||||
const pct = Math.round(((practiceScore + (selectedAnswer === practiceQuestions[currentQuestion]?.correctAnswer ? 0 : 0)) / practiceQuestions.length) * 100);
|
||||
if (pct >= 80) {
|
||||
setShowConfetti(true);
|
||||
setTimeout(() => setShowConfetti(false), 4000);
|
||||
}
|
||||
} else {
|
||||
setCurrentQuestion((q) => q + 1);
|
||||
setSelectedAnswer(null);
|
||||
setShowResult(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetryIncorrect = () => {
|
||||
const incorrectQuestions = answerHistory
|
||||
.filter((h) => !h.isCorrect)
|
||||
.map((h) => practiceQuestions[h.questionIndex]);
|
||||
|
||||
if (incorrectQuestions.length === 0) {
|
||||
handleStartPractice();
|
||||
return;
|
||||
}
|
||||
|
||||
setPracticeQuestions(incorrectQuestions);
|
||||
setIsPracticing(true);
|
||||
setCurrentQuestion(0);
|
||||
setPracticeScore(0);
|
||||
setSelectedAnswer(null);
|
||||
setShowResult(false);
|
||||
setPracticeComplete(false);
|
||||
};
|
||||
|
||||
const handleAnswer = (answer: string) => {
|
||||
setSelectedAnswer(answer);
|
||||
setShowResult(true);
|
||||
// Simulate correct answer 50% of the time
|
||||
const isCorrect = Math.random() > 0.5;
|
||||
if (isCorrect) {
|
||||
setPracticeScore((s) => s + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextQuestion = () => {
|
||||
const totalQ = selectedTopic?.questionCount || 5;
|
||||
if (currentQuestion + 1 >= totalQ) {
|
||||
setPracticeComplete(true);
|
||||
} else {
|
||||
setCurrentQuestion((q) => q + 1);
|
||||
setSelectedAnswer(null);
|
||||
setShowResult(false);
|
||||
}
|
||||
setAnswerHistory([]);
|
||||
setShowConfetti(false);
|
||||
};
|
||||
|
||||
const handleSeedData = async () => {
|
||||
@@ -1086,7 +1193,36 @@ export default function SubjectsView() {
|
||||
return renderPractice();
|
||||
}
|
||||
|
||||
const totalQ = selectedTopic.questionCount || 5;
|
||||
if (isGenerating) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto space-y-6">
|
||||
<Card className="border-0 shadow-xl overflow-hidden">
|
||||
<div className="bg-gradient-to-br from-emerald-500 to-teal-600 p-8 text-center">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ repeat: Infinity, duration: 2, ease: 'linear' }}
|
||||
className="inline-block"
|
||||
>
|
||||
<Sparkles className="h-12 w-12 text-white" />
|
||||
</motion.div>
|
||||
<h2 className="text-xl font-bold text-white mt-4">
|
||||
{t('practice.generating')}
|
||||
</h2>
|
||||
<p className="text-emerald-100 mt-2 text-sm">
|
||||
{t('practice.generate_questions')}
|
||||
</p>
|
||||
</div>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-emerald-600" />
|
||||
<span className="text-sm text-gray-500">{t('practice.generating')}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
@@ -1116,16 +1252,22 @@ export default function SubjectsView() {
|
||||
<p className="text-emerald-100 mt-1">{selectedTopic.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Badge className="text-xs bg-white/20 text-white border-0 hover:bg-white/30">
|
||||
{totalQ} {t('subjects.question_count')}
|
||||
</Badge>
|
||||
<DifficultyBadge difficulty={selectedTopic.difficulty} />
|
||||
{selectedTopic.questionCount > 0 && (
|
||||
<Badge className="text-xs bg-white/20 text-white border-0 hover:bg-white/30">
|
||||
{selectedTopic.questionCount} {t('subjects.question_count')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button onClick={handleStartPractice} className="gap-1 bg-white text-emerald-700 hover:bg-emerald-50 shadow-md">
|
||||
<Play className="h-4 w-4" />
|
||||
{t('subjects.start_practice')}
|
||||
{t('practice.start')}
|
||||
</Button>
|
||||
<Button onClick={handleGenerateQuestions} variant="outline" className="gap-1 bg-white/20 border-white/30 text-white hover:bg-white/30 hover:text-white">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t('practice.generate_questions')}
|
||||
</Button>
|
||||
{isTeacher && (
|
||||
<Button
|
||||
@@ -1172,40 +1314,138 @@ export default function SubjectsView() {
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Confetti Particle ───────────────────────────────────────────── */
|
||||
|
||||
function ConfettiParticle({ delay, color }: { delay: number; color: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="absolute w-3 h-3 rounded-sm"
|
||||
style={{ backgroundColor: color, left: `${Math.random() * 100}%`, top: '-10px' }}
|
||||
initial={{ opacity: 1, y: 0, rotate: 0, scale: 1 }}
|
||||
animate={{
|
||||
opacity: [1, 1, 0],
|
||||
y: [0, window?.innerHeight || 600],
|
||||
rotate: [0, Math.random() * 720 - 360],
|
||||
scale: [1, 0.8, 0.3],
|
||||
x: [0, (Math.random() - 0.5) * 200],
|
||||
}}
|
||||
transition={{ duration: 2.5, delay, ease: 'easeOut' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Render: Practice Mode ───────────────────────────────────────── */
|
||||
|
||||
const renderPractice = () => {
|
||||
if (!selectedTopic) return null;
|
||||
const totalQ = selectedTopic.questionCount || 5;
|
||||
const totalQ = practiceQuestions.length;
|
||||
|
||||
if (practiceComplete) {
|
||||
const pct = Math.round((practiceScore / totalQ) * 100);
|
||||
if (totalQ === 0) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto space-y-6">
|
||||
<Card className="border-0 shadow-xl overflow-hidden">
|
||||
<div className="bg-gradient-to-br from-emerald-500 to-teal-600 p-6 text-center">
|
||||
<FileText className="h-16 w-16 text-white/80 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-bold text-white mb-2">
|
||||
{t('practice.no_questions')}
|
||||
</h2>
|
||||
<p className="text-emerald-100 text-sm">
|
||||
{t('practice.no_questions_desc')}
|
||||
</p>
|
||||
</div>
|
||||
<CardContent className="p-6 text-center space-y-3">
|
||||
<Button onClick={handleGenerateQuestions} className="w-full gap-1 bg-emerald-600 hover:bg-emerald-700">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t('practice.generate_questions')}
|
||||
</Button>
|
||||
<Button onClick={handleBack} variant="outline" className="w-full gap-1">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('subjects.back_to_subject')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (practiceComplete) {
|
||||
const pct = Math.round((practiceScore / totalQ) * 100);
|
||||
const incorrectCount = totalQ - practiceScore;
|
||||
const scoreMessage = pct === 100
|
||||
? t('practice.perfect_score')
|
||||
: pct >= 80
|
||||
? t('practice.great_score')
|
||||
: pct >= 50
|
||||
? t('practice.good_score')
|
||||
: t('practice.needs_practice');
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto space-y-6">
|
||||
{/* Confetti overlay */}
|
||||
<AnimatePresence>
|
||||
{showConfetti && (
|
||||
<motion.div
|
||||
className="fixed inset-0 pointer-events-none z-50 overflow-hidden"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
{Array.from({ length: 40 }).map((_, i) => (
|
||||
<ConfettiParticle
|
||||
key={i}
|
||||
delay={Math.random() * 0.8}
|
||||
color={['#10b981', '#fbbf24', '#ef4444', '#3b82f6', '#8b5cf6', '#ec4899'][i % 6]}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Card className="border-0 shadow-xl overflow-hidden">
|
||||
<div className="bg-gradient-to-br from-emerald-500 to-teal-600 p-6 text-center relative">
|
||||
{pct >= 80 && (
|
||||
<motion.div
|
||||
className="absolute top-2 right-4"
|
||||
animate={{ scale: [1, 1.2, 1], rotate: [0, 10, -10, 0] }}
|
||||
transition={{ repeat: Infinity, duration: 2 }}
|
||||
>
|
||||
<PartyPopper className="h-8 w-8 text-yellow-300" />
|
||||
</motion.div>
|
||||
)}
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: 'spring', duration: 0.8, stiffness: 200 }}
|
||||
>
|
||||
<Award className="h-20 w-20 text-white mx-auto mb-4 drop-shadow-lg" />
|
||||
{pct >= 80 ? (
|
||||
<Trophy className="h-20 w-20 text-yellow-300 mx-auto mb-4 drop-shadow-lg" />
|
||||
) : (
|
||||
<Award className="h-20 w-20 text-white mx-auto mb-4 drop-shadow-lg" />
|
||||
)}
|
||||
</motion.div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">
|
||||
{t('subjects.practice_complete')}
|
||||
{t('practice.results')}
|
||||
</h2>
|
||||
<p className="text-emerald-100">
|
||||
{t('subjects.score')}: {practiceScore}/{totalQ} ({pct}%)
|
||||
<p className="text-emerald-100 text-lg">
|
||||
{practiceScore}/{totalQ} ({pct}%)
|
||||
</p>
|
||||
<motion.p
|
||||
className="text-white font-semibold mt-2"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
{scoreMessage}
|
||||
</motion.p>
|
||||
</div>
|
||||
<CardContent className="p-6 text-center">
|
||||
<CardContent className="p-6">
|
||||
{/* Circular progress */}
|
||||
<div className="relative w-32 h-32 mx-auto mb-4">
|
||||
<div className="relative w-32 h-32 mx-auto mb-6">
|
||||
<svg className="w-full h-full -rotate-90" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="40" fill="none" stroke="currentColor" className="text-gray-200 dark:text-gray-700" strokeWidth="8" />
|
||||
<motion.circle
|
||||
cx="50" cy="50" r="40" fill="none"
|
||||
stroke={pct >= 70 ? '#10b981' : pct >= 40 ? '#f59e0b' : '#ef4444'}
|
||||
stroke={pct >= 80 ? '#10b981' : pct >= 50 ? '#f59e0b' : '#ef4444'}
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 40}`}
|
||||
@@ -1225,12 +1465,88 @@ export default function SubjectsView() {
|
||||
</motion.span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button onClick={handleStartPractice} variant="outline" className="gap-1">
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
<div className="text-center p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20">
|
||||
<p className="text-2xl font-bold text-emerald-600">{practiceScore}</p>
|
||||
<p className="text-xs text-gray-500">{t('practice.correct_answers')}</p>
|
||||
</div>
|
||||
<div className="text-center p-3 rounded-lg bg-rose-50 dark:bg-rose-900/20">
|
||||
<p className="text-2xl font-bold text-rose-600">{incorrectCount}</p>
|
||||
<p className="text-xs text-gray-500">{t('practice.incorrect_answers')}</p>
|
||||
</div>
|
||||
<div className="text-center p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20">
|
||||
<p className="text-2xl font-bold text-amber-600">{pct}%</p>
|
||||
<p className="text-xs text-gray-500">{t('practice.percentage')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed breakdown */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||
<Info className="h-4 w-4 text-emerald-600" />
|
||||
{t('practice.detailed_breakdown')}
|
||||
</h3>
|
||||
<ScrollArea className="max-h-64">
|
||||
<div className="space-y-2">
|
||||
{answerHistory.map((entry, idx) => {
|
||||
const q = practiceQuestions[entry.questionIndex];
|
||||
if (!q) return null;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`p-3 rounded-lg border text-sm ${
|
||||
entry.isCorrect
|
||||
? 'border-emerald-200 bg-emerald-50/50 dark:border-emerald-800 dark:bg-emerald-900/10'
|
||||
: 'border-rose-200 bg-rose-50/50 dark:border-rose-800 dark:bg-rose-900/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{entry.isCorrect ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500 mt-0.5 shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-rose-500 mt-0.5 shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{q.question}
|
||||
</p>
|
||||
{!entry.isCorrect && (
|
||||
<div className="mt-1 text-xs space-y-0.5">
|
||||
<p className="text-rose-600 dark:text-rose-400">
|
||||
{t('practice.your_answer')}: {q.options[entry.selectedAnswer]}
|
||||
</p>
|
||||
<p className="text-emerald-600 dark:text-emerald-400">
|
||||
{t('practice.correct_answer')}: {q.options[q.correctAnswer]}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{q.explanation && (
|
||||
<p className="text-xs text-gray-500 mt-1">{q.explanation}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button onClick={handleStartPractice} className="gap-1 bg-emerald-600 hover:bg-emerald-700">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('subjects.start_practice')}
|
||||
{t('practice.retry')}
|
||||
</Button>
|
||||
<Button onClick={handleBack} className="gap-1 bg-emerald-600 hover:bg-emerald-700">
|
||||
{incorrectCount > 0 && (
|
||||
<Button onClick={handleRetryIncorrect} variant="outline" className="gap-1 border-emerald-300 text-emerald-700 hover:bg-emerald-50 dark:border-emerald-700 dark:text-emerald-400 dark:hover:bg-emerald-900/20">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
{t('practice.retry_incorrect')} ({incorrectCount})
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleBack} variant="ghost" className="gap-1">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('subjects.back_to_subject')}
|
||||
</Button>
|
||||
@@ -1241,8 +1557,10 @@ export default function SubjectsView() {
|
||||
);
|
||||
}
|
||||
|
||||
// Generate mock options for the current question
|
||||
const mockOptions = ['A', 'B', 'C', 'D'];
|
||||
const currentQ = practiceQuestions[currentQuestion];
|
||||
if (!currentQ) return null;
|
||||
|
||||
const optionLabels = ['A', 'B', 'C', 'D'];
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto space-y-6">
|
||||
@@ -1268,19 +1586,24 @@ export default function SubjectsView() {
|
||||
</div>
|
||||
{/* Question navigation dots */}
|
||||
<div className="flex items-center gap-1 mt-2 justify-center">
|
||||
{Array.from({ length: totalQ }).map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
i === currentQuestion
|
||||
? 'w-6 bg-emerald-500'
|
||||
: i < currentQuestion
|
||||
? 'w-2 bg-emerald-300 dark:bg-emerald-700'
|
||||
: 'w-2 bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
layout
|
||||
/>
|
||||
))}
|
||||
{practiceQuestions.map((_, i) => {
|
||||
const historyEntry = answerHistory.find((h) => h.questionIndex === i);
|
||||
return (
|
||||
<motion.div
|
||||
key={i}
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
i === currentQuestion
|
||||
? 'w-6 bg-emerald-500'
|
||||
: historyEntry
|
||||
? historyEntry.isCorrect
|
||||
? 'w-2 bg-emerald-400'
|
||||
: 'w-2 bg-rose-400'
|
||||
: 'w-2 bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
layout
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1289,21 +1612,21 @@ export default function SubjectsView() {
|
||||
<Card className="border-emerald-200 dark:border-emerald-800">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
{selectedTopic.title} - Frage {currentQuestion + 1}
|
||||
{t('practice.question')} {currentQuestion + 1}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Was ist die richtige Antwort auf diese Frage zum Thema {selectedTopic.title}?
|
||||
{currentQ.question}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{mockOptions.map((option) => {
|
||||
const isSelected = selectedAnswer === option;
|
||||
const isCorrect = showResult && option === 'A';
|
||||
const isWrong = showResult && isSelected && option !== 'A';
|
||||
{currentQ.options.map((option, idx) => {
|
||||
const isSelected = selectedAnswer === idx;
|
||||
const isCorrect = showResult && idx === currentQ.correctAnswer;
|
||||
const isWrong = showResult && isSelected && idx !== currentQ.correctAnswer;
|
||||
return (
|
||||
<motion.button
|
||||
key={option}
|
||||
onClick={() => !showResult && handleAnswer(option)}
|
||||
key={idx}
|
||||
onClick={() => !showResult && handleAnswer(idx)}
|
||||
className={`w-full text-left p-4 rounded-lg border-2 transition-all ${
|
||||
isCorrect
|
||||
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20'
|
||||
@@ -1317,12 +1640,18 @@ export default function SubjectsView() {
|
||||
whileTap={!showResult ? { scale: 0.99 } : {}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-sm font-medium">
|
||||
{option}
|
||||
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
isCorrect
|
||||
? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'
|
||||
: isWrong
|
||||
? 'bg-rose-100 dark:bg-rose-900/40 text-rose-700 dark:text-rose-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800'
|
||||
}`}>
|
||||
{optionLabels[idx]}
|
||||
</span>
|
||||
<span className="text-sm">Antwortmoeglichkeit {option}</span>
|
||||
{isCorrect && <Check className="h-4 w-4 text-emerald-500 ml-auto" />}
|
||||
{isWrong && <X className="h-4 w-4 text-rose-500 ml-auto" />}
|
||||
<span className="text-sm flex-1">{option}</span>
|
||||
{isCorrect && <Check className="h-4 w-4 text-emerald-500 ml-auto shrink-0" />}
|
||||
{isWrong && <X className="h-4 w-4 text-rose-500 ml-auto shrink-0" />}
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
@@ -1333,24 +1662,29 @@ export default function SubjectsView() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-4"
|
||||
className="mt-4 space-y-3"
|
||||
>
|
||||
<div className={`p-3 rounded-lg text-sm ${
|
||||
selectedAnswer === 'A'
|
||||
selectedAnswer === currentQ.correctAnswer
|
||||
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300'
|
||||
: 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300'
|
||||
}`}>
|
||||
{selectedAnswer === 'A'
|
||||
? t('subjects.correct_answer')
|
||||
: t('subjects.wrong_answer')}
|
||||
<p className="font-medium">
|
||||
{selectedAnswer === currentQ.correctAnswer
|
||||
? t('practice.correct')
|
||||
: t('practice.incorrect')}
|
||||
</p>
|
||||
{currentQ.explanation && (
|
||||
<p className="mt-1 text-xs opacity-80">{currentQ.explanation}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleNextQuestion}
|
||||
className="mt-3 w-full bg-emerald-600 hover:bg-emerald-700"
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
{currentQuestion + 1 >= totalQ
|
||||
? t('subjects.finish_practice')
|
||||
: t('subjects.next_question')}
|
||||
? t('practice.finish')
|
||||
: t('practice.next_question')}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user