- 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>
342 lines
12 KiB
TypeScript
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;
|