diff --git a/app/(protected)/teacher/actions.ts b/app/(protected)/teacher/actions.ts index c6a2193..5b5e629 100644 --- a/app/(protected)/teacher/actions.ts +++ b/app/(protected)/teacher/actions.ts @@ -8,6 +8,7 @@ import { ContentStatus, Prisma, ProficiencyLevel } from "@prisma/client"; import { buildLessonDescriptionMeta, parseLessonDescriptionMeta, + type LessonActivityMeta, type LessonContentType, } from "@/lib/courses/lessonContent"; @@ -186,9 +187,11 @@ export async function updateLesson(lessonId: string, data: { title?: string; description?: string; videoUrl?: string; - youtubeUrl?: string; + youtubeUrl?: string | null; materialUrl?: string; contentType?: LessonContentType; + lectureContent?: string; + activity?: LessonActivityMeta | null; estimatedDurationMinutes?: number; isPreview?: boolean; // maps to DB field isFreePreview isPublished?: boolean; // optional: for later @@ -200,7 +203,7 @@ export async function updateLesson(lessonId: string, data: { const updateData: Prisma.LessonUpdateInput = { updatedAt: new Date() }; if (data.title !== undefined) updateData.title = data.title; if (data.videoUrl !== undefined) updateData.videoUrl = data.videoUrl; - if (data.youtubeUrl !== undefined) updateData.youtubeUrl = data.youtubeUrl; + if (data.youtubeUrl !== undefined) updateData.youtubeUrl = data.youtubeUrl?.trim() || null; if (data.estimatedDurationMinutes !== undefined) { const minutes = Math.max(0, Math.round(data.estimatedDurationMinutes)); updateData.estimatedDuration = minutes * 60; @@ -208,7 +211,11 @@ export async function updateLesson(lessonId: string, data: { if (data.isPreview !== undefined) updateData.isFreePreview = data.isPreview; const shouldUpdateMeta = - data.description !== undefined || data.contentType !== undefined || data.materialUrl !== undefined; + data.description !== undefined || + data.contentType !== undefined || + data.materialUrl !== undefined || + data.lectureContent !== undefined || + data.activity !== undefined; if (shouldUpdateMeta) { const lesson = await db.lesson.findUnique({ @@ -221,7 +228,9 @@ export async function updateLesson(lessonId: string, data: { text: data.description ?? existingMeta.text, contentType: data.contentType ?? existingMeta.contentType, materialUrl: data.materialUrl ?? existingMeta.materialUrl, - }); + lectureContent: data.lectureContent ?? existingMeta.lectureContent, + activity: data.activity ?? existingMeta.activity, + }) as Prisma.InputJsonValue; } await db.lesson.update({ @@ -300,7 +309,7 @@ export async function updateModuleTitle(moduleId: string, title: string) { } // 2. CREATE LESSON -export async function createLesson(moduleId: string) { +export async function createLesson(moduleId: string, contentType: LessonContentType = "VIDEO") { const user = await requireTeacher(); if (!user) return { success: false, error: "Unauthorized" }; @@ -318,9 +327,11 @@ export async function createLesson(moduleId: string) { data: { moduleId, title: "Nueva Lección", - description: { - contentType: "VIDEO", - }, + description: buildLessonDescriptionMeta({ + text: "", + contentType, + lectureContent: "", + }) as Prisma.InputJsonValue, orderIndex: newOrder, estimatedDuration: 0, version: 1, diff --git a/app/(protected)/teacher/courses/[slug]/edit/page.tsx b/app/(protected)/teacher/courses/[slug]/edit/page.tsx index 096b849..4ce9056 100755 --- a/app/(protected)/teacher/courses/[slug]/edit/page.tsx +++ b/app/(protected)/teacher/courses/[slug]/edit/page.tsx @@ -21,6 +21,11 @@ export default async function CourseEditPage({ params }: { params: Promise<{ slu include: { lessons: { orderBy: { orderIndex: "asc" }, + select: { + id: true, + title: true, + description: true, + }, }, }, }, @@ -42,4 +47,4 @@ export default async function CourseEditPage({ params }: { params: Promise<{ slu // Key forces remount when course updates so uncontrolled inputs (level, status) show new defaultValues after save + router.refresh() return ; -} \ No newline at end of file +} diff --git a/app/(protected)/teacher/courses/[slug]/lessons/[lessonId]/LessonEditorForm.tsx b/app/(protected)/teacher/courses/[slug]/lessons/[lessonId]/LessonEditorForm.tsx index d08c435..f87831e 100644 --- a/app/(protected)/teacher/courses/[slug]/lessons/[lessonId]/LessonEditorForm.tsx +++ b/app/(protected)/teacher/courses/[slug]/lessons/[lessonId]/LessonEditorForm.tsx @@ -1,222 +1,676 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { updateLesson } from "@/app/(protected)/teacher/actions"; -import VideoUpload from "@/components/teacher/VideoUpload"; // The component you created earlier import { getClientLocale } from "@/lib/i18n/clientLocale"; -import { getLessonContentTypeLabel, lessonContentTypes, type LessonContentType } from "@/lib/courses/lessonContent"; +import { markdownToPlainText, markdownToSafeHtml } from "@/lib/courses/lessonMarkdown"; +import { getLessonContentTypeLabel, lessonContentTypes, type LessonActivityMeta, type LessonContentType, type LessonQuestion, type LessonQuestionKind } from "@/lib/courses/lessonContent"; +import VideoUpload from "@/components/teacher/VideoUpload"; +import FileUpload from "@/components/teacher/FileUpload"; interface LessonEditorFormProps { - lesson: { - id: string; - title: string; - description?: string | null; - videoUrl?: string | null; - youtubeUrl?: string | null; - isFreePreview?: boolean; - contentType: LessonContentType; - materialUrl?: string | null; - estimatedDurationMinutes: number; + lesson: { + id: string; + title: string; + description?: string | null; + videoUrl?: string | null; + youtubeUrl?: string | null; + materialUrl?: string | null; + isFreePreview?: boolean; + contentType: LessonContentType; + lectureContent: string; + activity: LessonActivityMeta | null; + estimatedDurationMinutes: number; + }; + courseSlug: string; +} + +type EditableQuestion = LessonQuestion; + +function createId(prefix: string): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return `${prefix}-${crypto.randomUUID()}`; + } + return `${prefix}-${Math.random().toString(36).slice(2, 10)}`; +} + +function createTrueFalseQuestion(): EditableQuestion { + return { + id: createId("q"), + kind: "TRUE_FALSE", + prompt: "", + explanation: "", + options: [ + { id: "true", text: "Verdadero", isCorrect: true }, + { id: "false", text: "Falso", isCorrect: false }, + ], + }; +} + +function createMultipleChoiceQuestion(): EditableQuestion { + return { + id: createId("q"), + kind: "MULTIPLE_CHOICE", + prompt: "", + explanation: "", + options: [ + { id: createId("opt"), text: "", isCorrect: true }, + { id: createId("opt"), text: "", isCorrect: false }, + ], + }; +} + +function normalizeQuestion(question: EditableQuestion): EditableQuestion { + if (question.kind === "TRUE_FALSE") { + const correctOptionId = + question.options.find((option) => option.isCorrect)?.id ?? "true"; + return { + ...question, + options: [ + { id: "true", text: "Verdadero", isCorrect: correctOptionId === "true" }, + { id: "false", text: "Falso", isCorrect: correctOptionId === "false" }, + ], }; - courseSlug: string; + } + + const filteredOptions = question.options.filter((option) => option.text.trim()); + const hasCorrect = filteredOptions.some((option) => option.isCorrect); + + return { + ...question, + options: filteredOptions.map((option, index) => ({ + ...option, + text: option.text.trim(), + isCorrect: hasCorrect ? option.isCorrect : index === 0, + })), + }; +} + +function isAssessmentType(contentType: LessonContentType): boolean { + return contentType === "ACTIVITY" || contentType === "QUIZ" || contentType === "FINAL_EXAM"; +} + +function getYouTubeEmbedUrl(url: string): string | null { + const value = url.trim(); + if (!value) return null; + const watchMatch = value.match(/(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]+)/); + if (watchMatch) return `https://www.youtube.com/embed/${watchMatch[1]}`; + const shortMatch = value.match(/(?:youtu\.be\/)([a-zA-Z0-9_-]+)/); + if (shortMatch) return `https://www.youtube.com/embed/${shortMatch[1]}`; + const embedMatch = value.match(/(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]+)/); + if (embedMatch) return value; + return null; } export function LessonEditorForm({ lesson, courseSlug }: LessonEditorFormProps) { - const router = useRouter(); - const [loading, setLoading] = useState(false); - const [title, setTitle] = useState(lesson.title); - const [description, setDescription] = useState(lesson.description ?? ""); - const [youtubeUrl, setYoutubeUrl] = useState(lesson.youtubeUrl ?? ""); - const [isFreePreview, setIsFreePreview] = useState(lesson.isFreePreview ?? false); - const [contentType, setContentType] = useState(lesson.contentType); - const [materialUrl, setMaterialUrl] = useState(lesson.materialUrl ?? ""); - const [durationMinutes, setDurationMinutes] = useState(lesson.estimatedDurationMinutes); + const router = useRouter(); + const lectureRef = useRef(null); + const [loading, setLoading] = useState(false); + const [title, setTitle] = useState(lesson.title); + const [youtubeUrl, setYoutubeUrl] = useState(lesson.youtubeUrl ?? ""); + const [videoUrl, setVideoUrl] = useState(lesson.videoUrl ?? ""); + const [materialUrl, setMaterialUrl] = useState(lesson.materialUrl ?? ""); + const [isFreePreview, setIsFreePreview] = useState(lesson.isFreePreview ?? false); + const [contentType, setContentType] = useState(lesson.contentType); + const [durationMinutes, setDurationMinutes] = useState(lesson.estimatedDurationMinutes); + const [lectureContent, setLectureContent] = useState(lesson.lectureContent || lesson.description || ""); + const [activityInstructions, setActivityInstructions] = useState( + lesson.activity?.instructions || lesson.description || "", + ); + const [passingScorePercent, setPassingScorePercent] = useState(lesson.activity?.passingScorePercent ?? 70); + const [activityQuestions, setActivityQuestions] = useState( + lesson.activity?.questions?.length ? lesson.activity.questions : [], + ); - const showSavedToast = () => { - const isSpanish = getClientLocale() === "es"; - toast.success(isSpanish ? "Cambios guardados" : "Changes saved", { - description: isSpanish - ? "Puedes seguir editando o volver al panel de cursos." - : "You can keep editing or go back to the courses dashboard.", - action: { - label: isSpanish ? "Volver a cursos" : "Back to courses", - onClick: () => router.push("/teacher"), - }, - cancel: { - label: isSpanish ? "Seguir editando" : "Keep editing", - onClick: () => {}, - }, - duration: 7000, - }); - }; + const handleVideoUploaded = (url: string) => { + setVideoUrl(url); + toast.success("Video cargado en el formulario (no olvides Guardar Cambios)"); + }; - // 1. Auto-save Video URL when upload finishes - const handleVideoUploaded = async (url: string) => { - toast.loading("Guardando video..."); - const res = await updateLesson(lesson.id, { videoUrl: url }); + const handleMaterialUploaded = (url: string) => { + setMaterialUrl(url); + toast.success("Material cargado en el formulario (no olvides Guardar Cambios)"); + }; - if (res.success) { - toast.dismiss(); - toast.success("Video guardado correctamente"); - router.refresh(); // Update the UI to show the new video player - } else { - toast.error("Error al guardar el video en la base de datos"); + const showSavedToast = () => { + const isSpanish = getClientLocale() === "es"; + toast.success(isSpanish ? "Cambios guardados" : "Changes saved", { + description: isSpanish + ? "Puedes seguir editando o volver al panel de cursos." + : "You can keep editing or go back to the courses dashboard.", + action: { + label: isSpanish ? "Volver a cursos" : "Back to courses", + onClick: () => router.push("/teacher"), + }, + cancel: { + label: isSpanish ? "Seguir editando" : "Keep editing", + onClick: () => {}, + }, + duration: 7000, + }); + }; + + const insertMarkdown = (prefix: string, suffix = "", placeholder = "texto") => { + const target = lectureRef.current; + if (!target) return; + const start = target.selectionStart; + const end = target.selectionEnd; + const selected = lectureContent.slice(start, end); + const replacement = `${prefix}${selected || placeholder}${suffix}`; + const nextContent = `${lectureContent.slice(0, start)}${replacement}${lectureContent.slice(end)}`; + setLectureContent(nextContent); + + requestAnimationFrame(() => { + target.focus(); + target.setSelectionRange(start + prefix.length, start + prefix.length + (selected || placeholder).length); + }); + }; + + const updateQuestion = (questionId: string, updater: (question: EditableQuestion) => EditableQuestion) => { + setActivityQuestions((prev) => prev.map((question) => (question.id === questionId ? updater(question) : question))); + }; + + const changeQuestionKind = (questionId: string, kind: LessonQuestionKind) => { + updateQuestion(questionId, (question) => { + if (kind === question.kind) return question; + if (kind === "TRUE_FALSE") { + return { + ...createTrueFalseQuestion(), + id: question.id, + prompt: question.prompt, + explanation: question.explanation, + }; + } + return { + ...createMultipleChoiceQuestion(), + id: question.id, + prompt: question.prompt, + explanation: question.explanation, + }; + }); + }; + + const validateForSave = (): { ok: boolean; normalizedQuestions: EditableQuestion[] } => { + if (!title.trim()) { + toast.error("El título de la lección es obligatorio."); + return { ok: false, normalizedQuestions: [] }; + } + + if (contentType === "VIDEO") { + const hasYouTube = getYouTubeEmbedUrl(youtubeUrl); + const hasDirectVideo = videoUrl.trim(); + + if (!hasYouTube && !hasDirectVideo) { + toast.error("Para video, sube un archivo o ingresa una URL válida de YouTube."); + return { ok: false, normalizedQuestions: [] }; + } + } + + if (contentType === "LECTURE") { + const plainLecture = markdownToPlainText(lectureContent); + if (plainLecture.length < 20) { + toast.error("La lectura necesita contenido con formato (mínimo 20 caracteres)."); + return { ok: false, normalizedQuestions: [] }; + } + } + + if (isAssessmentType(contentType)) { + if (activityQuestions.length === 0) { + toast.error("Agrega al menos una pregunta para esta actividad."); + return { ok: false, normalizedQuestions: [] }; + } + + const normalized = activityQuestions.map(normalizeQuestion); + + for (let index = 0; index < normalized.length; index += 1) { + const question = normalized[index]; + if (!question.prompt.trim()) { + toast.error(`La pregunta ${index + 1} no tiene enunciado.`); + return { ok: false, normalizedQuestions: [] }; } - }; - // 2. Save Text Changes (Title/Desc/YouTube/Preview) - const handleSave = async () => { - setLoading(true); - const res = await updateLesson(lesson.id, { - title, - description, - youtubeUrl: youtubeUrl.trim() || undefined, - materialUrl: materialUrl.trim() || undefined, - contentType, - estimatedDurationMinutes: durationMinutes, - isPreview: isFreePreview, - }); - if (res.success) { - showSavedToast(); - router.refresh(); - } else { - toast.error("Error al guardar cambios"); + if (question.options.length < 2) { + toast.error(`La pregunta ${index + 1} debe tener al menos 2 opciones.`); + return { ok: false, normalizedQuestions: [] }; } - setLoading(false); + + const correctCount = question.options.filter((option) => option.isCorrect).length; + if (correctCount !== 1) { + toast.error(`La pregunta ${index + 1} debe tener exactamente 1 respuesta correcta.`); + return { ok: false, normalizedQuestions: [] }; + } + } + + return { ok: true, normalizedQuestions: normalized }; + } + + return { ok: true, normalizedQuestions: [] }; + }; + + const handleSave = async () => { + const validation = validateForSave(); + if (!validation.ok) return; + + setLoading(true); + const lectureSummary = markdownToPlainText(lectureContent).slice(0, 240); + const activitySummary = activityInstructions.trim().slice(0, 240); + + const payload = { + title: title.trim(), + contentType, + youtubeUrl: contentType === "VIDEO" ? youtubeUrl.trim() : null, + videoUrl: contentType === "VIDEO" ? videoUrl.trim() : undefined, + materialUrl: materialUrl.trim() || undefined, + description: + contentType === "LECTURE" + ? lectureSummary + : isAssessmentType(contentType) + ? activitySummary + : title.trim(), + lectureContent: contentType === "LECTURE" ? lectureContent.trim() : "", + activity: isAssessmentType(contentType) + ? { + instructions: activityInstructions.trim(), + passingScorePercent: contentType === "ACTIVITY" ? 0 : passingScorePercent, + questions: validation.normalizedQuestions, + } + : null, + estimatedDurationMinutes: durationMinutes, + isPreview: isFreePreview, }; - return ( -
+ const res = await updateLesson(lesson.id, payload); + if (res.success) { + showSavedToast(); + router.refresh(); + } else { + toast.error("Error al guardar cambios"); + } + setLoading(false); + }; - {/* LEFT: Video & Content */} -
+ const lecturePreview = markdownToSafeHtml(lectureContent); + const videoEmbedUrl = getYouTubeEmbedUrl(youtubeUrl); - {/* Video Upload Section */} -
-
-

Video del Curso

-

Sube el video principal de esta lección.

-
-
- -
-
- - setYoutubeUrl(e.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" - /> -

Si se proporciona, se usará en lugar del video subido en la lección.

-
-
+ return ( +
+
+
+
+ + 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

+ +
-
- -