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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user