feat: Implement student notes functionality for lessons, including API endpoints, database schema, and frontend UI.
This commit is contained in:
Regular → Executable
+1
-1
@@ -11,4 +11,4 @@ echo "location.reload();"
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Luego vuelve a hacer login con:"
|
echo "Luego vuelve a hacer login con:"
|
||||||
echo " Email: juan.allende@gmail.com"
|
echo " Email: juan.allende@gmail.com"
|
||||||
echo " Password: password123"
|
echo " Password: apoca11"
|
||||||
|
|||||||
+2
-2
@@ -23,7 +23,7 @@ services:
|
|||||||
DATABASE_URL: postgresql://user:password@db:5432/openccb_cms
|
DATABASE_URL: postgresql://user:password@db:5432/openccb_cms
|
||||||
JWT_SECRET: ${JWT_SECRET:-openccb_secret_key_2025_production}
|
JWT_SECRET: ${JWT_SECRET:-openccb_secret_key_2025_production}
|
||||||
NEXT_PUBLIC_CMS_API_URL: ${NEXT_PUBLIC_CMS_API_URL:-http://localhost:3001}
|
NEXT_PUBLIC_CMS_API_URL: ${NEXT_PUBLIC_CMS_API_URL:-http://localhost:3001}
|
||||||
RUST_LOG: info
|
RUST_LOG: debug
|
||||||
LMS_INTERNAL_URL: http://experience:3002
|
LMS_INTERNAL_URL: http://experience:3002
|
||||||
volumes:
|
volumes:
|
||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
@@ -46,7 +46,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://user:password@db:5432/openccb_lms
|
DATABASE_URL: postgresql://user:password@db:5432/openccb_lms
|
||||||
JWT_SECRET: ${JWT_SECRET:-openccb_secret_key_2025_production}
|
JWT_SECRET: ${JWT_SECRET:-openccb_secret_key_2025_production}
|
||||||
RUST_LOG: info
|
RUST_LOG: debug
|
||||||
NEXT_PUBLIC_LMS_API_URL: ${NEXT_PUBLIC_LMS_API_URL:-http://localhost:3002}
|
NEXT_PUBLIC_LMS_API_URL: ${NEXT_PUBLIC_LMS_API_URL:-http://localhost:3002}
|
||||||
NEXT_PUBLIC_CMS_API_URL: ${NEXT_PUBLIC_CMS_API_URL:-http://localhost:3001}
|
NEXT_PUBLIC_CMS_API_URL: ${NEXT_PUBLIC_CMS_API_URL:-http://localhost:3001}
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
|||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script para borrar y resetear las bases de datos de OpenCCB
|
||||||
|
# Uso: ./scripts/reset_db.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🛑 Deteniendo servicios..."
|
||||||
|
docker compose stop studio experience
|
||||||
|
|
||||||
|
echo "🧹 Borrando bases de datos..."
|
||||||
|
docker exec openccb-db-1 psql -U user -d openccb -c "DROP DATABASE IF EXISTS openccb_cms;"
|
||||||
|
docker exec openccb-db-1 psql -U user -d openccb -c "DROP DATABASE IF EXISTS openccb_lms;"
|
||||||
|
|
||||||
|
echo "🏗️ Recreando bases de datos..."
|
||||||
|
docker exec openccb-db-1 psql -U user -d openccb -c "CREATE DATABASE openccb_cms;"
|
||||||
|
docker exec openccb-db-1 psql -U user -d openccb -c "CREATE DATABASE openccb_lms;"
|
||||||
|
|
||||||
|
echo "🚀 Reiniciando servicios y aplicando migraciones..."
|
||||||
|
docker compose start studio experience
|
||||||
|
|
||||||
|
echo "✅ Base de datos reseteada exitosamente."
|
||||||
|
echo "Nota: Las migraciones se ejecutarán automáticamente al iniciar los servicios."
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Migration: Create Student Notes Table
|
||||||
|
-- Description: Allows students to save personal notes for each lesson.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS student_notes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, lesson_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for faster retrieval by user
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_student_notes_user_id ON student_notes(user_id);
|
||||||
|
-- Index for faster retrieval by lesson (useful if we ever want to see all notes for a lesson as an instructor, though not requested yet)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_student_notes_lesson_id ON student_notes(lesson_id);
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use common::auth::Claims;
|
||||||
|
use common::models::{SaveNotePayload, StudentNote};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub async fn get_note(
|
||||||
|
claims: Claims,
|
||||||
|
Path(lesson_id): Path<Uuid>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<Option<StudentNote>>, (StatusCode, String)> {
|
||||||
|
let note = sqlx::query_as::<_, StudentNote>(
|
||||||
|
"SELECT * FROM student_notes WHERE user_id = $1 AND lesson_id = $2",
|
||||||
|
)
|
||||||
|
.bind(claims.sub)
|
||||||
|
.bind(lesson_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(note))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_note(
|
||||||
|
claims: Claims,
|
||||||
|
Path(lesson_id): Path<Uuid>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<SaveNotePayload>,
|
||||||
|
) -> Result<Json<StudentNote>, (StatusCode, String)> {
|
||||||
|
let note = sqlx::query_as::<_, StudentNote>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO student_notes (user_id, lesson_id, content)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, lesson_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
content = EXCLUDED.content,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(claims.sub)
|
||||||
|
.bind(lesson_id)
|
||||||
|
.bind(payload.content)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(note))
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ mod db_util;
|
|||||||
mod handlers;
|
mod handlers;
|
||||||
mod handlers_announcements;
|
mod handlers_announcements;
|
||||||
mod handlers_discussions;
|
mod handlers_discussions;
|
||||||
|
mod handlers_notes;
|
||||||
mod handlers_payments;
|
mod handlers_payments;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -147,6 +148,8 @@ async fn main() {
|
|||||||
"/announcements/{id}",
|
"/announcements/{id}",
|
||||||
delete(handlers_announcements::delete_announcement),
|
delete(handlers_announcements::delete_announcement),
|
||||||
)
|
)
|
||||||
|
.route("/lessons/{id}/notes", get(handlers_notes::get_note))
|
||||||
|
.route("/lessons/{id}/notes", put(handlers_notes::save_note))
|
||||||
.route_layer(middleware::from_fn(
|
.route_layer(middleware::from_fn(
|
||||||
common::middleware::org_extractor_middleware,
|
common::middleware::org_extractor_middleware,
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -435,6 +435,22 @@ pub struct AnnouncementWithAuthor {
|
|||||||
pub author_avatar: Option<String>,
|
pub author_avatar: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Student Notes
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
|
pub struct StudentNote {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub lesson_id: Uuid,
|
||||||
|
pub content: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SaveNotePayload {
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import MemoryPlayer from "@/components/blocks/MemoryPlayer";
|
|||||||
import DocumentPlayer from "@/components/blocks/DocumentPlayer";
|
import DocumentPlayer from "@/components/blocks/DocumentPlayer";
|
||||||
import AudioResponsePlayer from "@/components/blocks/AudioResponsePlayer";
|
import AudioResponsePlayer from "@/components/blocks/AudioResponsePlayer";
|
||||||
import InteractiveTranscript from "@/components/InteractiveTranscript";
|
import InteractiveTranscript from "@/components/InteractiveTranscript";
|
||||||
import AITutor from "@/components/AITutor";
|
|
||||||
import LessonLockedView from "@/components/LessonLockedView";
|
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 } }) {
|
export default function LessonPlayerPage({ params }: { params: { id: string, lessonId: string } }) {
|
||||||
const [lesson, setLesson] = useState<Lesson | null>(null);
|
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 [loading, setLoading] = useState(true);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [transcriptOpen, setTranscriptOpen] = useState(true);
|
const [transcriptOpen, setTranscriptOpen] = useState(true);
|
||||||
|
const [notesOpen, setNotesOpen] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [userGrade, setUserGrade] = useState<UserGrade | null>(null);
|
const [userGrade, setUserGrade] = useState<UserGrade | null>(null);
|
||||||
const [allGrades, setAllGrades] = useState<UserGrade[]>([]);
|
const [allGrades, setAllGrades] = useState<UserGrade[]>([]);
|
||||||
@@ -214,13 +215,26 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
</button>
|
</button>
|
||||||
{hasTranscription && (
|
{hasTranscription && (
|
||||||
<button
|
<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'}`}
|
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"
|
title="Alternar Transcripción"
|
||||||
>
|
>
|
||||||
<ListMusic size={20} />
|
<ListMusic size={20} />
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
@@ -458,14 +472,43 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Interactive Transcript Panel */}
|
{/* Right Side Panels */}
|
||||||
{hasTranscription && transcriptOpen && (
|
{((hasTranscription && transcriptOpen) || notesOpen) && (
|
||||||
<aside className="w-[400px] border-l border-white/5 bg-black/20 animate-in slide-in-from-right duration-500">
|
<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
|
<InteractiveTranscript
|
||||||
transcription={lesson.transcription!}
|
transcription={lesson.transcription!}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
onSeek={handleSeek}
|
onSeek={handleSeek}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -508,7 +551,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
|
|
||||||
{/* AI Tutor Bubble/Panel */}
|
{/* AI Tutor Bubble/Panel */}
|
||||||
<AITutor lessonId={params.lessonId} />
|
<AITutor lessonId={params.lessonId} />
|
||||||
</main>
|
</main >
|
||||||
</div>
|
</div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading) {
|
if (!loading) {
|
||||||
const isAuthPage = pathname?.startsWith("/auth");
|
const isAuthPage = pathname?.startsWith("/auth");
|
||||||
if (!user && !isAuthPage) {
|
const isCatalogRoot = pathname === "/";
|
||||||
|
if (!user && !isAuthPage && !isCatalogRoot) {
|
||||||
router.push("/auth/login");
|
router.push("/auth/login");
|
||||||
} else if (user && isAuthPage) {
|
} else if (user && isAuthPage) {
|
||||||
router.push("/");
|
router.push("/");
|
||||||
@@ -29,7 +30,8 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isAuthPage = pathname?.startsWith("/auth");
|
const isAuthPage = pathname?.startsWith("/auth");
|
||||||
if (!user && !isAuthPage) {
|
const isCatalogRoot = pathname === "/";
|
||||||
|
if (!user && !isAuthPage && !isCatalogRoot) {
|
||||||
return null;
|
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[];
|
lessons: Lesson[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StudentNote {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
lesson_id: string;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Discussion Forums Types
|
// Discussion Forums Types
|
||||||
export interface DiscussionThread {
|
export interface DiscussionThread {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -577,5 +586,14 @@ export const lmsApi = {
|
|||||||
return apiFetch(`/announcements/${id}`, {
|
return apiFetch(`/announcements/${id}`, {
|
||||||
method: 'DELETE'
|
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