MVP
This commit is contained in:
@@ -8,6 +8,7 @@ import { ContentStatus, Prisma, ProficiencyLevel } from "@prisma/client";
|
|||||||
import {
|
import {
|
||||||
buildLessonDescriptionMeta,
|
buildLessonDescriptionMeta,
|
||||||
parseLessonDescriptionMeta,
|
parseLessonDescriptionMeta,
|
||||||
|
type LessonActivityMeta,
|
||||||
type LessonContentType,
|
type LessonContentType,
|
||||||
} from "@/lib/courses/lessonContent";
|
} from "@/lib/courses/lessonContent";
|
||||||
|
|
||||||
@@ -186,9 +187,11 @@ export async function updateLesson(lessonId: string, data: {
|
|||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
videoUrl?: string;
|
videoUrl?: string;
|
||||||
youtubeUrl?: string;
|
youtubeUrl?: string | null;
|
||||||
materialUrl?: string;
|
materialUrl?: string;
|
||||||
contentType?: LessonContentType;
|
contentType?: LessonContentType;
|
||||||
|
lectureContent?: string;
|
||||||
|
activity?: LessonActivityMeta | null;
|
||||||
estimatedDurationMinutes?: number;
|
estimatedDurationMinutes?: number;
|
||||||
isPreview?: boolean; // maps to DB field isFreePreview
|
isPreview?: boolean; // maps to DB field isFreePreview
|
||||||
isPublished?: boolean; // optional: for later
|
isPublished?: boolean; // optional: for later
|
||||||
@@ -200,7 +203,7 @@ export async function updateLesson(lessonId: string, data: {
|
|||||||
const updateData: Prisma.LessonUpdateInput = { updatedAt: new Date() };
|
const updateData: Prisma.LessonUpdateInput = { updatedAt: new Date() };
|
||||||
if (data.title !== undefined) updateData.title = data.title;
|
if (data.title !== undefined) updateData.title = data.title;
|
||||||
if (data.videoUrl !== undefined) updateData.videoUrl = data.videoUrl;
|
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) {
|
if (data.estimatedDurationMinutes !== undefined) {
|
||||||
const minutes = Math.max(0, Math.round(data.estimatedDurationMinutes));
|
const minutes = Math.max(0, Math.round(data.estimatedDurationMinutes));
|
||||||
updateData.estimatedDuration = minutes * 60;
|
updateData.estimatedDuration = minutes * 60;
|
||||||
@@ -208,7 +211,11 @@ export async function updateLesson(lessonId: string, data: {
|
|||||||
if (data.isPreview !== undefined) updateData.isFreePreview = data.isPreview;
|
if (data.isPreview !== undefined) updateData.isFreePreview = data.isPreview;
|
||||||
|
|
||||||
const shouldUpdateMeta =
|
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) {
|
if (shouldUpdateMeta) {
|
||||||
const lesson = await db.lesson.findUnique({
|
const lesson = await db.lesson.findUnique({
|
||||||
@@ -221,7 +228,9 @@ export async function updateLesson(lessonId: string, data: {
|
|||||||
text: data.description ?? existingMeta.text,
|
text: data.description ?? existingMeta.text,
|
||||||
contentType: data.contentType ?? existingMeta.contentType,
|
contentType: data.contentType ?? existingMeta.contentType,
|
||||||
materialUrl: data.materialUrl ?? existingMeta.materialUrl,
|
materialUrl: data.materialUrl ?? existingMeta.materialUrl,
|
||||||
});
|
lectureContent: data.lectureContent ?? existingMeta.lectureContent,
|
||||||
|
activity: data.activity ?? existingMeta.activity,
|
||||||
|
}) as Prisma.InputJsonValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.lesson.update({
|
await db.lesson.update({
|
||||||
@@ -300,7 +309,7 @@ export async function updateModuleTitle(moduleId: string, title: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. CREATE LESSON
|
// 2. CREATE LESSON
|
||||||
export async function createLesson(moduleId: string) {
|
export async function createLesson(moduleId: string, contentType: LessonContentType = "VIDEO") {
|
||||||
const user = await requireTeacher();
|
const user = await requireTeacher();
|
||||||
if (!user) return { success: false, error: "Unauthorized" };
|
if (!user) return { success: false, error: "Unauthorized" };
|
||||||
|
|
||||||
@@ -318,9 +327,11 @@ export async function createLesson(moduleId: string) {
|
|||||||
data: {
|
data: {
|
||||||
moduleId,
|
moduleId,
|
||||||
title: "Nueva Lección",
|
title: "Nueva Lección",
|
||||||
description: {
|
description: buildLessonDescriptionMeta({
|
||||||
contentType: "VIDEO",
|
text: "",
|
||||||
},
|
contentType,
|
||||||
|
lectureContent: "",
|
||||||
|
}) as Prisma.InputJsonValue,
|
||||||
orderIndex: newOrder,
|
orderIndex: newOrder,
|
||||||
estimatedDuration: 0,
|
estimatedDuration: 0,
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export default async function CourseEditPage({ params }: { params: Promise<{ slu
|
|||||||
include: {
|
include: {
|
||||||
lessons: {
|
lessons: {
|
||||||
orderBy: { orderIndex: "asc" },
|
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()
|
// Key forces remount when course updates so uncontrolled inputs (level, status) show new defaultValues after save + router.refresh()
|
||||||
return <TeacherEditCourseForm key={`${course.id}-${course.updatedAt.toISOString()}`} course={courseData} />;
|
return <TeacherEditCourseForm key={`${course.id}-${course.updatedAt.toISOString()}`} course={courseData} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,222 +1,676 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updateLesson } from "@/app/(protected)/teacher/actions";
|
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 { 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 {
|
interface LessonEditorFormProps {
|
||||||
lesson: {
|
lesson: {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
videoUrl?: string | null;
|
videoUrl?: string | null;
|
||||||
youtubeUrl?: string | null;
|
youtubeUrl?: string | null;
|
||||||
isFreePreview?: boolean;
|
materialUrl?: string | null;
|
||||||
contentType: LessonContentType;
|
isFreePreview?: boolean;
|
||||||
materialUrl?: string | null;
|
contentType: LessonContentType;
|
||||||
estimatedDurationMinutes: number;
|
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) {
|
export function LessonEditorForm({ lesson, courseSlug }: LessonEditorFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const lectureRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const [title, setTitle] = useState(lesson.title);
|
const [loading, setLoading] = useState(false);
|
||||||
const [description, setDescription] = useState(lesson.description ?? "");
|
const [title, setTitle] = useState(lesson.title);
|
||||||
const [youtubeUrl, setYoutubeUrl] = useState(lesson.youtubeUrl ?? "");
|
const [youtubeUrl, setYoutubeUrl] = useState(lesson.youtubeUrl ?? "");
|
||||||
const [isFreePreview, setIsFreePreview] = useState(lesson.isFreePreview ?? false);
|
const [videoUrl, setVideoUrl] = useState(lesson.videoUrl ?? "");
|
||||||
const [contentType, setContentType] = useState<LessonContentType>(lesson.contentType);
|
const [materialUrl, setMaterialUrl] = useState(lesson.materialUrl ?? "");
|
||||||
const [materialUrl, setMaterialUrl] = useState(lesson.materialUrl ?? "");
|
const [isFreePreview, setIsFreePreview] = useState(lesson.isFreePreview ?? false);
|
||||||
const [durationMinutes, setDurationMinutes] = useState(lesson.estimatedDurationMinutes);
|
const [contentType, setContentType] = useState<LessonContentType>(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<EditableQuestion[]>(
|
||||||
|
lesson.activity?.questions?.length ? lesson.activity.questions : [],
|
||||||
|
);
|
||||||
|
|
||||||
const showSavedToast = () => {
|
const handleVideoUploaded = (url: string) => {
|
||||||
const isSpanish = getClientLocale() === "es";
|
setVideoUrl(url);
|
||||||
toast.success(isSpanish ? "Cambios guardados" : "Changes saved", {
|
toast.success("Video cargado en el formulario (no olvides Guardar Cambios)");
|
||||||
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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1. Auto-save Video URL when upload finishes
|
const handleMaterialUploaded = (url: string) => {
|
||||||
const handleVideoUploaded = async (url: string) => {
|
setMaterialUrl(url);
|
||||||
toast.loading("Guardando video...");
|
toast.success("Material cargado en el formulario (no olvides Guardar Cambios)");
|
||||||
const res = await updateLesson(lesson.id, { videoUrl: url });
|
};
|
||||||
|
|
||||||
if (res.success) {
|
const showSavedToast = () => {
|
||||||
toast.dismiss();
|
const isSpanish = getClientLocale() === "es";
|
||||||
toast.success("Video guardado correctamente");
|
toast.success(isSpanish ? "Cambios guardados" : "Changes saved", {
|
||||||
router.refresh(); // Update the UI to show the new video player
|
description: isSpanish
|
||||||
} else {
|
? "Puedes seguir editando o volver al panel de cursos."
|
||||||
toast.error("Error al guardar el video en la base de datos");
|
: "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)
|
if (question.options.length < 2) {
|
||||||
const handleSave = async () => {
|
toast.error(`La pregunta ${index + 1} debe tener al menos 2 opciones.`);
|
||||||
setLoading(true);
|
return { ok: false, normalizedQuestions: [] };
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
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);
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
if (res.success) {
|
||||||
|
showSavedToast();
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error("Error al guardar cambios");
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
{/* LEFT: Video & Content */}
|
const lecturePreview = markdownToSafeHtml(lectureContent);
|
||||||
<div className="lg:col-span-2 space-y-6">
|
const videoEmbedUrl = getYouTubeEmbedUrl(youtubeUrl);
|
||||||
|
|
||||||
{/* Video Upload Section */}
|
return (
|
||||||
<section className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||||
<div className="p-6 border-b border-slate-100">
|
<div className="space-y-6 lg:col-span-2">
|
||||||
<h2 className="font-semibold text-slate-900">Video del Curso</h2>
|
<section className="space-y-4 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
<p className="text-sm text-slate-500">Sube el video principal de esta lección.</p>
|
<div>
|
||||||
</div>
|
<label className="mb-1 block text-sm font-medium text-slate-700">Titulo de la Leccion</label>
|
||||||
<div className="p-6 bg-slate-50/50">
|
<input
|
||||||
<VideoUpload
|
value={title}
|
||||||
lessonId={lesson.id}
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
currentVideoUrl={lesson.videoUrl}
|
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
|
||||||
onUploadComplete={handleVideoUploaded}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="border-t border-slate-100 p-6">
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">YouTube URL</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={youtubeUrl}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-slate-500">Si se proporciona, se usará en lugar del video subido en la lección.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Text Content */}
|
{contentType === "VIDEO" ? (
|
||||||
<section className="bg-white rounded-xl border border-slate-200 shadow-sm p-6 space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<label className="block text-sm font-medium text-slate-700">Contenido de Video</label>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">Título de la Lección</label>
|
|
||||||
<input
|
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||||
value={title}
|
<p className="mb-2 text-xs font-semibold text-slate-500 uppercase">Opción 1: Subida directa</p>
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
<VideoUpload
|
||||||
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
|
lessonId={lesson.id}
|
||||||
/>
|
currentVideoUrl={videoUrl}
|
||||||
</div>
|
onUploadComplete={handleVideoUploaded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">Descripción / Notas</label>
|
<p className="mb-2 text-xs font-semibold text-slate-500 uppercase">Opción 2: YouTube URL</p>
|
||||||
<textarea
|
<input
|
||||||
rows={6}
|
type="url"
|
||||||
value={description}
|
value={youtubeUrl}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(event) => setYoutubeUrl(event.target.value)}
|
||||||
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
|
placeholder="https://www.youtube.com/watch?v=..."
|
||||||
placeholder="Escribe aquí el contenido de la lección..."
|
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<p className="text-xs text-slate-500 italic">Si ambas están presentes, se priorizará YouTube en la vista del alumno.</p>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">URL de material (PDF / actividad / lectura)</label>
|
<div className="overflow-hidden rounded-lg border border-slate-200 bg-slate-100">
|
||||||
<input
|
{videoEmbedUrl ? (
|
||||||
type="url"
|
<iframe
|
||||||
value={materialUrl}
|
className="aspect-video w-full"
|
||||||
onChange={(e) => setMaterialUrl(e.target.value)}
|
src={videoEmbedUrl}
|
||||||
placeholder="https://.../material.pdf"
|
title="Vista previa de YouTube"
|
||||||
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
/>
|
allowFullScreen
|
||||||
<p className="mt-1 text-xs text-slate-500">Se usará en la vista del alumno como lectura o actividad descargable.</p>
|
/>
|
||||||
</div>
|
) : videoUrl ? (
|
||||||
</section>
|
<div className="flex aspect-video items-center justify-center bg-black text-sm text-white">
|
||||||
|
<video src={videoUrl} controls className="w-full h-full" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex aspect-video items-center justify-center text-sm text-slate-500">
|
||||||
|
Agrega una URL o sube un video para previsualizar.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* RIGHT: Settings / Actions */}
|
<section className="mt-8 border-t border-slate-100 pt-6">
|
||||||
<div className="space-y-6">
|
<h3 className="mb-4 text-lg font-semibold text-slate-900">Material Descargable</h3>
|
||||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6">
|
<FileUpload
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">Tipo de contenido</h3>
|
lessonId={lesson.id}
|
||||||
<label className="block text-sm text-slate-700 mb-2">Formato de la lección</label>
|
currentFileUrl={materialUrl}
|
||||||
<select
|
onUploadComplete={handleMaterialUploaded}
|
||||||
value={contentType}
|
onRemove={() => setMaterialUrl("")}
|
||||||
onChange={(e) => setContentType(e.target.value as LessonContentType)}
|
/>
|
||||||
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
|
<p className="mt-2 text-xs text-slate-500">Este archivo aparecerá como un recurso descargable para el alumno en esta lección.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{contentType === "LECTURE" ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block text-sm font-medium text-slate-700">Contenido de la lectura</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertMarkdown("## ")}
|
||||||
|
className="rounded-md border border-slate-300 bg-white px-2 py-1 text-xs font-medium text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Titulo
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertMarkdown("**", "**")}
|
||||||
|
className="rounded-md border border-slate-300 bg-white px-2 py-1 text-xs font-medium text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Negrita
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertMarkdown("*", "*")}
|
||||||
|
className="rounded-md border border-slate-300 bg-white px-2 py-1 text-xs font-medium text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Cursiva
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertMarkdown("- ")}
|
||||||
|
className="rounded-md border border-slate-300 bg-white px-2 py-1 text-xs font-medium text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Lista
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertMarkdown("[", "](https://...)")}
|
||||||
|
className="rounded-md border border-slate-300 bg-white px-2 py-1 text-xs font-medium text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Enlace
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref={lectureRef}
|
||||||
|
rows={14}
|
||||||
|
value={lectureContent}
|
||||||
|
onChange={(event) => setLectureContent(event.target.value)}
|
||||||
|
className="w-full rounded-md border border-slate-300 px-3 py-2 font-mono text-sm outline-none focus:border-black"
|
||||||
|
placeholder="Escribe la lectura con formato..."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Usa el toolbar para formato rapido. La vista previa muestra como lo vera el alumno.
|
||||||
|
</p>
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-slate-50 p-5">
|
||||||
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-500">Vista previa</p>
|
||||||
|
<article
|
||||||
|
className="space-y-4 text-[15px] leading-7 text-slate-800 [&_a]:text-blue-700 [&_a]:underline [&_blockquote]:border-l-4 [&_blockquote]:border-slate-300 [&_blockquote]:pl-3 [&_h1]:mt-5 [&_h1]:text-2xl [&_h1]:font-semibold [&_h2]:mt-4 [&_h2]:text-xl [&_h2]:font-semibold [&_h3]:mt-3 [&_h3]:text-lg [&_h3]:font-semibold [&_ol]:list-decimal [&_ol]:space-y-1 [&_ol]:pl-6 [&_p]:whitespace-pre-wrap [&_ul]:list-disc [&_ul]:space-y-1 [&_ul]:pl-6"
|
||||||
|
dangerouslySetInnerHTML={{ __html: lecturePreview }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isAssessmentType(contentType) ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-slate-700">Instrucciones de la actividad</label>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={activityInstructions}
|
||||||
|
onChange={(event) => setActivityInstructions(event.target.value)}
|
||||||
|
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm outline-none focus:border-black"
|
||||||
|
placeholder="Ej. Responde cada pregunta y revisa tus respuestas al final."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{contentType !== "ACTIVITY" ? (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-slate-700">Puntaje minimo para aprobar (%)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={passingScorePercent}
|
||||||
|
onChange={(event) => setPassingScorePercent(Math.max(0, Math.min(100, Number(event.target.value) || 0)))}
|
||||||
|
className="w-40 rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900">Preguntas</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActivityQuestions((prev) => [...prev, createTrueFalseQuestion()])}
|
||||||
|
className="rounded-md border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 hover:bg-slate-100"
|
||||||
>
|
>
|
||||||
{lessonContentTypes.map((type) => (
|
+ Verdadero/Falso
|
||||||
<option key={type} value={type}>
|
</button>
|
||||||
{getLessonContentTypeLabel(type)}
|
<button
|
||||||
</option>
|
type="button"
|
||||||
))}
|
onClick={() => setActivityQuestions((prev) => [...prev, createMultipleChoiceQuestion()])}
|
||||||
</select>
|
className="rounded-md border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 hover:bg-slate-100"
|
||||||
<p className="mt-2 text-xs text-slate-500">
|
>
|
||||||
Usa “Evaluación final” para marcar el examen obligatorio del curso.
|
+ Opcion multiple
|
||||||
</p>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="block text-sm text-slate-700 mb-1 mt-4">Duración estimada (minutos)</label>
|
{activityQuestions.length === 0 ? (
|
||||||
<input
|
<p className="rounded-lg border border-dashed border-slate-300 bg-white px-3 py-4 text-sm text-slate-500">
|
||||||
type="number"
|
Todavia no hay preguntas. Agrega la primera para construir la actividad.
|
||||||
min={0}
|
</p>
|
||||||
value={durationMinutes}
|
) : null}
|
||||||
onChange={(e) => setDurationMinutes(Math.max(0, Number(e.target.value)))}
|
|
||||||
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
|
{activityQuestions.map((question, index) => (
|
||||||
|
<div key={question.id} className="space-y-3 rounded-lg border border-slate-200 bg-white p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="text-sm font-semibold text-slate-900">Pregunta {index + 1}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={question.kind}
|
||||||
|
onChange={(event) => changeQuestionKind(question.id, event.target.value as LessonQuestionKind)}
|
||||||
|
className="rounded-md border border-slate-300 px-2 py-1 text-xs outline-none focus:border-black"
|
||||||
|
>
|
||||||
|
<option value="TRUE_FALSE">Verdadero/Falso</option>
|
||||||
|
<option value="MULTIPLE_CHOICE">Opcion multiple</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActivityQuestions((prev) => prev.filter((item) => item.id !== question.id))}
|
||||||
|
className="rounded-md border border-rose-300 px-2 py-1 text-xs font-medium text-rose-700 hover:bg-rose-50"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={question.prompt}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateQuestion(question.id, (current) => ({ ...current, prompt: event.target.value }))
|
||||||
|
}
|
||||||
|
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm outline-none focus:border-black"
|
||||||
|
placeholder="Escribe el enunciado..."
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6">
|
{question.kind === "TRUE_FALSE" ? (
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">Vista previa gratuita</h3>
|
<div className="space-y-2 rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||||
<label className="flex cursor-pointer items-center gap-2">
|
{question.options.map((option) => (
|
||||||
<input
|
<label key={option.id} className="flex items-center gap-2 text-sm text-slate-700">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={isFreePreview}
|
type="radio"
|
||||||
onChange={(e) => setIsFreePreview(e.target.checked)}
|
checked={option.isCorrect}
|
||||||
className="h-4 w-4 rounded border-slate-300"
|
onChange={() =>
|
||||||
/>
|
updateQuestion(question.id, (current) => ({
|
||||||
<span className="text-sm text-slate-700">Accesible sin inscripción (teaser)</span>
|
...current,
|
||||||
</label>
|
options: current.options.map((item) => ({
|
||||||
<p className="mt-2 text-xs text-slate-500">Los no inscritos podrán ver esta lección sin comprar el curso.</p>
|
...item,
|
||||||
</div>
|
isCorrect: item.id === option.id,
|
||||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6">
|
})),
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">Acciones</h3>
|
}))
|
||||||
<button
|
}
|
||||||
onClick={handleSave}
|
/>
|
||||||
disabled={loading}
|
<span>{option.text}</span>
|
||||||
className="w-full bg-black text-white rounded-lg py-2.5 font-medium hover:bg-slate-800 disabled:opacity-50 transition-colors"
|
</label>
|
||||||
>
|
))}
|
||||||
{loading ? "Guardando..." : "Guardar Cambios"}
|
</div>
|
||||||
</button>
|
) : (
|
||||||
|
<div className="space-y-2 rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||||
|
{question.options.map((option) => (
|
||||||
|
<div key={option.id} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={option.isCorrect}
|
||||||
|
onChange={() =>
|
||||||
|
updateQuestion(question.id, (current) => ({
|
||||||
|
...current,
|
||||||
|
options: current.options.map((item) => ({
|
||||||
|
...item,
|
||||||
|
isCorrect: item.id === option.id,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={option.text}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateQuestion(question.id, (current) => ({
|
||||||
|
...current,
|
||||||
|
options: current.options.map((item) =>
|
||||||
|
item.id === option.id ? { ...item, text: event.target.value } : item,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="flex-1 rounded-md border border-slate-300 px-2 py-1 text-sm outline-none focus:border-black"
|
||||||
|
placeholder="Texto de la opcion"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
updateQuestion(question.id, (current) => ({
|
||||||
|
...current,
|
||||||
|
options: current.options.filter((item) => item.id !== option.id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="rounded border border-slate-300 px-2 py-1 text-xs text-slate-600 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
updateQuestion(question.id, (current) => ({
|
||||||
|
...current,
|
||||||
|
options: [...current.options, { id: createId("opt"), text: "", isCorrect: false }],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="rounded-md border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
+ Agregar opcion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<input
|
||||||
onClick={() => router.push(`/teacher/courses/${courseSlug}/edit`)}
|
value={question.explanation}
|
||||||
className="w-full mt-3 bg-white border border-slate-300 text-slate-700 rounded-lg py-2.5 font-medium hover:bg-slate-50 transition-colors"
|
onChange={(event) =>
|
||||||
>
|
updateQuestion(question.id, (current) => ({ ...current, explanation: event.target.value }))
|
||||||
Volver al Curso
|
}
|
||||||
</button>
|
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm outline-none focus:border-black"
|
||||||
</div>
|
placeholder="Explicacion breve (opcional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<h3 className="mb-4 font-semibold text-slate-900">Tipo de contenido</h3>
|
||||||
|
<label className="mb-2 block text-sm text-slate-700">Formato de la leccion</label>
|
||||||
|
<select
|
||||||
|
value={contentType}
|
||||||
|
onChange={(event) => 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) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{getLessonContentTypeLabel(type)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">Usa Evaluacion final para marcar el examen obligatorio del curso.</p>
|
||||||
|
|
||||||
|
<label className="mb-1 mt-4 block text-sm text-slate-700">Duracion estimada (minutos)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={durationMinutes}
|
||||||
|
onChange={(event) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<h3 className="mb-4 font-semibold text-slate-900">Vista previa gratuita</h3>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isFreePreview}
|
||||||
|
onChange={(event) => setIsFreePreview(event.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-slate-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-700">Accesible sin inscripcion (teaser)</span>
|
||||||
|
</label>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">Los no inscritos podran ver esta leccion sin comprar el curso.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<h3 className="mb-4 font-semibold text-slate-900">Acciones</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded-lg bg-black py-2.5 font-medium text-white transition-colors hover:bg-slate-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Guardando..." : "Guardar Cambios"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/teacher/courses/${courseSlug}/edit`)}
|
||||||
|
className="mt-3 w-full rounded-lg border border-slate-300 bg-white py-2.5 font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Volver al Curso
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export default async function LessonPage({ params }: PageProps) {
|
|||||||
title: getText(lesson.title),
|
title: getText(lesson.title),
|
||||||
description: lessonMeta.text,
|
description: lessonMeta.text,
|
||||||
contentType: lessonMeta.contentType,
|
contentType: lessonMeta.contentType,
|
||||||
|
lectureContent: lessonMeta.lectureContent,
|
||||||
|
activity: lessonMeta.activity,
|
||||||
materialUrl: lessonMeta.materialUrl,
|
materialUrl: lessonMeta.materialUrl,
|
||||||
estimatedDurationMinutes: Math.max(0, Math.ceil((lesson.estimatedDuration ?? 0) / 60)),
|
estimatedDurationMinutes: Math.max(0, Math.ceil((lesson.estimatedDuration ?? 0) / 60)),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ export default async function CourseLearnPage({ params, searchParams }: PageProp
|
|||||||
title: getText(lesson.title) || "Untitled lesson",
|
title: getText(lesson.title) || "Untitled lesson",
|
||||||
description: lessonMeta.text,
|
description: lessonMeta.text,
|
||||||
contentType: lessonMeta.contentType,
|
contentType: lessonMeta.contentType,
|
||||||
|
lectureContent: lessonMeta.lectureContent,
|
||||||
|
activity: lessonMeta.activity,
|
||||||
materialUrl: lessonMeta.materialUrl,
|
materialUrl: lessonMeta.materialUrl,
|
||||||
videoUrl: lesson.videoUrl,
|
videoUrl: lesson.videoUrl,
|
||||||
youtubeUrl: lesson.youtubeUrl,
|
youtubeUrl: lesson.youtubeUrl,
|
||||||
|
|||||||
@@ -4,14 +4,23 @@ import { useEffect, useMemo, useState, useTransition } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Check, CircleDashed, ClipboardCheck, FileText, Lock, PlayCircle } from "lucide-react";
|
import { Check, CircleDashed, ClipboardCheck, FileText, Lock, PlayCircle } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { toggleLessonComplete } from "@/app/(public)/courses/[slug]/learn/actions";
|
import { toggleLessonComplete } from "@/app/(public)/courses/[slug]/learn/actions";
|
||||||
import ProgressBar from "@/components/ProgressBar";
|
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 = {
|
type ClassroomLesson = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
lectureContent: string;
|
||||||
|
activity: LessonActivityMeta | null;
|
||||||
contentType: LessonContentType;
|
contentType: LessonContentType;
|
||||||
materialUrl: string | null;
|
materialUrl: string | null;
|
||||||
videoUrl: string | null;
|
videoUrl: string | null;
|
||||||
@@ -40,6 +49,14 @@ type CompletionCertificate = {
|
|||||||
certificateNumber: string | null;
|
certificateNumber: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ActivityResult = {
|
||||||
|
score: number;
|
||||||
|
correct: number;
|
||||||
|
total: number;
|
||||||
|
passed: boolean;
|
||||||
|
questionResults: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
function getYouTubeEmbedUrl(url: string | null | undefined): string | null {
|
function getYouTubeEmbedUrl(url: string | null | undefined): string | null {
|
||||||
if (!url?.trim()) return null;
|
if (!url?.trim()) return null;
|
||||||
const trimmed = url.trim();
|
const trimmed = url.trim();
|
||||||
@@ -57,6 +74,10 @@ function getIsPdfUrl(url: string | null | undefined): boolean {
|
|||||||
return /\.pdf(?:$|\?)/i.test(url.trim());
|
return /\.pdf(?:$|\?)/i.test(url.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAssessmentType(contentType: LessonContentType): boolean {
|
||||||
|
return contentType === "ACTIVITY" || contentType === "QUIZ" || contentType === "FINAL_EXAM";
|
||||||
|
}
|
||||||
|
|
||||||
export default function StudentClassroomClient({
|
export default function StudentClassroomClient({
|
||||||
courseSlug,
|
courseSlug,
|
||||||
courseTitle,
|
courseTitle,
|
||||||
@@ -70,6 +91,8 @@ export default function StudentClassroomClient({
|
|||||||
const [selectedLessonId, setSelectedLessonId] = useState(initialSelectedLessonId);
|
const [selectedLessonId, setSelectedLessonId] = useState(initialSelectedLessonId);
|
||||||
const [completedLessonIds, setCompletedLessonIds] = useState(initialCompletedLessonIds);
|
const [completedLessonIds, setCompletedLessonIds] = useState(initialCompletedLessonIds);
|
||||||
const [completionCertificate, setCompletionCertificate] = useState<CompletionCertificate | null>(null);
|
const [completionCertificate, setCompletionCertificate] = useState<CompletionCertificate | null>(null);
|
||||||
|
const [activityAnswers, setActivityAnswers] = useState<Record<string, string>>({});
|
||||||
|
const [activityResult, setActivityResult] = useState<ActivityResult | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedLessonId(initialSelectedLessonId);
|
setSelectedLessonId(initialSelectedLessonId);
|
||||||
@@ -89,10 +112,17 @@ export default function StudentClassroomClient({
|
|||||||
const selectedLesson =
|
const selectedLesson =
|
||||||
flatLessons.find((lesson) => lesson.id === selectedLessonId) ?? flatLessons[0] ?? null;
|
flatLessons.find((lesson) => lesson.id === selectedLessonId) ?? flatLessons[0] ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActivityAnswers({});
|
||||||
|
setActivityResult(null);
|
||||||
|
}, [selectedLesson?.id]);
|
||||||
|
|
||||||
const selectedLessonTypeLabel = selectedLesson ? getLessonContentTypeLabel(selectedLesson.contentType) : "";
|
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) => {
|
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);
|
const lessonIndex = flatLessons.findIndex((lesson) => lesson.id === lessonId);
|
||||||
if (lessonIndex <= 0) return false;
|
if (lessonIndex <= 0) return false;
|
||||||
if (completedSet.has(lessonId)) return false;
|
if (completedSet.has(lessonId)) return false;
|
||||||
@@ -109,7 +139,7 @@ export default function StudentClassroomClient({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleComplete = async () => {
|
const handleToggleComplete = async () => {
|
||||||
if (!selectedLesson || isSaving) return;
|
if (!selectedLesson || isSaving || !isEnrolled) return;
|
||||||
|
|
||||||
const lessonId = selectedLesson.id;
|
const lessonId = selectedLesson.id;
|
||||||
const wasCompleted = completedSet.has(lessonId);
|
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<string, boolean> = {};
|
||||||
|
|
||||||
|
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) {
|
if (!selectedLesson) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-6">
|
<div className="rounded-xl border border-slate-200 bg-white p-6">
|
||||||
@@ -217,6 +281,7 @@ export default function StudentClassroomClient({
|
|||||||
`}</style>
|
`}</style>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5">
|
<section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5">
|
||||||
<Link href={`/courses/${courseSlug}`} className="text-sm font-medium text-slate-600 hover:text-slate-900">
|
<Link href={`/courses/${courseSlug}`} className="text-sm font-medium text-slate-600 hover:text-slate-900">
|
||||||
{"<-"} Back to Course
|
{"<-"} Back to Course
|
||||||
@@ -255,12 +320,113 @@ export default function StudentClassroomClient({
|
|||||||
key={`${selectedLesson.id}-${selectedLesson.videoUrl}`}
|
key={`${selectedLesson.id}-${selectedLesson.videoUrl}`}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
controls
|
controls
|
||||||
onEnded={handleToggleComplete}
|
onEnded={() => {
|
||||||
|
if (isEnrolled) {
|
||||||
|
void handleToggleComplete();
|
||||||
|
}
|
||||||
|
}}
|
||||||
src={selectedLesson.videoUrl}
|
src={selectedLesson.videoUrl}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-slate-300">Video not available for this lesson</div>
|
<div className="flex h-full items-center justify-center text-sm text-slate-300">
|
||||||
|
Video not available for this lesson
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
|
) : selectedLesson.contentType === "LECTURE" ? (
|
||||||
|
<article
|
||||||
|
className="space-y-4 text-[16px] leading-8 text-slate-800 [&_a]:font-medium [&_a]:text-blue-700 [&_a]:underline [&_blockquote]:rounded-r-md [&_blockquote]:border-l-4 [&_blockquote]:border-slate-300 [&_blockquote]:bg-slate-100 [&_blockquote]:px-4 [&_blockquote]:py-2 [&_h1]:mt-6 [&_h1]:text-3xl [&_h1]:font-semibold [&_h2]:mt-5 [&_h2]:text-2xl [&_h2]:font-semibold [&_h3]:mt-4 [&_h3]:text-xl [&_h3]:font-semibold [&_ol]:list-decimal [&_ol]:space-y-2 [&_ol]:pl-7 [&_p]:my-3 [&_ul]:list-disc [&_ul]:space-y-2 [&_ul]:pl-7"
|
||||||
|
dangerouslySetInnerHTML={{ __html: selectedLectureHtml }}
|
||||||
|
/>
|
||||||
|
) : isAssessmentType(selectedLesson.contentType) && selectedLessonActivity ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="inline-flex rounded-full border border-slate-300 bg-white px-3 py-1 text-xs font-semibold uppercase tracking-wide text-slate-700">
|
||||||
|
{selectedLessonTypeLabel}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{selectedLessonActivity.instructions ? (
|
||||||
|
<p className="rounded-lg border border-slate-200 bg-white px-4 py-3 text-sm leading-relaxed text-slate-700">
|
||||||
|
{selectedLessonActivity.instructions}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{selectedLessonActivity.questions.map((question, index) => (
|
||||||
|
<div key={question.id} className="rounded-xl border border-slate-200 bg-white p-4">
|
||||||
|
<p className="text-sm font-semibold text-slate-900">
|
||||||
|
{index + 1}. {question.prompt}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 grid gap-2">
|
||||||
|
{question.options.map((option) => {
|
||||||
|
const isSelected = activityAnswers[question.id] === option.id;
|
||||||
|
const showResult = Boolean(activityResult);
|
||||||
|
const isCorrect = option.isCorrect;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setActivityAnswers((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[question.id]: option.id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
isSelected ? "border-slate-500 bg-slate-900 text-white" : "border-slate-300 bg-white text-slate-700 hover:bg-slate-50"
|
||||||
|
} ${
|
||||||
|
showResult && isCorrect
|
||||||
|
? "border-emerald-300 bg-emerald-50 text-emerald-900"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.text}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{activityResult && question.explanation ? (
|
||||||
|
<p className="mt-3 rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">
|
||||||
|
{question.explanation}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void submitActivity()}
|
||||||
|
className="rounded-md border border-slate-300 bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
Enviar respuestas
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{activityResult ? (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border px-4 py-3 text-sm ${
|
||||||
|
activityResult.passed
|
||||||
|
? "border-emerald-200 bg-emerald-50 text-emerald-900"
|
||||||
|
: "border-amber-200 bg-amber-50 text-amber-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Resultado: {activityResult.correct}/{activityResult.total} ({activityResult.score}%)
|
||||||
|
{selectedLesson.contentType !== "ACTIVITY" ? (
|
||||||
|
<span>
|
||||||
|
{" "}
|
||||||
|
| Mínimo: {selectedLessonActivity.passingScorePercent}%{" "}
|
||||||
|
{activityResult.passed ? "(Aprobado)" : "(No aprobado)"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span> | Actividad completada</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isFinalExam(selectedLesson.contentType) ? (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||||
|
Debes aprobar esta evaluación final para graduarte y emitir el certificado del curso.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="inline-flex rounded-full border border-slate-300 bg-white px-3 py-1 text-xs font-semibold uppercase tracking-wide text-slate-700">
|
<p className="inline-flex rounded-full border border-slate-300 bg-white px-3 py-1 text-xs font-semibold uppercase tracking-wide text-slate-700">
|
||||||
@@ -271,35 +437,29 @@ export default function StudentClassroomClient({
|
|||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-slate-600">Este contenido no tiene descripción adicional.</p>
|
<p className="text-sm text-slate-600">Este contenido no tiene descripción adicional.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedLesson.materialUrl ? (
|
|
||||||
getIsPdfUrl(selectedLesson.materialUrl) ? (
|
|
||||||
<iframe
|
|
||||||
className="h-[430px] w-full rounded-lg border border-slate-200 bg-white"
|
|
||||||
src={selectedLesson.materialUrl}
|
|
||||||
title={`${selectedLesson.title} material`}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<a
|
|
||||||
className="inline-flex rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-800 hover:bg-slate-100"
|
|
||||||
href={selectedLesson.materialUrl}
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Abrir material
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isFinalExam(selectedLesson.contentType) ? (
|
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
|
||||||
Debes completar esta evaluación final para graduarte y emitir el certificado del curso.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedLesson.materialUrl ? (
|
||||||
|
getIsPdfUrl(selectedLesson.materialUrl) ? (
|
||||||
|
<iframe
|
||||||
|
className="h-[430px] w-full rounded-lg border border-slate-200 bg-white"
|
||||||
|
src={selectedLesson.materialUrl}
|
||||||
|
title={`${selectedLesson.title} material`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
className="inline-flex rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-800 hover:bg-slate-100"
|
||||||
|
href={selectedLesson.materialUrl}
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Abrir material
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-2 rounded-xl border border-slate-200 bg-white p-4">
|
<div className="space-y-2 rounded-xl border border-slate-200 bg-white p-4">
|
||||||
<h1 className="text-2xl font-semibold text-slate-900">{selectedLesson.title}</h1>
|
<h1 className="text-2xl font-semibold text-slate-900">{selectedLesson.title}</h1>
|
||||||
<p className="inline-flex w-fit rounded-full border border-slate-300 bg-slate-50 px-2.5 py-0.5 text-xs font-semibold text-slate-700">
|
<p className="inline-flex w-fit rounded-full border border-slate-300 bg-slate-50 px-2.5 py-0.5 text-xs font-semibold text-slate-700">
|
||||||
@@ -313,18 +473,14 @@ export default function StudentClassroomClient({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isEnrolled && (
|
{isEnrolled && !isAssessmentType(selectedLesson.contentType) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleToggleComplete}
|
onClick={() => void handleToggleComplete()}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
className="rounded-md border border-slate-300 bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-60"
|
className="rounded-md border border-slate-300 bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{completedSet.has(selectedLesson.id)
|
{completedSet.has(selectedLesson.id) ? "Marcar como pendiente" : "Marcar como completada"}
|
||||||
? "Marcar como pendiente"
|
|
||||||
: isFinalExam(selectedLesson.contentType)
|
|
||||||
? "Marcar evaluación final como completada"
|
|
||||||
: "Marcar como completada"}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
159
components/teacher/FileUpload.tsx
Normal file
159
components/teacher/FileUpload.tsx
Normal file
@@ -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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-slate-700">{label}</label>
|
||||||
|
|
||||||
|
{currentFileUrl ? (
|
||||||
|
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg bg-white group shadow-sm">
|
||||||
|
<div className="h-10 w-10 bg-slate-100 rounded flex items-center justify-center text-slate-500">
|
||||||
|
<FileDown className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-900 truncate">
|
||||||
|
{decodeURIComponent(fileName || "Archivo adjunto")}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={currentFileUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
Ver archivo actual
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
|
||||||
|
title="Eliminar archivo"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<button className="text-xs bg-slate-100 text-slate-700 px-2 py-1 rounded border border-slate-200 hover:bg-slate-200">
|
||||||
|
Cambiar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative border-2 border-dashed border-slate-300 rounded-lg p-6 hover:border-black hover:bg-slate-50 transition-all cursor-pointer group">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col items-center justify-center text-center space-y-2">
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 className="h-8 w-8 text-slate-400 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-8 w-8 text-slate-400 group-hover:text-black" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-900">
|
||||||
|
{uploading ? "Subiendo archivo..." : "Haz clic o arrastra un archivo aquí"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
Formatos permitidos: <span className="font-semibold">PDF, Word, PowerPoint o Texto (.txt)</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">Máximo 50MB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,11 @@ import { useRouter } from "next/navigation";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getClientLocale } from "@/lib/i18n/clientLocale";
|
import { getClientLocale } from "@/lib/i18n/clientLocale";
|
||||||
|
import {
|
||||||
|
getLessonContentTypeLabel,
|
||||||
|
parseLessonDescriptionMeta,
|
||||||
|
type LessonContentType,
|
||||||
|
} from "@/lib/courses/lessonContent";
|
||||||
|
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
@@ -35,22 +40,44 @@ type CourseData = {
|
|||||||
modules: {
|
modules: {
|
||||||
id: string;
|
id: string;
|
||||||
title: Prisma.JsonValue;
|
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 }) {
|
export default function TeacherEditCourseForm({ course }: { course: CourseData }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [optimisticModules, setOptimisticModules] = useState(course.modules);
|
const [optimisticModules, setOptimisticModules] = useState(course.modules);
|
||||||
const [editingModuleId, setEditingModuleId] = useState<string | null>(null);
|
const [editingModuleId, setEditingModuleId] = useState<string | null>(null);
|
||||||
const [editingTitle, setEditingTitle] = useState("");
|
const [editingTitle, setEditingTitle] = useState("");
|
||||||
|
const [newLessonTypeByModule, setNewLessonTypeByModule] = useState<Record<string, LessonContentType>>({});
|
||||||
const [learningOutcomes, setLearningOutcomes] = useState<string[]>(() =>
|
const [learningOutcomes, setLearningOutcomes] = useState<string[]>(() =>
|
||||||
parseLearningOutcomes(course.learningOutcomes)
|
parseLearningOutcomes(course.learningOutcomes)
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOptimisticModules(course.modules);
|
setOptimisticModules(course.modules);
|
||||||
|
setNewLessonTypeByModule(
|
||||||
|
course.modules.reduce<Record<string, LessonContentType>>((acc, module) => {
|
||||||
|
acc[module.id] = acc[module.id] ?? "VIDEO";
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
);
|
||||||
}, [course.modules]);
|
}, [course.modules]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -134,10 +161,11 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
|
|||||||
|
|
||||||
// 3. CREATE NEW LESSON
|
// 3. CREATE NEW LESSON
|
||||||
const handleAddLesson = async (moduleId: string) => {
|
const handleAddLesson = async (moduleId: string) => {
|
||||||
|
const selectedType = newLessonTypeByModule[moduleId] ?? "VIDEO";
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await createLesson(moduleId);
|
const res = await createLesson(moduleId, selectedType);
|
||||||
if (res.success && res.lessonId) {
|
if (res.success && res.lessonId) {
|
||||||
toast.success("Lección creada");
|
toast.success(`Lección creada (${getLessonContentTypeLabel(selectedType)})`);
|
||||||
// Redirect immediately to the video upload page
|
// Redirect immediately to the video upload page
|
||||||
router.push(`/teacher/courses/${course.slug}/lessons/${res.lessonId}`);
|
router.push(`/teacher/courses/${course.slug}/lessons/${res.lessonId}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -444,61 +472,92 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
|
|||||||
|
|
||||||
{/* Lessons List */}
|
{/* Lessons List */}
|
||||||
<div className="divide-y divide-slate-100">
|
<div className="divide-y divide-slate-100">
|
||||||
{module.lessons.map((lesson, lessonIndex) => (
|
{module.lessons.map((lesson, lessonIndex) => {
|
||||||
<div
|
const lessonMeta = parseLessonDescriptionMeta(lesson.description);
|
||||||
key={lesson.id}
|
const lessonType = lessonMeta.contentType;
|
||||||
className="px-4 py-3 flex items-center gap-2 hover:bg-slate-50 transition-colors group"
|
return (
|
||||||
>
|
<div
|
||||||
<div className="flex flex-col">
|
key={lesson.id}
|
||||||
<button
|
className="px-4 py-3 flex items-center gap-2 hover:bg-slate-50 transition-colors group"
|
||||||
type="button"
|
|
||||||
onClick={() => handleReorderLesson(moduleIndex, lessonIndex, "up")}
|
|
||||||
disabled={lessonIndex === 0}
|
|
||||||
className="p-0.5 rounded text-slate-400 hover:bg-slate-200 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors"
|
|
||||||
aria-label="Mover lección arriba"
|
|
||||||
>
|
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleReorderLesson(moduleIndex, lessonIndex, "down")}
|
|
||||||
disabled={lessonIndex === module.lessons.length - 1}
|
|
||||||
className="p-0.5 rounded text-slate-400 hover:bg-slate-200 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors"
|
|
||||||
aria-label="Mover lección abajo"
|
|
||||||
>
|
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href={`/teacher/courses/${course.slug}/lessons/${lesson.id}`}
|
|
||||||
className="flex items-center gap-3 flex-1 min-w-0"
|
|
||||||
>
|
>
|
||||||
<span className="text-slate-400 text-lg group-hover:text-blue-500 flex-shrink-0">▶</span>
|
<div className="flex flex-col">
|
||||||
<div className="flex-1 min-w-0">
|
<button
|
||||||
<p className="text-sm font-medium text-slate-700 group-hover:text-slate-900">
|
type="button"
|
||||||
{getStr(lesson.title)}
|
onClick={() => handleReorderLesson(moduleIndex, lessonIndex, "up")}
|
||||||
</p>
|
disabled={lessonIndex === 0}
|
||||||
|
className="p-0.5 rounded text-slate-400 hover:bg-slate-200 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors"
|
||||||
|
aria-label="Mover lección arriba"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleReorderLesson(moduleIndex, lessonIndex, "down")}
|
||||||
|
disabled={lessonIndex === module.lessons.length - 1}
|
||||||
|
className="p-0.5 rounded text-slate-400 hover:bg-slate-200 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors"
|
||||||
|
aria-label="Mover lección abajo"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-slate-400 opacity-0 group-hover:opacity-100 border border-slate-200 rounded px-2 py-1 flex-shrink-0">
|
<Link
|
||||||
Editar Contenido
|
href={`/teacher/courses/${course.slug}/lessons/${lesson.id}`}
|
||||||
</span>
|
className="flex items-center gap-3 flex-1 min-w-0"
|
||||||
</Link>
|
>
|
||||||
</div>
|
<span className="text-slate-400 text-lg group-hover:text-blue-500 flex-shrink-0">▶</span>
|
||||||
))}
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="mb-1 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full border px-2 py-0.5 text-[11px] font-semibold ${getLessonTypeBadgeClass(
|
||||||
|
lessonType,
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{getLessonContentTypeLabel(lessonType)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-slate-700 group-hover:text-slate-900">
|
||||||
|
{getStr(lesson.title)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-400 opacity-0 group-hover:opacity-100 border border-slate-200 rounded px-2 py-1 flex-shrink-0">
|
||||||
|
Editar Contenido
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Add Lesson Button */}
|
<div className="flex flex-wrap items-center gap-2 px-4 py-3">
|
||||||
<button
|
<select
|
||||||
type="button"
|
value={newLessonTypeByModule[module.id] ?? "VIDEO"}
|
||||||
onClick={() => handleAddLesson(module.id)}
|
onChange={(event) =>
|
||||||
disabled={loading}
|
setNewLessonTypeByModule((prev) => ({
|
||||||
className="w-full text-left px-4 py-3 text-xs text-slate-500 hover:text-blue-600 hover:bg-slate-50 font-medium flex items-center gap-2 transition-colors"
|
...prev,
|
||||||
>
|
[module.id]: event.target.value as LessonContentType,
|
||||||
<span className="text-lg leading-none">+</span> Agregar Lección
|
}))
|
||||||
</button>
|
}
|
||||||
|
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) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{getLessonContentTypeLabel(type)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAddLesson(module.id)}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-xs text-slate-500 hover:text-blue-600 hover:bg-slate-50 font-medium flex items-center gap-2 transition-colors rounded px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="text-lg leading-none">+</span> Agregar Lección
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -527,8 +586,8 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
|
|||||||
<h3 className="font-semibold text-slate-900 mb-2">💡 Tips</h3>
|
<h3 className="font-semibold text-slate-900 mb-2">💡 Tips</h3>
|
||||||
<ul className="text-sm text-slate-600 space-y-2 list-disc pl-4">
|
<ul className="text-sm text-slate-600 space-y-2 list-disc pl-4">
|
||||||
<li>Crea módulos para organizar tus temas.</li>
|
<li>Crea módulos para organizar tus temas.</li>
|
||||||
<li>Dentro de cada módulo, agrega lecciones.</li>
|
<li>Dentro de cada módulo, agrega lecciones con tipo (video, lectura, actividad, quiz).</li>
|
||||||
<li>Haz clic en una lección para <strong>subir el video</strong>.</li>
|
<li>Haz clic en una lección para editar su contenido según el formato elegido.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function VideoUpload({ lessonId, currentVideoUrl, onUploadComplet
|
|||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
|
|
||||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleVideoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
@@ -26,28 +26,46 @@ export default function VideoUpload({ lessonId, currentVideoUrl, onUploadComplet
|
|||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create a unique file path: lesson_id/timestamp_filename
|
// 1. Verify session
|
||||||
|
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||||
|
if (authError || !user) {
|
||||||
|
toast.error("Authentication error: Please log in again.");
|
||||||
|
setUploading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Authenticated User ID:", user.id);
|
||||||
|
|
||||||
|
// 2. Create a unique file path: lesson_id/timestamp_filename
|
||||||
const filePath = `${lessonId}/${Date.now()}_${file.name}`;
|
const filePath = `${lessonId}/${Date.now()}_${file.name}`;
|
||||||
|
|
||||||
|
// 3. Upload to Supabase Storage
|
||||||
const { error } = await supabase.storage
|
const { error } = await supabase.storage
|
||||||
.from("courses") // Make sure this bucket exists!
|
.from("courses")
|
||||||
.upload(filePath, file, {
|
.upload(filePath, file, {
|
||||||
upsert: true,
|
upsert: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
toast.error("Upload failed: " + error.message);
|
console.error("Storage upload error:", error);
|
||||||
|
// Hint for common RLS issue
|
||||||
|
if (error.message.includes("row-level security policy")) {
|
||||||
|
toast.error("Upload failed: RLS Policy error. Make sure 'courses' bucket allows uploads for authenticated teachers.");
|
||||||
|
} else {
|
||||||
|
toast.error("Upload failed: " + error.message);
|
||||||
|
}
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Public URL
|
// 4. Get Public URL
|
||||||
const { data: { publicUrl } } = supabase.storage
|
const { data: { publicUrl } } = supabase.storage
|
||||||
.from("courses")
|
.from("courses")
|
||||||
.getPublicUrl(filePath);
|
.getPublicUrl(filePath);
|
||||||
|
|
||||||
onUploadComplete(publicUrl);
|
onUploadComplete(publicUrl);
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
|
toast.success("Video subido con éxito");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -73,7 +91,7 @@ export default function VideoUpload({ lessonId, currentVideoUrl, onUploadComplet
|
|||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="video/*"
|
accept="video/*"
|
||||||
onChange={handleUpload}
|
onChange={handleVideoUpload}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
className="block w-full text-sm text-slate-500
|
className="block w-full text-sm text-slate-500
|
||||||
file:mr-4 file:py-2 file:px-4
|
file:mr-4 file:py-2 file:px-4
|
||||||
|
|||||||
21
db_check.ts
Normal file
21
db_check.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
import { Pool } from 'pg'
|
||||||
|
|
||||||
|
const connectionString = process.env.DIRECT_URL || process.env.DATABASE_URL
|
||||||
|
const pool = new Pool({ connectionString })
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
try {
|
||||||
|
const res = await pool.query("SELECT id, name, public FROM storage.buckets;")
|
||||||
|
console.log("Buckets:", JSON.stringify(res.rows, null, 2))
|
||||||
|
|
||||||
|
const res2 = await pool.query("SELECT policyname, cmd, qual, with_check FROM pg_policies WHERE tablename = 'objects' AND schemaname = 'storage';")
|
||||||
|
console.log("Policies:", JSON.stringify(res2.rows, null, 2))
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
await pool.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check()
|
||||||
@@ -2,10 +2,36 @@ export const lessonContentTypes = ["VIDEO", "LECTURE", "ACTIVITY", "QUIZ", "FINA
|
|||||||
|
|
||||||
export type LessonContentType = (typeof lessonContentTypes)[number];
|
export type LessonContentType = (typeof lessonContentTypes)[number];
|
||||||
|
|
||||||
|
export const lessonQuestionKinds = ["TRUE_FALSE", "MULTIPLE_CHOICE"] as const;
|
||||||
|
|
||||||
|
export type LessonQuestionKind = (typeof lessonQuestionKinds)[number];
|
||||||
|
|
||||||
|
export type LessonQuestionOption = {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
isCorrect: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LessonQuestion = {
|
||||||
|
id: string;
|
||||||
|
kind: LessonQuestionKind;
|
||||||
|
prompt: string;
|
||||||
|
explanation: string;
|
||||||
|
options: LessonQuestionOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LessonActivityMeta = {
|
||||||
|
instructions: string;
|
||||||
|
passingScorePercent: number;
|
||||||
|
questions: LessonQuestion[];
|
||||||
|
};
|
||||||
|
|
||||||
type LessonDescriptionMeta = {
|
type LessonDescriptionMeta = {
|
||||||
text: string;
|
text: string;
|
||||||
contentType: LessonContentType;
|
contentType: LessonContentType;
|
||||||
materialUrl: string | null;
|
materialUrl: string | null;
|
||||||
|
lectureContent: string;
|
||||||
|
activity: LessonActivityMeta | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const lessonTypeAliases: Record<string, LessonContentType> = {
|
const lessonTypeAliases: Record<string, LessonContentType> = {
|
||||||
@@ -33,6 +59,15 @@ function asString(value: unknown): string {
|
|||||||
return typeof value === "string" ? value.trim() : "";
|
return typeof value === "string" ? value.trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asNumber(value: unknown): number | null {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeType(value: string): LessonContentType {
|
function normalizeType(value: string): LessonContentType {
|
||||||
const normalized = value.trim().toUpperCase();
|
const normalized = value.trim().toUpperCase();
|
||||||
return lessonTypeAliases[normalized] ?? "VIDEO";
|
return lessonTypeAliases[normalized] ?? "VIDEO";
|
||||||
@@ -53,12 +88,111 @@ function getDescriptionText(input: unknown): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeQuestionKind(value: unknown): LessonQuestionKind {
|
||||||
|
const raw = asString(value).toUpperCase();
|
||||||
|
return raw === "TRUE_FALSE" ? "TRUE_FALSE" : "MULTIPLE_CHOICE";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQuestionOptions(value: unknown, kind: LessonQuestionKind): LessonQuestionOption[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
if (kind === "TRUE_FALSE") {
|
||||||
|
return [
|
||||||
|
{ id: "true", text: "Verdadero", isCorrect: true },
|
||||||
|
{ id: "false", text: "Falso", isCorrect: false },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = value
|
||||||
|
.map((option, index) => {
|
||||||
|
if (!isRecord(option)) return null;
|
||||||
|
const id = asString(option.id) || `opt-${index + 1}`;
|
||||||
|
const text = asString(option.text);
|
||||||
|
const isCorrect = Boolean(option.isCorrect);
|
||||||
|
if (!text) return null;
|
||||||
|
return { id, text, isCorrect };
|
||||||
|
})
|
||||||
|
.filter((option): option is LessonQuestionOption => option !== null);
|
||||||
|
|
||||||
|
if (kind === "TRUE_FALSE") {
|
||||||
|
const hasTrue = options.find((option) => option.id === "true" || option.text.toLowerCase() === "verdadero");
|
||||||
|
const hasFalse = options.find((option) => option.id === "false" || option.text.toLowerCase() === "falso");
|
||||||
|
if (!hasTrue || !hasFalse) {
|
||||||
|
const correctIndex = options.findIndex((option) => option.isCorrect);
|
||||||
|
return [
|
||||||
|
{ id: "true", text: "Verdadero", isCorrect: correctIndex !== 1 },
|
||||||
|
{ id: "false", text: "Falso", isCorrect: correctIndex === 1 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQuestion(value: unknown, index: number): LessonQuestion | null {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
const kind = normalizeQuestionKind(value.kind ?? value.type);
|
||||||
|
const id = asString(value.id) || `q-${index + 1}`;
|
||||||
|
const prompt = asString(value.prompt ?? value.question);
|
||||||
|
const explanation = asString(value.explanation);
|
||||||
|
const options = parseQuestionOptions(value.options, kind);
|
||||||
|
|
||||||
|
if (!prompt) return null;
|
||||||
|
if (options.length < 2) return null;
|
||||||
|
|
||||||
|
const hasCorrect = options.some((option) => option.isCorrect);
|
||||||
|
const normalizedOptions = hasCorrect ? options : options.map((option, optIndex) => ({ ...option, isCorrect: optIndex === 0 }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
prompt,
|
||||||
|
explanation,
|
||||||
|
options: normalizedOptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePassingScore(value: unknown, fallback: number): number {
|
||||||
|
const parsed = asNumber(value);
|
||||||
|
if (parsed == null) return fallback;
|
||||||
|
return Math.max(0, Math.min(100, Math.round(parsed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseActivityMeta(description: Record<string, unknown>): LessonActivityMeta | null {
|
||||||
|
const activityRaw =
|
||||||
|
(isRecord(description.activity) && description.activity) ||
|
||||||
|
(isRecord(description.quiz) && description.quiz) ||
|
||||||
|
(isRecord(description.exercise) && description.exercise) ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
if (!activityRaw) return null;
|
||||||
|
|
||||||
|
const instructions = asString(activityRaw.instructions ?? activityRaw.intro ?? description.es ?? description.text);
|
||||||
|
const passingScorePercent = normalizePassingScore(activityRaw.passingScorePercent, 70);
|
||||||
|
const questionsRaw = Array.isArray(activityRaw.questions) ? activityRaw.questions : [];
|
||||||
|
const questions = questionsRaw
|
||||||
|
.map((question, index) => parseQuestion(question, index))
|
||||||
|
.filter((question): question is LessonQuestion => question !== null);
|
||||||
|
|
||||||
|
if (questions.length === 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
instructions,
|
||||||
|
passingScorePercent,
|
||||||
|
questions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function parseLessonDescriptionMeta(description: unknown): LessonDescriptionMeta {
|
export function parseLessonDescriptionMeta(description: unknown): LessonDescriptionMeta {
|
||||||
if (!isRecord(description)) {
|
if (!isRecord(description)) {
|
||||||
|
const fallbackText = getDescriptionText(description);
|
||||||
return {
|
return {
|
||||||
text: getDescriptionText(description),
|
text: fallbackText,
|
||||||
contentType: "VIDEO",
|
contentType: "VIDEO",
|
||||||
materialUrl: null,
|
materialUrl: null,
|
||||||
|
lectureContent: fallbackText,
|
||||||
|
activity: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,11 +203,16 @@ export function parseLessonDescriptionMeta(description: unknown): LessonDescript
|
|||||||
asString(description.pdfUrl) ||
|
asString(description.pdfUrl) ||
|
||||||
asString(description.attachmentUrl) ||
|
asString(description.attachmentUrl) ||
|
||||||
"";
|
"";
|
||||||
|
const text = getDescriptionText(description);
|
||||||
|
const lectureContent = asString(description.lectureContent) || asString(description.content) || text;
|
||||||
|
const activity = parseActivityMeta(description);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: getDescriptionText(description),
|
text,
|
||||||
contentType: normalizeType(contentTypeRaw || "VIDEO"),
|
contentType: normalizeType(contentTypeRaw || "VIDEO"),
|
||||||
materialUrl: materialUrl || null,
|
materialUrl: materialUrl || null,
|
||||||
|
lectureContent,
|
||||||
|
activity,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,8 +220,10 @@ export function buildLessonDescriptionMeta(input: {
|
|||||||
text: string;
|
text: string;
|
||||||
contentType: LessonContentType;
|
contentType: LessonContentType;
|
||||||
materialUrl?: string | null;
|
materialUrl?: string | null;
|
||||||
}): Record<string, string> {
|
lectureContent?: string | null;
|
||||||
const payload: Record<string, string> = {
|
activity?: LessonActivityMeta | null;
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
contentType: input.contentType,
|
contentType: input.contentType,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,6 +233,44 @@ export function buildLessonDescriptionMeta(input: {
|
|||||||
const materialUrl = (input.materialUrl ?? "").trim();
|
const materialUrl = (input.materialUrl ?? "").trim();
|
||||||
if (materialUrl) payload.materialUrl = materialUrl;
|
if (materialUrl) payload.materialUrl = materialUrl;
|
||||||
|
|
||||||
|
const lectureContent = (input.lectureContent ?? "").trim();
|
||||||
|
if (lectureContent) payload.lectureContent = lectureContent;
|
||||||
|
|
||||||
|
const activity = input.activity;
|
||||||
|
if (activity && activity.questions.length > 0) {
|
||||||
|
payload.activity = {
|
||||||
|
instructions: activity.instructions.trim(),
|
||||||
|
passingScorePercent: Math.max(0, Math.min(100, Math.round(activity.passingScorePercent))),
|
||||||
|
questions: activity.questions.map((question, qIndex) => {
|
||||||
|
const questionId = question.id.trim() || `q-${qIndex + 1}`;
|
||||||
|
const prompt = question.prompt.trim();
|
||||||
|
const explanation = question.explanation.trim();
|
||||||
|
const kind: LessonQuestionKind = question.kind === "TRUE_FALSE" ? "TRUE_FALSE" : "MULTIPLE_CHOICE";
|
||||||
|
const options = question.options
|
||||||
|
.map((option, optionIndex) => {
|
||||||
|
const optionId = option.id.trim() || `opt-${optionIndex + 1}`;
|
||||||
|
const optionText = option.text.trim();
|
||||||
|
if (!optionText) return null;
|
||||||
|
return {
|
||||||
|
id: optionId,
|
||||||
|
text: optionText,
|
||||||
|
isCorrect: Boolean(option.isCorrect),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((option): option is LessonQuestionOption => option !== null);
|
||||||
|
const hasCorrect = options.some((option) => option.isCorrect);
|
||||||
|
const normalizedOptions = hasCorrect ? options : options.map((option, optionIndex) => ({ ...option, isCorrect: optionIndex === 0 }));
|
||||||
|
return {
|
||||||
|
id: questionId,
|
||||||
|
kind,
|
||||||
|
prompt,
|
||||||
|
explanation,
|
||||||
|
options: normalizedOptions,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
126
lib/courses/lessonMarkdown.ts
Normal file
126
lib/courses/lessonMarkdown.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttribute(value: string): string {
|
||||||
|
return value.replaceAll("&", "&").replaceAll('"', """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHttpUrl(rawUrl: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(rawUrl);
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInline(markdown: string): string {
|
||||||
|
let rendered = escapeHtml(markdown);
|
||||||
|
|
||||||
|
rendered = rendered.replace(/`([^`]+)`/g, (_match, value: string) => `<code>${value}</code>`);
|
||||||
|
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, (_match, value: string) => `<strong>${value}</strong>`);
|
||||||
|
rendered = rendered.replace(/\*([^*]+)\*/g, (_match, value: string) => `<em>${value}</em>`);
|
||||||
|
rendered = rendered.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label: string, url: string) => {
|
||||||
|
const safeUrl = normalizeHttpUrl(url);
|
||||||
|
if (!safeUrl) return label;
|
||||||
|
return `<a href="${escapeAttribute(safeUrl)}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markdownToSafeHtml(markdown: string): string {
|
||||||
|
const source = markdown.replace(/\r\n/g, "\n").trim();
|
||||||
|
if (!source) return "<p>Sin contenido.</p>";
|
||||||
|
|
||||||
|
const lines = source.split("\n");
|
||||||
|
const output: string[] = [];
|
||||||
|
let listMode: "ol" | "ul" | null = null;
|
||||||
|
|
||||||
|
const closeList = () => {
|
||||||
|
if (!listMode) return;
|
||||||
|
output.push(listMode === "ol" ? "</ol>" : "</ul>");
|
||||||
|
listMode = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
|
||||||
|
if (!line) {
|
||||||
|
closeList();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const h3 = line.match(/^###\s+(.+)$/);
|
||||||
|
if (h3) {
|
||||||
|
closeList();
|
||||||
|
output.push(`<h3>${renderInline(h3[1])}</h3>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const h2 = line.match(/^##\s+(.+)$/);
|
||||||
|
if (h2) {
|
||||||
|
closeList();
|
||||||
|
output.push(`<h2>${renderInline(h2[1])}</h2>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const h1 = line.match(/^#\s+(.+)$/);
|
||||||
|
if (h1) {
|
||||||
|
closeList();
|
||||||
|
output.push(`<h1>${renderInline(h1[1])}</h1>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordered = line.match(/^\d+\.\s+(.+)$/);
|
||||||
|
if (ordered) {
|
||||||
|
if (listMode !== "ol") {
|
||||||
|
closeList();
|
||||||
|
output.push("<ol>");
|
||||||
|
listMode = "ol";
|
||||||
|
}
|
||||||
|
output.push(`<li>${renderInline(ordered[1])}</li>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unordered = line.match(/^[-*]\s+(.+)$/);
|
||||||
|
if (unordered) {
|
||||||
|
if (listMode !== "ul") {
|
||||||
|
closeList();
|
||||||
|
output.push("<ul>");
|
||||||
|
listMode = "ul";
|
||||||
|
}
|
||||||
|
output.push(`<li>${renderInline(unordered[1])}</li>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quote = line.match(/^>\s+(.+)$/);
|
||||||
|
if (quote) {
|
||||||
|
closeList();
|
||||||
|
output.push(`<blockquote>${renderInline(quote[1])}</blockquote>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeList();
|
||||||
|
output.push(`<p>${renderInline(line)}</p>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeList();
|
||||||
|
return output.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markdownToPlainText(markdown: string): string {
|
||||||
|
return markdown
|
||||||
|
.replace(/\r\n/g, "\n")
|
||||||
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1")
|
||||||
|
.replace(/[`*_>#-]/g, "")
|
||||||
|
.replace(/\n{2,}/g, "\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user