trading-platform-frontend-v2/src/modules/education/components/LessonNotes.tsx
Adrian Flores Cortes fc0ab528c3 [OQI-002] feat: Add education UX components
- CertificatePreview: Display earned certificates with download/share
- CourseReviews: Reviews section with rating summary and filtering
- LessonNotes: Note-taking with timestamps and auto-save
- RecommendedCourses: Course recommendations with multiple layouts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 10:27:25 -06:00

342 lines
12 KiB
TypeScript

/**
* LessonNotes Component
* Note-taking interface for lessons with auto-save
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
StickyNote,
Save,
Clock,
Trash2,
Download,
ChevronDown,
ChevronUp,
BookmarkPlus,
CheckCircle,
Loader2,
} from 'lucide-react';
interface Note {
id: string;
lessonId: string;
content: string;
timestamp?: number; // Video timestamp in seconds
createdAt: string;
updatedAt: string;
}
interface LessonNotesProps {
lessonId: string;
courseId: string;
notes: Note[];
currentVideoTime?: number;
onSaveNote: (content: string, timestamp?: number) => Promise<void>;
onUpdateNote: (noteId: string, content: string) => Promise<void>;
onDeleteNote: (noteId: string) => Promise<void>;
onSeekToTimestamp?: (timestamp: number) => void;
onExportNotes?: () => void;
collapsed?: boolean;
onToggleCollapse?: () => void;
}
const LessonNotes: React.FC<LessonNotesProps> = ({
lessonId,
courseId,
notes,
currentVideoTime,
onSaveNote,
onUpdateNote,
onDeleteNote,
onSeekToTimestamp,
onExportNotes,
collapsed = false,
onToggleCollapse,
}) => {
const [newNote, setNewNote] = useState('');
const [editingNoteId, setEditingNoteId] = useState<string | null>(null);
const [editContent, setEditContent] = useState('');
const [saving, setSaving] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [includeTimestamp, setIncludeTimestamp] = useState(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-resize textarea
const adjustTextareaHeight = useCallback((textarea: HTMLTextAreaElement) => {
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
}, []);
useEffect(() => {
if (textareaRef.current) {
adjustTextareaHeight(textareaRef.current);
}
}, [newNote, adjustTextareaHeight]);
const formatTimestamp = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const handleSaveNote = async () => {
if (!newNote.trim()) return;
setSaving(true);
try {
const timestamp = includeTimestamp && currentVideoTime ? Math.floor(currentVideoTime) : undefined;
await onSaveNote(newNote.trim(), timestamp);
setNewNote('');
setLastSaved(new Date());
} catch (error) {
console.error('Error saving note:', error);
} finally {
setSaving(false);
}
};
const handleUpdateNote = async (noteId: string) => {
if (!editContent.trim()) return;
setSaving(true);
try {
await onUpdateNote(noteId, editContent.trim());
setEditingNoteId(null);
setEditContent('');
setLastSaved(new Date());
} catch (error) {
console.error('Error updating note:', error);
} finally {
setSaving(false);
}
};
const handleDeleteNote = async (noteId: string) => {
if (!confirm('Estas seguro de eliminar esta nota?')) return;
try {
await onDeleteNote(noteId);
} catch (error) {
console.error('Error deleting note:', error);
}
};
const startEditing = (note: Note) => {
setEditingNoteId(note.id);
setEditContent(note.content);
};
const cancelEditing = () => {
setEditingNoteId(null);
setEditContent('');
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSaveNote();
}
};
// Sort notes by timestamp or creation date
const sortedNotes = [...notes].sort((a, b) => {
if (a.timestamp !== undefined && b.timestamp !== undefined) {
return a.timestamp - b.timestamp;
}
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
return (
<div className="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
{/* Header */}
<button
onClick={onToggleCollapse}
className="w-full flex items-center justify-between p-4 hover:bg-gray-700/50 transition-colors"
>
<div className="flex items-center gap-3">
<StickyNote className="w-5 h-5 text-yellow-400" />
<span className="font-medium text-white">Mis Notas</span>
<span className="px-2 py-0.5 bg-gray-700 text-gray-400 text-xs rounded-full">
{notes.length}
</span>
</div>
<div className="flex items-center gap-2">
{lastSaved && (
<span className="text-xs text-gray-500 flex items-center gap-1">
<CheckCircle className="w-3 h-3 text-green-400" />
Guardado
</span>
)}
{collapsed ? (
<ChevronDown className="w-5 h-5 text-gray-400" />
) : (
<ChevronUp className="w-5 h-5 text-gray-400" />
)}
</div>
</button>
{!collapsed && (
<div className="p-4 pt-0 space-y-4">
{/* New Note Input */}
<div className="space-y-3">
<div className="relative">
<textarea
ref={textareaRef}
value={newNote}
onChange={(e) => {
setNewNote(e.target.value);
adjustTextareaHeight(e.target);
}}
onKeyDown={handleKeyDown}
placeholder="Escribe una nota... (Ctrl+Enter para guardar)"
className="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 resize-none focus:outline-none focus:border-blue-500 min-h-[80px]"
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{currentVideoTime !== undefined && (
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={includeTimestamp}
onChange={(e) => setIncludeTimestamp(e.target.checked)}
className="rounded border-gray-600 bg-gray-700 text-blue-500 focus:ring-blue-500"
/>
<Clock className="w-4 h-4" />
<span>Incluir timestamp ({formatTimestamp(currentVideoTime)})</span>
</label>
)}
</div>
<div className="flex items-center gap-2">
{onExportNotes && notes.length > 0 && (
<button
onClick={onExportNotes}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
title="Exportar notas"
>
<Download className="w-4 h-4" />
</button>
)}
<button
onClick={handleSaveNote}
disabled={!newNote.trim() || saving}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<BookmarkPlus className="w-4 h-4" />
)}
Guardar Nota
</button>
</div>
</div>
</div>
{/* Notes List */}
{sortedNotes.length > 0 && (
<div className="space-y-3 pt-4 border-t border-gray-700">
{sortedNotes.map((note) => (
<div
key={note.id}
className="p-3 bg-gray-900/50 rounded-lg group"
>
{editingNoteId === note.id ? (
<div className="space-y-2">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white resize-none focus:outline-none focus:border-blue-500 min-h-[60px]"
autoFocus
/>
<div className="flex items-center justify-end gap-2">
<button
onClick={cancelEditing}
className="px-3 py-1.5 text-gray-400 hover:text-white text-sm transition-colors"
>
Cancelar
</button>
<button
onClick={() => handleUpdateNote(note.id)}
disabled={!editContent.trim() || saving}
className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm transition-colors disabled:opacity-50"
>
{saving && <Loader2 className="w-3 h-3 animate-spin" />}
Guardar
</button>
</div>
</div>
) : (
<>
{/* Timestamp */}
{note.timestamp !== undefined && onSeekToTimestamp && (
<button
onClick={() => onSeekToTimestamp(note.timestamp!)}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded mb-2 hover:bg-blue-500/30 transition-colors"
>
<Clock className="w-3 h-3" />
{formatTimestamp(note.timestamp)}
</button>
)}
{/* Note Content */}
<p className="text-gray-300 text-sm whitespace-pre-wrap">
{note.content}
</p>
{/* Actions */}
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-700/50">
<span className="text-xs text-gray-500">
{new Date(note.updatedAt).toLocaleDateString('es-ES', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => startEditing(note)}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Editar"
>
<Save className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDeleteNote(note.id)}
className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-gray-700 rounded transition-colors"
title="Eliminar"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</>
)}
</div>
))}
</div>
)}
{/* Empty State */}
{notes.length === 0 && (
<div className="text-center py-8">
<StickyNote className="w-10 h-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500 text-sm">
Aun no tienes notas para esta leccion
</p>
<p className="text-gray-600 text-xs mt-1">
Escribe arriba para agregar tu primera nota
</p>
</div>
)}
</div>
)}
</div>
);
};
export default LessonNotes;