feat: Implement student notes functionality for lessons, including API endpoints, database schema, and frontend UI.
This commit is contained in:
@@ -19,9 +19,9 @@ import MemoryPlayer from "@/components/blocks/MemoryPlayer";
|
||||
import DocumentPlayer from "@/components/blocks/DocumentPlayer";
|
||||
import AudioResponsePlayer from "@/components/blocks/AudioResponsePlayer";
|
||||
import InteractiveTranscript from "@/components/InteractiveTranscript";
|
||||
import AITutor from "@/components/AITutor";
|
||||
import LessonLockedView from "@/components/LessonLockedView";
|
||||
import { ListMusic } from "lucide-react";
|
||||
import StudentNotes from "@/components/StudentNotes";
|
||||
import { ListMusic, StickyNote } from "lucide-react";
|
||||
|
||||
export default function LessonPlayerPage({ params }: { params: { id: string, lessonId: string } }) {
|
||||
const [lesson, setLesson] = useState<Lesson | null>(null);
|
||||
@@ -29,6 +29,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [transcriptOpen, setTranscriptOpen] = useState(true);
|
||||
const [notesOpen, setNotesOpen] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [userGrade, setUserGrade] = useState<UserGrade | null>(null);
|
||||
const [allGrades, setAllGrades] = useState<UserGrade[]>([]);
|
||||
@@ -214,13 +215,26 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
||||
</button>
|
||||
{hasTranscription && (
|
||||
<button
|
||||
onClick={() => setTranscriptOpen(!transcriptOpen)}
|
||||
onClick={() => {
|
||||
setTranscriptOpen(!transcriptOpen);
|
||||
if (!transcriptOpen) setNotesOpen(false);
|
||||
}}
|
||||
className={`p-3 rounded-xl glass border-white/10 transition-all bg-black/40 ${transcriptOpen ? 'text-blue-400' : 'text-gray-400 hover:text-white'}`}
|
||||
title="Alternar Transcripción"
|
||||
>
|
||||
<ListMusic size={20} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setNotesOpen(!notesOpen);
|
||||
if (!notesOpen) setTranscriptOpen(false);
|
||||
}}
|
||||
className={`p-3 rounded-xl glass border-white/10 transition-all bg-black/40 ${notesOpen ? 'text-indigo-400' : 'text-gray-400 hover:text-white'}`}
|
||||
title="Alternar Notas"
|
||||
>
|
||||
<StickyNote size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
@@ -458,14 +472,43 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive Transcript Panel */}
|
||||
{hasTranscription && transcriptOpen && (
|
||||
<aside className="w-[400px] border-l border-white/5 bg-black/20 animate-in slide-in-from-right duration-500">
|
||||
<InteractiveTranscript
|
||||
transcription={lesson.transcription!}
|
||||
currentTime={currentTime}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
{/* Right Side Panels */}
|
||||
{((hasTranscription && transcriptOpen) || notesOpen) && (
|
||||
<aside className="w-[400px] border-l border-white/5 bg-black/20 flex flex-col animate-in slide-in-from-right duration-500">
|
||||
{/* Panel Tabs */}
|
||||
<div className="flex border-b border-white/5">
|
||||
{hasTranscription && (
|
||||
<button
|
||||
onClick={() => setTranscriptOpen(true)}
|
||||
className={`flex-1 py-3 text-[10px] font-black uppercase tracking-widest transition-all ${transcriptOpen ? 'text-blue-400 bg-white/5' : 'text-gray-500 hover:text-gray-300'}`}
|
||||
>
|
||||
Transcripción
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setNotesOpen(true);
|
||||
if (hasTranscription) setTranscriptOpen(false);
|
||||
}}
|
||||
className={`flex-1 py-3 text-[10px] font-black uppercase tracking-widest transition-all ${notesOpen && (!transcriptOpen || !hasTranscription) ? 'text-indigo-400 bg-white/5' : 'text-gray-500 hover:text-gray-300'}`}
|
||||
>
|
||||
Notas
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{notesOpen && (!transcriptOpen || !hasTranscription) ? (
|
||||
<StudentNotes lessonId={params.lessonId} />
|
||||
) : (
|
||||
hasTranscription && (
|
||||
<InteractiveTranscript
|
||||
transcription={lesson.transcription!}
|
||||
currentTime={currentTime}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
@@ -508,7 +551,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
||||
|
||||
{/* AI Tutor Bubble/Panel */}
|
||||
<AITutor lessonId={params.lessonId} />
|
||||
</main>
|
||||
</div>
|
||||
</main >
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
const isAuthPage = pathname?.startsWith("/auth");
|
||||
if (!user && !isAuthPage) {
|
||||
const isCatalogRoot = pathname === "/";
|
||||
if (!user && !isAuthPage && !isCatalogRoot) {
|
||||
router.push("/auth/login");
|
||||
} else if (user && isAuthPage) {
|
||||
router.push("/");
|
||||
@@ -29,7 +30,8 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
const isAuthPage = pathname?.startsWith("/auth");
|
||||
if (!user && !isAuthPage) {
|
||||
const isCatalogRoot = pathname === "/";
|
||||
if (!user && !isAuthPage && !isCatalogRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { lmsApi, StudentNote } from "@/lib/api";
|
||||
import { StickyNote, Save, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import debounce from "lodash/debounce";
|
||||
|
||||
interface StudentNotesProps {
|
||||
lessonId: string;
|
||||
}
|
||||
|
||||
export default function StudentNotes({ lessonId }: StudentNotesProps) {
|
||||
const [note, setNote] = useState<string>("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchNote = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await lmsApi.getNote(lessonId);
|
||||
if (data) {
|
||||
setNote(data.content);
|
||||
} else {
|
||||
setNote("");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch note:", err);
|
||||
setError("No se pudo cargar tu nota.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchNote();
|
||||
}, [lessonId]);
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce(async (content: string) => {
|
||||
if (!content.trim() && note === "") return;
|
||||
try {
|
||||
setSaving(true);
|
||||
await lmsApi.saveNote(lessonId, content);
|
||||
setLastSaved(new Date());
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to save note:", err);
|
||||
setError("Error al auto-guardar.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, 1000),
|
||||
[lessonId, note]
|
||||
);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newContent = e.target.value;
|
||||
setNote(newContent);
|
||||
debouncedSave(newContent);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-gray-500">
|
||||
<Loader2 className="w-6 h-6 animate-spin mb-2" />
|
||||
<span className="text-xs font-bold uppercase tracking-widest">Cargando tus notas...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-card flex flex-col h-full bg-white/[0.02] border-white/5 overflow-hidden rounded-2xl">
|
||||
<div className="p-4 border-b border-white/5 flex items-center justify-between bg-white/[0.03]">
|
||||
<div className="flex items-center gap-2">
|
||||
<StickyNote size={18} className="text-indigo-400" />
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-white">Notas Personales</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{saving ? (
|
||||
<span className="flex items-center gap-1 text-[10px] text-gray-400 font-bold uppercase tracking-widest animate-pulse">
|
||||
<Loader2 size={12} className="animate-spin" /> Guardando...
|
||||
</span>
|
||||
) : error ? (
|
||||
<span className="flex items-center gap-1 text-[10px] text-red-400 font-bold uppercase tracking-widest">
|
||||
<AlertCircle size={12} /> {error}
|
||||
</span>
|
||||
) : lastSaved ? (
|
||||
<span className="flex items-center gap-1 text-[10px] text-green-400 font-bold uppercase tracking-widest">
|
||||
<CheckCircle2 size={12} /> Guardado {lastSaved.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={handleChange}
|
||||
placeholder="Escribe tus apuntes aquí... Se guardan automáticamente."
|
||||
className="flex-1 w-full p-6 bg-transparent text-gray-200 text-sm leading-relaxed focus:outline-none resize-none placeholder:text-gray-600 custom-scrollbar"
|
||||
/>
|
||||
|
||||
<div className="p-3 bg-white/[0.01] border-t border-white/5 text-[10px] text-gray-500 font-medium text-center italic">
|
||||
Solo tú puedes ver estas notas.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -198,6 +198,15 @@ export interface Module {
|
||||
lessons: Lesson[];
|
||||
}
|
||||
|
||||
export interface StudentNote {
|
||||
id: string;
|
||||
user_id: string;
|
||||
lesson_id: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Discussion Forums Types
|
||||
export interface DiscussionThread {
|
||||
id: string;
|
||||
@@ -577,5 +586,14 @@ export const lmsApi = {
|
||||
return apiFetch(`/announcements/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
},
|
||||
async getNote(lessonId: string): Promise<StudentNote | null> {
|
||||
return apiFetch(`/lessons/${lessonId}/notes`);
|
||||
},
|
||||
async saveNote(lessonId: string, content: string): Promise<StudentNote> {
|
||||
return apiFetch(`/lessons/${lessonId}/notes`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ content })
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user