Files
schulos/src/components/communication-view.tsx
T
2026-08-02 12:53:19 +02:00

1250 lines
46 KiB
TypeScript

// @ts-nocheck
'use client';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
MessageSquare,
Plus,
Send,
Clock,
CheckCircle2,
XCircle,
Phone,
Paperclip,
Mic,
MicOff,
StickyNote,
FileText,
User,
Shield,
Eye,
X,
MessageCircle,
ChevronRight,
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';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
import { toast } from 'sonner';
interface CommunicationRoom {
id: string;
schoolId: string;
studentId: string;
teacherId: string;
roomType: string;
status: string;
requestedBy: string;
acceptedAt: string | null;
closedAt: string | null;
closeReason: string | null;
createdAt: string;
updatedAt: string;
student?: { id: string; firstName: string; lastName: string };
teacher?: { id: string; firstName: string; lastName: string };
messages?: Array<{
id: string;
content: string;
messageType: string;
createdAt: string;
senderId: string;
}>;
}
interface Message {
id: string;
roomId: string;
senderId: string;
content: string;
messageType: string;
fileUrl: string | null;
metadata: string | null;
isRead: boolean;
createdAt: string;
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[]>([]);
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showRequestDialog, setShowRequestDialog] = useState(false);
const fetchRooms = useCallback(async () => {
try {
const data = await apiGet<CommunicationRoom[]>('/api/communication-rooms');
setRooms(data);
} catch (error) {
console.error('Failed to fetch rooms:', error);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchRooms();
const interval = setInterval(fetchRooms, 5000);
return () => {
clearInterval(interval);
};
}, [fetchRooms]);
const role = currentUser?.role;
if (isLoading) {
return (
<div className="flex items-center justify-center h-96">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
);
}
const requestedRooms = rooms.filter((r) => r.status === 'requested');
const activeRooms = rooms.filter((r) => r.status === 'active');
const closedRooms = rooms.filter((r) => r.status === 'closed');
return (
<div className="p-4 md:p-6 h-[calc(100vh-4rem)] flex flex-col max-w-7xl mx-auto">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4"
>
<div>
<h1 className="text-2xl md:text-3xl font-bold text-foreground flex items-center gap-2">
<MessageSquare className="h-7 w-7 text-emerald-500" />
{t('communication.title')}
</h1>
<p className="text-muted-foreground mt-1">
{role === 'STUDENT' && t('communication.active_conversations')}
{role === 'TEACHER' && t('communication.incoming_requests')}
{(role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') && t('communication.all_rooms')}
</p>
</div>
{role === 'STUDENT' && (
<Button
onClick={() => setShowRequestDialog(true)}
className="bg-emerald-500 hover:bg-emerald-600 text-white"
>
<Plus className="h-4 w-4 mr-2" />
{t('communication.request_conversation')}
</Button>
)}
</motion.div>
{/* Main Layout: Room List + Chat */}
<div className="flex-1 flex gap-4 min-h-0">
{/* Room List */}
<div className="w-full md:w-80 shrink-0 flex flex-col gap-4">
{/* Pending Requests (Teacher) */}
{role === 'TEACHER' && requestedRooms.length > 0 && (
<Card className="border-amber-200 dark:border-amber-800">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Clock className="h-4 w-4 text-amber-500" />
{t('communication.pending_requests')} ({requestedRooms.length})
</CardTitle>
</CardHeader>
<CardContent className="p-2 pt-0">
<div className="space-y-1">
{requestedRooms.map((room) => (
<RoomListItem
key={room.id}
room={room}
isSelected={selectedRoomId === room.id}
onClick={() => setSelectedRoomId(room.id)}
currentUserId={currentUser?.id || ''}
role={role}
/>
))}
</div>
</CardContent>
</Card>
)}
{/* Active Rooms */}
<Card className="flex-1 min-h-0">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<MessageCircle className="h-4 w-4 text-emerald-500" />
{t('communication.active_conversations')} ({activeRooms.length})
</CardTitle>
</CardHeader>
<CardContent className="p-2 pt-0 overflow-y-auto max-h-96">
{activeRooms.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">{t('communication.no_conversations')}</p>
) : (
<div className="space-y-1">
{activeRooms.map((room) => (
<RoomListItem
key={room.id}
room={room}
isSelected={selectedRoomId === room.id}
onClick={() => setSelectedRoomId(room.id)}
currentUserId={currentUser?.id || ''}
role={role}
/>
))}
</div>
)}
</CardContent>
</Card>
{/* Closed Rooms */}
{closedRooms.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<XCircle className="h-4 w-4 text-muted-foreground" />
{t('communication.closed')} ({closedRooms.length})
</CardTitle>
</CardHeader>
<CardContent className="p-2 pt-0 overflow-y-auto max-h-32">
<div className="space-y-1">
{closedRooms.map((room) => (
<RoomListItem
key={room.id}
room={room}
isSelected={selectedRoomId === room.id}
onClick={() => setSelectedRoomId(room.id)}
currentUserId={currentUser?.id || ''}
role={role}
/>
))}
</div>
</CardContent>
</Card>
)}
</div>
{/* Chat Area */}
<div className="hidden md:flex flex-1 min-h-0">
{selectedRoomId ? (
<ChatArea
roomId={selectedRoomId}
currentUserId={currentUser?.id || ''}
role={role}
onRefresh={fetchRooms}
/>
) : (
<Card className="flex-1 flex items-center justify-center">
<div className="text-center">
<MessageSquare className="h-12 w-12 text-muted-foreground/30 mx-auto mb-4" />
<p className="text-muted-foreground">{t('communication.select_conversation')}</p>
</div>
</Card>
)}
</div>
</div>
{/* Mobile: Show chat as full-screen overlay */}
<AnimatePresence>
{selectedRoomId && (
<div className="md:hidden fixed inset-0 z-50 bg-background">
<ChatArea
roomId={selectedRoomId}
currentUserId={currentUser?.id || ''}
role={role}
onRefresh={fetchRooms}
isMobile
onClose={() => setSelectedRoomId(null)}
/>
</div>
)}
</AnimatePresence>
{/* Request Dialog */}
<RequestConversationDialog
open={showRequestDialog}
onOpenChange={setShowRequestDialog}
onCreated={fetchRooms}
/>
</div>
);
}
function RoomListItem({
room,
isSelected,
onClick,
currentUserId,
role,
}: {
room: CommunicationRoom;
isSelected: boolean;
onClick: () => void;
currentUserId: string;
role: string | undefined;
}) {
const isStudent = room.studentId === currentUserId;
const otherPerson = isStudent ? room.teacher : room.student;
const lastMessage = room.messages?.[0];
const getStatusColor = () => {
switch (room.status) {
case 'requested': return 'bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300';
case 'active': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300';
case 'closed': return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
default: return '';
}
};
const getStatusLabel = () => {
switch (room.status) {
case 'requested': return t('communication.requested');
case 'active': return t('communication.active');
case 'closed': return t('communication.closed');
default: return room.status;
}
};
return (
<motion.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
onClick={onClick}
className={`w-full text-left p-3 rounded-lg transition-colors ${
isSelected
? 'bg-emerald-50 dark:bg-emerald-950/30 border border-emerald-200 dark:border-emerald-800'
: 'hover:bg-muted/50'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<div className="relative shrink-0">
<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>
<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'
}`} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{otherPerson ? `${otherPerson.firstName} ${otherPerson.lastName}` : 'Unknown'}
</p>
{lastMessage && (
<p className="text-xs text-muted-foreground truncate">{lastMessage.content}</p>
)}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{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}
</span>
)}
<Badge variant="secondary" className={`text-xs shrink-0 ${getStatusColor()}`}>
{getStatusLabel()}
</Badge>
</div>
</div>
</motion.button>
);
}
function ChatArea({
roomId,
currentUserId,
role,
onRefresh,
isMobile,
onClose,
}: {
roomId: string;
currentUserId: string;
role: string | undefined;
onRefresh: () => void;
isMobile?: boolean;
onClose?: () => void;
}) {
const [messages, setMessages] = useState<Message[]>([]);
const [newMessage, setNewMessage] = useState('');
const [isLoadingMessages, setIsLoadingMessages] = useState(true);
const [isSending, setIsSending] = useState(false);
const [room, setRoom] = useState<CommunicationRoom | null>(null);
const [isRecording, setIsRecording] = useState(false);
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 {
const data = await apiGet<{ messages: Message[]; total: number }>(`/api/communication-rooms/${roomId}/messages`);
setMessages(data.messages);
} catch (error) {
console.error('Failed to fetch messages:', error);
} finally {
setIsLoadingMessages(false);
}
}, [roomId]);
const fetchRoom = useCallback(async () => {
try {
const data = await apiGet<CommunicationRoom>(`/api/communication-rooms/${roomId}`);
setRoom(data);
} catch (error) {
console.error('Failed to fetch room:', error);
}
}, [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, fetchTeacherNotes]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
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,
messageType,
metadata: metadata || null,
});
setNewMessage('');
fetchMessages();
onRefresh();
} catch (error) {
toast.error(t('communication.error_send'));
} finally {
setIsSending(false);
}
};
const handleAccept = async () => {
try {
await apiPut(`/api/communication-rooms/${roomId}`, { status: 'active' });
toast.success(t('communication.conversation_accepted'));
fetchRoom();
onRefresh();
} catch (error) {
toast.error('Error accepting conversation');
}
};
const handleReject = async () => {
try {
await apiPut(`/api/communication-rooms/${roomId}`, { status: 'rejected' });
toast.success(t('communication.conversation_rejected'));
onRefresh();
} catch (error) {
toast.error('Error rejecting conversation');
}
};
const handleClose = async () => {
try {
await apiPut(`/api/communication-rooms/${roomId}`, { status: 'closed', closeReason: 'Closed by teacher' });
toast.success(t('communication.conversation_closed'));
fetchRoom();
onRefresh();
} catch (error) {
toast.error('Error closing conversation');
}
};
// Voice recording with Web Audio API
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
audioChunksRef.current.push(event.data);
};
mediaRecorder.onstop = async () => {
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',
});
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(t('communication.voice_recording_error'));
}
};
const stopRecording = () => {
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');
}
};
const isStudent = room?.studentId === currentUserId;
const otherPerson = isStudent ? room?.teacher : room?.student;
const isAdmin = role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL' || role === 'SUPER_ADMIN';
const formatTime = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
const time = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
if (isToday) return `${t('communication.today')} ${time}`;
if (isYesterday) return `${t('communication.yesterday')} ${time}`;
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 */}
<div className="p-4 border-b flex items-center justify-between bg-gradient-to-r from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20">
<div className="flex items-center gap-3">
{isMobile && onClose && (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
)}
<div className="relative">
<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>
<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'
}`} />
</div>
<div>
<p className="font-medium">
{otherPerson ? `${otherPerson.firstName} ${otherPerson.lastName}` : 'Unknown'}
</p>
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={`text-xs ${
room?.status === 'active' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' :
room?.status === 'requested' ? 'bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300' :
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
}`}
>
{room?.status === 'requested' ? t('communication.requested') :
room?.status === 'active' ? t('communication.active') :
t('communication.closed')}
</Badge>
{isAdmin && (
<Badge variant="outline" className="text-xs">
<Eye className="h-3 w-3 mr-1" />
{t('communication.read_only')}
</Badge>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1">
{role === 'TEACHER' && room?.status === 'requested' && (
<>
<Button size="sm" className="bg-emerald-500 hover:bg-emerald-600 text-white h-8" onClick={handleAccept}>
<CheckCircle2 className="h-3.5 w-3.5 mr-1" />
{t('communication.accept')}
</Button>
<Button size="sm" variant="outline" className="h-8 text-rose-600" onClick={handleReject}>
<XCircle className="h-3.5 w-3.5 mr-1" />
{t('communication.reject')}
</Button>
</>
)}
{role === 'TEACHER' && room?.status === 'active' && (
<>
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowTeacherNotes(!showTeacherNotes)}>
<StickyNote className="h-3.5 w-3.5 mr-1" />
{t('communication.teacher_notes')}
</Button>
<Button variant="outline" size="sm" className="h-8 text-rose-600" onClick={handleClose}>
<XCircle className="h-3.5 w-3.5 mr-1" />
{t('communication.close')}
</Button>
</>
)}
</div>
</div>
{/* Teacher Notes Panel */}
<AnimatePresence>
{showTeacherNotes && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="border-b bg-amber-50 dark:bg-amber-950/20 p-4"
>
<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>
{/* Typing indicator */}
<AnimatePresence>
{isSending && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="px-4 py-2 border-b"
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<motion.div className="h-2 w-2 rounded-full bg-emerald-500" animate={{ y: [0, -4, 0] }} transition={{ repeat: Infinity, duration: 0.6, delay: 0 }} />
<motion.div className="h-2 w-2 rounded-full bg-emerald-500" animate={{ y: [0, -4, 0] }} transition={{ repeat: Infinity, duration: 0.6, delay: 0.15 }} />
<motion.div className="h-2 w-2 rounded-full bg-emerald-500" animate={{ y: [0, -4, 0] }} transition={{ repeat: Infinity, duration: 0.6, delay: 0.3 }} />
</div>
<span className="text-xs text-muted-foreground">{t('communication.type_message')}</span>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Messages */}
<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" />
</div>
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<p>{t('communication.no_messages')}</p>
</div>
) : (
messages.map((msg, idx) => {
const isOwn = msg.senderId === currentUserId;
const isVoice = msg.messageType === 'voice';
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}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.02 }}
className={`flex ${isOwn ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-[80%] ${isOwn ? 'order-2' : 'order-1'}`}>
{!isOwn && msg.sender && (
<p className="text-xs text-muted-foreground mb-1 ml-1">
{msg.sender.firstName} {msg.sender.lastName}
</p>
)}
<div
className={`rounded-2xl px-4 py-2.5 shadow-sm ${
isOwn
? 'bg-gradient-to-br from-emerald-500 to-teal-600 text-white rounded-br-md'
: 'bg-white dark:bg-gray-800 rounded-bl-md border border-gray-100 dark:border-gray-700'
}`}
>
{isVoice && (
<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 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 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>}
</div>
<p className={`text-xs text-muted-foreground mt-1 ${isOwn ? 'text-right mr-1' : 'ml-1'}`}>
{formatTime(msg.createdAt)}
</p>
</div>
</motion.div>
);
})
)}
<div ref={messagesEndRef} />
</div>
{/* Message Input */}
{!isAdmin && room?.status === 'active' && (
<div className="p-4 border-t">
{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={() => {
fetchNotebooks();
setShowShareNotesDialog(true);
}}
title={t('communication.share_notes')}
>
<Notebook className="h-4 w-4" />
</Button>
{/* 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>
{/* 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>
)}
{/* Admin read-only notice */}
{isAdmin && (
<div className="p-4 border-t bg-muted/30 text-center">
<p className="text-sm text-muted-foreground flex items-center justify-center gap-2">
<Shield className="h-4 w-4" />
{t('communication.read_only')}
</p>
</div>
)}
{/* Room not active notice */}
{room?.status !== 'active' && !isAdmin && (
<div className="p-4 border-t bg-muted/30 text-center">
<p className="text-sm text-muted-foreground">
{room?.status === 'requested' && t('communication.requested')}
{room?.status === 'closed' && t('communication.closed')}
</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>
);
}
function RequestConversationDialog({
open,
onOpenChange,
onCreated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: () => void;
}) {
const [reason, setReason] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
setIsSubmitting(true);
try {
await apiPost('/api/communication-rooms', { reason });
toast.success(t('communication.request_sent'));
onOpenChange(false);
setReason('');
onCreated();
} catch (error) {
toast.error(t('communication.error_create'));
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-emerald-500" />
{t('communication.request_conversation')}
</DialogTitle>
<DialogDescription>
{t('communication.with_teacher')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>{t('communication.request_reason')}</Label>
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t('communication.optional_reason')}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('action.cancel')}</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting}
className="bg-emerald-500 hover:bg-emerald-600 text-white"
>
{isSubmitting ? <div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" /> : t('action.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}