+ const lecturePreview = markdownToSafeHtml(lectureContent);
+ const videoEmbedUrl = getYouTubeEmbedUrl(youtubeUrl);
- {/* Video Upload Section */}
-
+
+
+
+
+ setTitle(event.target.value)}
+ className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
+ />
+
- {/* Text Content */}
-
-
-
- setTitle(e.target.value)}
- className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
- />
-
+ {contentType === "VIDEO" ? (
+
+
+
+
+
Opción 1: Subida directa
+
+
-
-
-
+
+
Opción 2: YouTube URL
+
setYoutubeUrl(event.target.value)}
+ placeholder="https://www.youtube.com/watch?v=..."
+ className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
+ />
+
-
-
-
setMaterialUrl(e.target.value)}
- placeholder="https://.../material.pdf"
- className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
- />
-
Se usará en la vista del alumno como lectura o actividad descargable.
-
-
+
Si ambas están presentes, se priorizará YouTube en la vista del alumno.
+
+ {videoEmbedUrl ? (
+
+ ) : videoUrl ? (
+
+
+
+ ) : (
+
+ Agrega una URL o sube un video para previsualizar.
+
+ )}
+
+ ) : null}
- {/* RIGHT: Settings / Actions */}
-
-
-
Tipo de contenido
-
-
+ ) : null}
+
+
+
+
+
Tipo de contenido
+
+
setContentType(event.target.value as LessonContentType)}
+ className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
+ >
+ {lessonContentTypes.map((type) => (
+
+ ))}
+
+
Usa Evaluacion final para marcar el examen obligatorio del curso.
+
+
+
{
+ const parsed = Number(event.target.value);
+ setDurationMinutes(Number.isFinite(parsed) ? Math.max(0, parsed) : 0);
+ }}
+ className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
+ />
- );
+
+
+
Vista previa gratuita
+
+
Los no inscritos podran ver esta leccion sin comprar el curso.
+
+
+
+
Acciones
+
+
+
+
+
+
+ );
}
diff --git a/app/(protected)/teacher/courses/[slug]/lessons/[lessonId]/page.tsx b/app/(protected)/teacher/courses/[slug]/lessons/[lessonId]/page.tsx
index b0c4ec2..9384d22 100644
--- a/app/(protected)/teacher/courses/[slug]/lessons/[lessonId]/page.tsx
+++ b/app/(protected)/teacher/courses/[slug]/lessons/[lessonId]/page.tsx
@@ -69,6 +69,8 @@ export default async function LessonPage({ params }: PageProps) {
title: getText(lesson.title),
description: lessonMeta.text,
contentType: lessonMeta.contentType,
+ lectureContent: lessonMeta.lectureContent,
+ activity: lessonMeta.activity,
materialUrl: lessonMeta.materialUrl,
estimatedDurationMinutes: Math.max(0, Math.ceil((lesson.estimatedDuration ?? 0) / 60)),
}}
diff --git a/app/(public)/courses/[slug]/learn/page.tsx b/app/(public)/courses/[slug]/learn/page.tsx
index 75d00a2..064c8be 100644
--- a/app/(public)/courses/[slug]/learn/page.tsx
+++ b/app/(public)/courses/[slug]/learn/page.tsx
@@ -149,6 +149,8 @@ export default async function CourseLearnPage({ params, searchParams }: PageProp
title: getText(lesson.title) || "Untitled lesson",
description: lessonMeta.text,
contentType: lessonMeta.contentType,
+ lectureContent: lessonMeta.lectureContent,
+ activity: lessonMeta.activity,
materialUrl: lessonMeta.materialUrl,
videoUrl: lesson.videoUrl,
youtubeUrl: lesson.youtubeUrl,
diff --git a/components/courses/StudentClassroomClient.tsx b/components/courses/StudentClassroomClient.tsx
index db64f61..475a1b4 100644
--- a/components/courses/StudentClassroomClient.tsx
+++ b/components/courses/StudentClassroomClient.tsx
@@ -4,14 +4,23 @@ import { useEffect, useMemo, useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Check, CircleDashed, ClipboardCheck, FileText, Lock, PlayCircle } from "lucide-react";
+import { toast } from "sonner";
import { toggleLessonComplete } from "@/app/(public)/courses/[slug]/learn/actions";
import ProgressBar from "@/components/ProgressBar";
-import { getLessonContentTypeLabel, isFinalExam, type LessonContentType } from "@/lib/courses/lessonContent";
+import { markdownToSafeHtml } from "@/lib/courses/lessonMarkdown";
+import {
+ getLessonContentTypeLabel,
+ isFinalExam,
+ type LessonActivityMeta,
+ type LessonContentType,
+} from "@/lib/courses/lessonContent";
type ClassroomLesson = {
id: string;
title: string;
description: string;
+ lectureContent: string;
+ activity: LessonActivityMeta | null;
contentType: LessonContentType;
materialUrl: string | null;
videoUrl: string | null;
@@ -40,6 +49,14 @@ type CompletionCertificate = {
certificateNumber: string | null;
};
+type ActivityResult = {
+ score: number;
+ correct: number;
+ total: number;
+ passed: boolean;
+ questionResults: Record
;
+};
+
function getYouTubeEmbedUrl(url: string | null | undefined): string | null {
if (!url?.trim()) return null;
const trimmed = url.trim();
@@ -57,6 +74,10 @@ function getIsPdfUrl(url: string | null | undefined): boolean {
return /\.pdf(?:$|\?)/i.test(url.trim());
}
+function isAssessmentType(contentType: LessonContentType): boolean {
+ return contentType === "ACTIVITY" || contentType === "QUIZ" || contentType === "FINAL_EXAM";
+}
+
export default function StudentClassroomClient({
courseSlug,
courseTitle,
@@ -70,6 +91,8 @@ export default function StudentClassroomClient({
const [selectedLessonId, setSelectedLessonId] = useState(initialSelectedLessonId);
const [completedLessonIds, setCompletedLessonIds] = useState(initialCompletedLessonIds);
const [completionCertificate, setCompletionCertificate] = useState(null);
+ const [activityAnswers, setActivityAnswers] = useState>({});
+ const [activityResult, setActivityResult] = useState(null);
useEffect(() => {
setSelectedLessonId(initialSelectedLessonId);
@@ -89,10 +112,17 @@ export default function StudentClassroomClient({
const selectedLesson =
flatLessons.find((lesson) => lesson.id === selectedLessonId) ?? flatLessons[0] ?? null;
+ useEffect(() => {
+ setActivityAnswers({});
+ setActivityResult(null);
+ }, [selectedLesson?.id]);
+
const selectedLessonTypeLabel = selectedLesson ? getLessonContentTypeLabel(selectedLesson.contentType) : "";
+ const selectedLessonActivity = selectedLesson?.activity?.questions?.length ? selectedLesson.activity : null;
+ const selectedLectureHtml = markdownToSafeHtml(selectedLesson?.lectureContent || selectedLesson?.description || "");
const isRestricted = (lessonId: string) => {
- if (!isEnrolled) return false; // Non-enrolled can click any lesson (preview shows content, locked shows premium message)
+ if (!isEnrolled) return false;
const lessonIndex = flatLessons.findIndex((lesson) => lesson.id === lessonId);
if (lessonIndex <= 0) return false;
if (completedSet.has(lessonId)) return false;
@@ -109,7 +139,7 @@ export default function StudentClassroomClient({
};
const handleToggleComplete = async () => {
- if (!selectedLesson || isSaving) return;
+ if (!selectedLesson || isSaving || !isEnrolled) return;
const lessonId = selectedLesson.id;
const wasCompleted = completedSet.has(lessonId);
@@ -146,6 +176,40 @@ export default function StudentClassroomClient({
});
};
+ const submitActivity = async () => {
+ if (!selectedLesson || !selectedLessonActivity) return;
+
+ const total = selectedLessonActivity.questions.length;
+ if (total === 0) return;
+
+ const unanswered = selectedLessonActivity.questions.filter((question) => !activityAnswers[question.id]);
+ if (unanswered.length > 0) {
+ toast.error("Responde todas las preguntas antes de enviar.");
+ return;
+ }
+
+ let correct = 0;
+ const questionResults: Record = {};
+
+ for (const question of selectedLessonActivity.questions) {
+ const answerId = activityAnswers[question.id];
+ const selectedOption = question.options.find((option) => option.id === answerId);
+ const isCorrect = Boolean(selectedOption?.isCorrect);
+ if (isCorrect) correct += 1;
+ questionResults[question.id] = isCorrect;
+ }
+
+ const score = Math.round((correct / total) * 100);
+ const passingScore = selectedLesson.contentType === "ACTIVITY" ? 0 : selectedLessonActivity.passingScorePercent;
+ const passed = selectedLesson.contentType === "ACTIVITY" ? true : score >= passingScore;
+
+ setActivityResult({ score, correct, total, passed, questionResults });
+
+ if (passed && isEnrolled && !completedSet.has(selectedLesson.id)) {
+ await handleToggleComplete();
+ }
+ };
+
if (!selectedLesson) {
return (
@@ -217,6 +281,7 @@ export default function StudentClassroomClient({
`}
>
) : null}
+
{"<-"} Back to Course
@@ -255,12 +320,113 @@ export default function StudentClassroomClient({
key={`${selectedLesson.id}-${selectedLesson.videoUrl}`}
className="h-full w-full"
controls
- onEnded={handleToggleComplete}
+ onEnded={() => {
+ if (isEnrolled) {
+ void handleToggleComplete();
+ }
+ }}
src={selectedLesson.videoUrl}
/>
) : (
- Video not available for this lesson
+
+ Video not available for this lesson
+
)
+ ) : selectedLesson.contentType === "LECTURE" ? (
+
+ ) : isAssessmentType(selectedLesson.contentType) && selectedLessonActivity ? (
+
+
+ {selectedLessonTypeLabel}
+
+
+ {selectedLessonActivity.instructions ? (
+
+ {selectedLessonActivity.instructions}
+
+ ) : null}
+
+
+ {selectedLessonActivity.questions.map((question, index) => (
+
+
+ {index + 1}. {question.prompt}
+
+
+ {question.options.map((option) => {
+ const isSelected = activityAnswers[question.id] === option.id;
+ const showResult = Boolean(activityResult);
+ const isCorrect = option.isCorrect;
+ return (
+
+ );
+ })}
+
+ {activityResult && question.explanation ? (
+
+ {question.explanation}
+
+ ) : null}
+
+ ))}
+
+
+
+
+ {activityResult ? (
+
+ Resultado: {activityResult.correct}/{activityResult.total} ({activityResult.score}%)
+ {selectedLesson.contentType !== "ACTIVITY" ? (
+
+ {" "}
+ | Mínimo: {selectedLessonActivity.passingScorePercent}%{" "}
+ {activityResult.passed ? "(Aprobado)" : "(No aprobado)"}
+
+ ) : (
+ | Actividad completada
+ )}
+
+ ) : null}
+
+ {isFinalExam(selectedLesson.contentType) ? (
+
+ Debes aprobar esta evaluación final para graduarte y emitir el certificado del curso.
+
+ ) : null}
+
) : (
@@ -271,35 +437,29 @@ export default function StudentClassroomClient({
) : (
Este contenido no tiene descripción adicional.
)}
-
- {selectedLesson.materialUrl ? (
- getIsPdfUrl(selectedLesson.materialUrl) ? (
-
- ) : (
-
- Abrir material
-
- )
- ) : null}
-
- {isFinalExam(selectedLesson.contentType) ? (
-
- Debes completar esta evaluación final para graduarte y emitir el certificado del curso.
-
- ) : null}
)}
+ {selectedLesson.materialUrl ? (
+ getIsPdfUrl(selectedLesson.materialUrl) ? (
+
+ ) : (
+
+ Abrir material
+
+ )
+ ) : null}
+
{selectedLesson.title}
@@ -313,18 +473,14 @@ export default function StudentClassroomClient({
- {isEnrolled && (
+ {isEnrolled && !isAssessmentType(selectedLesson.contentType) && (
)}
diff --git a/components/teacher/FileUpload.tsx b/components/teacher/FileUpload.tsx
new file mode 100644
index 0000000..5a7aecc
--- /dev/null
+++ b/components/teacher/FileUpload.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import { useState } from "react";
+import { createBrowserClient } from "@supabase/ssr";
+import { toast } from "sonner";
+import { FileDown, Upload, X, Loader2 } from "lucide-react";
+
+interface FileUploadProps {
+ lessonId: string;
+ currentFileUrl?: string | null;
+ onUploadComplete: (url: string) => void;
+ onRemove?: () => void;
+ accept?: string;
+ label?: string;
+}
+
+const ALLOWED_MIMES = [
+ "application/pdf",
+ "application/msword",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ "text/plain"
+].join(",");
+
+export default function FileUpload({
+ lessonId,
+ currentFileUrl,
+ onUploadComplete,
+ onRemove,
+ accept = ALLOWED_MIMES,
+ label = "Documento / Material"
+}: FileUploadProps) {
+ const [uploading, setUploading] = useState(false);
+
+ const handleFileUpload = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ setUploading(true);
+
+ const supabase = createBrowserClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
+ );
+
+ // 1. Verify session
+ const { data: { user }, error: authError } = await supabase.auth.getUser();
+ if (authError || !user) {
+ toast.error("Error de autenticación: Por favor inicia sesión de nuevo.");
+ setUploading(false);
+ return;
+ }
+
+ // 2. Create a unique file path: lesson_id/timestamp_filename
+ const filePath = `${lessonId}/${Date.now()}_${file.name.replace(/\s+/g, '_')}`;
+
+ // 3. Upload to Supabase Storage (assets bucket)
+ const { error } = await supabase.storage
+ .from("assets")
+ .upload(filePath, file, {
+ upsert: true,
+ });
+
+ if (error) {
+ console.error("Storage upload error:", error);
+ if (error.message.includes("row-level security policy")) {
+ toast.error("Error de permisos: El bucket 'assets' no permite subidas para profesores.");
+ } else {
+ toast.error("Error al subir: " + error.message);
+ }
+ setUploading(false);
+ return;
+ }
+
+ // 4. Get Public URL
+ const { data: { publicUrl } } = supabase.storage
+ .from("assets")
+ .getPublicUrl(filePath);
+
+ onUploadComplete(publicUrl);
+ setUploading(false);
+ toast.success("Archivo subido con éxito");
+ };
+
+ const fileName = currentFileUrl ? currentFileUrl.split('/').pop() : "";
+
+ return (
+
+
+
+ {currentFileUrl ? (
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+ {uploading ? (
+
+ ) : (
+
+ )}
+
+
+ {uploading ? "Subiendo archivo..." : "Haz clic o arrastra un archivo aquí"}
+
+
+ Formatos permitidos: PDF, Word, PowerPoint o Texto (.txt)
+
+
Máximo 50MB
+
+
+
+ )}
+
+ );
+}
diff --git a/components/teacher/TeacherEditCourseForm.tsx b/components/teacher/TeacherEditCourseForm.tsx
index b676e16..6d9fbc2 100755
--- a/components/teacher/TeacherEditCourseForm.tsx
+++ b/components/teacher/TeacherEditCourseForm.tsx
@@ -14,6 +14,11 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { toast } from "sonner";
import { getClientLocale } from "@/lib/i18n/clientLocale";
+import {
+ getLessonContentTypeLabel,
+ parseLessonDescriptionMeta,
+ type LessonContentType,
+} from "@/lib/courses/lessonContent";
import { Prisma } from "@prisma/client";
@@ -35,22 +40,44 @@ type CourseData = {
modules: {
id: string;
title: Prisma.JsonValue;
- lessons: { id: string; title: Prisma.JsonValue }[];
+ lessons: { id: string; title: Prisma.JsonValue; description: Prisma.JsonValue | null }[];
}[];
};
+const selectableLessonTypes: LessonContentType[] = [
+ "VIDEO",
+ "LECTURE",
+ "ACTIVITY",
+ "QUIZ",
+ "FINAL_EXAM",
+];
+
+function getLessonTypeBadgeClass(type: LessonContentType): string {
+ if (type === "LECTURE") return "border-indigo-200 bg-indigo-50 text-indigo-700";
+ if (type === "ACTIVITY") return "border-rose-200 bg-rose-50 text-rose-700";
+ if (type === "QUIZ" || type === "FINAL_EXAM") return "border-amber-200 bg-amber-50 text-amber-700";
+ return "border-sky-200 bg-sky-50 text-sky-700";
+}
+
export default function TeacherEditCourseForm({ course }: { course: CourseData }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [optimisticModules, setOptimisticModules] = useState(course.modules);
const [editingModuleId, setEditingModuleId] = useState(null);
const [editingTitle, setEditingTitle] = useState("");
+ const [newLessonTypeByModule, setNewLessonTypeByModule] = useState>({});
const [learningOutcomes, setLearningOutcomes] = useState(() =>
parseLearningOutcomes(course.learningOutcomes)
);
useEffect(() => {
setOptimisticModules(course.modules);
+ setNewLessonTypeByModule(
+ course.modules.reduce>((acc, module) => {
+ acc[module.id] = acc[module.id] ?? "VIDEO";
+ return acc;
+ }, {}),
+ );
}, [course.modules]);
useEffect(() => {
@@ -134,10 +161,11 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
// 3. CREATE NEW LESSON
const handleAddLesson = async (moduleId: string) => {
+ const selectedType = newLessonTypeByModule[moduleId] ?? "VIDEO";
setLoading(true);
- const res = await createLesson(moduleId);
+ const res = await createLesson(moduleId, selectedType);
if (res.success && res.lessonId) {
- toast.success("Lección creada");
+ toast.success(`Lección creada (${getLessonContentTypeLabel(selectedType)})`);
// Redirect immediately to the video upload page
router.push(`/teacher/courses/${course.slug}/lessons/${res.lessonId}`);
} else {
@@ -444,61 +472,92 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
{/* Lessons List */}
- {module.lessons.map((lesson, lessonIndex) => (
-
-
-
-
-
-
{
+ const lessonMeta = parseLessonDescriptionMeta(lesson.description);
+ const lessonType = lessonMeta.contentType;
+ return (
+
-
▶
-
-
- {getStr(lesson.title)}
-
+
+
+
-
- Editar Contenido
-
-
-
- ))}
+
+
▶
+
+
+
+ {getLessonContentTypeLabel(lessonType)}
+
+
+
+ {getStr(lesson.title)}
+
+
+
+ Editar Contenido
+
+
+
+ );
+ })}
- {/* Add Lesson Button */}
-
+
+
+ setNewLessonTypeByModule((prev) => ({
+ ...prev,
+ [module.id]: event.target.value as LessonContentType,
+ }))
+ }
+ className="rounded-md border border-slate-300 px-2 py-1.5 text-xs text-slate-700 outline-none focus:border-black"
+ >
+ {selectableLessonTypes.map((type) => (
+
+ ))}
+
+
+
+
))}
@@ -527,8 +586,8 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
💡 Tips
- Crea módulos para organizar tus temas.
- - Dentro de cada módulo, agrega lecciones.
- - Haz clic en una lección para subir el video.
+ - Dentro de cada módulo, agrega lecciones con tipo (video, lectura, actividad, quiz).
+ - Haz clic en una lección para editar su contenido según el formato elegido.