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:
Mustafa Cetin committed 2026-07-29 21:01:32 +00:00
1 parent 2f266a2143
commit 44ff170fca
12 files changed
+3655 -476

No files matched your search

+472 -126
View File
@@ -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>
);
}