|
|
|
|
@@ -1,222 +1,676 @@
|
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState } from "react";
|
|
|
|
|
import { useRef, useState } from "react";
|
|
|
|
|
import { useRouter } from "next/navigation";
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
import { updateLesson } from "@/app/(protected)/teacher/actions";
|
|
|
|
|
import VideoUpload from "@/components/teacher/VideoUpload"; // The component you created earlier
|
|
|
|
|
import { getClientLocale } from "@/lib/i18n/clientLocale";
|
|
|
|
|
import { getLessonContentTypeLabel, lessonContentTypes, type LessonContentType } from "@/lib/courses/lessonContent";
|
|
|
|
|
import { markdownToPlainText, markdownToSafeHtml } from "@/lib/courses/lessonMarkdown";
|
|
|
|
|
import { getLessonContentTypeLabel, lessonContentTypes, type LessonActivityMeta, type LessonContentType, type LessonQuestion, type LessonQuestionKind } from "@/lib/courses/lessonContent";
|
|
|
|
|
import VideoUpload from "@/components/teacher/VideoUpload";
|
|
|
|
|
import FileUpload from "@/components/teacher/FileUpload";
|
|
|
|
|
|
|
|
|
|
interface LessonEditorFormProps {
|
|
|
|
|
lesson: {
|
|
|
|
|
id: string;
|
|
|
|
|
title: string;
|
|
|
|
|
description?: string | null;
|
|
|
|
|
videoUrl?: string | null;
|
|
|
|
|
youtubeUrl?: string | null;
|
|
|
|
|
isFreePreview?: boolean;
|
|
|
|
|
contentType: LessonContentType;
|
|
|
|
|
materialUrl?: string | null;
|
|
|
|
|
estimatedDurationMinutes: number;
|
|
|
|
|
lesson: {
|
|
|
|
|
id: string;
|
|
|
|
|
title: string;
|
|
|
|
|
description?: string | null;
|
|
|
|
|
videoUrl?: string | null;
|
|
|
|
|
youtubeUrl?: string | null;
|
|
|
|
|
materialUrl?: string | null;
|
|
|
|
|
isFreePreview?: boolean;
|
|
|
|
|
contentType: LessonContentType;
|
|
|
|
|
lectureContent: string;
|
|
|
|
|
activity: LessonActivityMeta | null;
|
|
|
|
|
estimatedDurationMinutes: number;
|
|
|
|
|
};
|
|
|
|
|
courseSlug: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type EditableQuestion = LessonQuestion;
|
|
|
|
|
|
|
|
|
|
function createId(prefix: string): string {
|
|
|
|
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
|
|
|
return `${prefix}-${crypto.randomUUID()}`;
|
|
|
|
|
}
|
|
|
|
|
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createTrueFalseQuestion(): EditableQuestion {
|
|
|
|
|
return {
|
|
|
|
|
id: createId("q"),
|
|
|
|
|
kind: "TRUE_FALSE",
|
|
|
|
|
prompt: "",
|
|
|
|
|
explanation: "",
|
|
|
|
|
options: [
|
|
|
|
|
{ id: "true", text: "Verdadero", isCorrect: true },
|
|
|
|
|
{ id: "false", text: "Falso", isCorrect: false },
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createMultipleChoiceQuestion(): EditableQuestion {
|
|
|
|
|
return {
|
|
|
|
|
id: createId("q"),
|
|
|
|
|
kind: "MULTIPLE_CHOICE",
|
|
|
|
|
prompt: "",
|
|
|
|
|
explanation: "",
|
|
|
|
|
options: [
|
|
|
|
|
{ id: createId("opt"), text: "", isCorrect: true },
|
|
|
|
|
{ id: createId("opt"), text: "", isCorrect: false },
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeQuestion(question: EditableQuestion): EditableQuestion {
|
|
|
|
|
if (question.kind === "TRUE_FALSE") {
|
|
|
|
|
const correctOptionId =
|
|
|
|
|
question.options.find((option) => option.isCorrect)?.id ?? "true";
|
|
|
|
|
return {
|
|
|
|
|
...question,
|
|
|
|
|
options: [
|
|
|
|
|
{ id: "true", text: "Verdadero", isCorrect: correctOptionId === "true" },
|
|
|
|
|
{ id: "false", text: "Falso", isCorrect: correctOptionId === "false" },
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
courseSlug: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const filteredOptions = question.options.filter((option) => option.text.trim());
|
|
|
|
|
const hasCorrect = filteredOptions.some((option) => option.isCorrect);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...question,
|
|
|
|
|
options: filteredOptions.map((option, index) => ({
|
|
|
|
|
...option,
|
|
|
|
|
text: option.text.trim(),
|
|
|
|
|
isCorrect: hasCorrect ? option.isCorrect : index === 0,
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isAssessmentType(contentType: LessonContentType): boolean {
|
|
|
|
|
return contentType === "ACTIVITY" || contentType === "QUIZ" || contentType === "FINAL_EXAM";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getYouTubeEmbedUrl(url: string): string | null {
|
|
|
|
|
const value = url.trim();
|
|
|
|
|
if (!value) return null;
|
|
|
|
|
const watchMatch = value.match(/(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]+)/);
|
|
|
|
|
if (watchMatch) return `https://www.youtube.com/embed/${watchMatch[1]}`;
|
|
|
|
|
const shortMatch = value.match(/(?:youtu\.be\/)([a-zA-Z0-9_-]+)/);
|
|
|
|
|
if (shortMatch) return `https://www.youtube.com/embed/${shortMatch[1]}`;
|
|
|
|
|
const embedMatch = value.match(/(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]+)/);
|
|
|
|
|
if (embedMatch) return value;
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function LessonEditorForm({ lesson, courseSlug }: LessonEditorFormProps) {
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [title, setTitle] = useState(lesson.title);
|
|
|
|
|
const [description, setDescription] = useState(lesson.description ?? "");
|
|
|
|
|
const [youtubeUrl, setYoutubeUrl] = useState(lesson.youtubeUrl ?? "");
|
|
|
|
|
const [isFreePreview, setIsFreePreview] = useState(lesson.isFreePreview ?? false);
|
|
|
|
|
const [contentType, setContentType] = useState<LessonContentType>(lesson.contentType);
|
|
|
|
|
const [materialUrl, setMaterialUrl] = useState(lesson.materialUrl ?? "");
|
|
|
|
|
const [durationMinutes, setDurationMinutes] = useState(lesson.estimatedDurationMinutes);
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
const lectureRef = useRef<HTMLTextAreaElement | null>(null);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [title, setTitle] = useState(lesson.title);
|
|
|
|
|
const [youtubeUrl, setYoutubeUrl] = useState(lesson.youtubeUrl ?? "");
|
|
|
|
|
const [videoUrl, setVideoUrl] = useState(lesson.videoUrl ?? "");
|
|
|
|
|
const [materialUrl, setMaterialUrl] = useState(lesson.materialUrl ?? "");
|
|
|
|
|
const [isFreePreview, setIsFreePreview] = useState(lesson.isFreePreview ?? false);
|
|
|
|
|
const [contentType, setContentType] = useState<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 isSpanish = getClientLocale() === "es";
|
|
|
|
|
toast.success(isSpanish ? "Cambios guardados" : "Changes saved", {
|
|
|
|
|
description: isSpanish
|
|
|
|
|
? "Puedes seguir editando o volver al panel de cursos."
|
|
|
|
|
: "You can keep editing or go back to the courses dashboard.",
|
|
|
|
|
action: {
|
|
|
|
|
label: isSpanish ? "Volver a cursos" : "Back to courses",
|
|
|
|
|
onClick: () => router.push("/teacher"),
|
|
|
|
|
},
|
|
|
|
|
cancel: {
|
|
|
|
|
label: isSpanish ? "Seguir editando" : "Keep editing",
|
|
|
|
|
onClick: () => {},
|
|
|
|
|
},
|
|
|
|
|
duration: 7000,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
const handleVideoUploaded = (url: string) => {
|
|
|
|
|
setVideoUrl(url);
|
|
|
|
|
toast.success("Video cargado en el formulario (no olvides Guardar Cambios)");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 1. Auto-save Video URL when upload finishes
|
|
|
|
|
const handleVideoUploaded = async (url: string) => {
|
|
|
|
|
toast.loading("Guardando video...");
|
|
|
|
|
const res = await updateLesson(lesson.id, { videoUrl: url });
|
|
|
|
|
const handleMaterialUploaded = (url: string) => {
|
|
|
|
|
setMaterialUrl(url);
|
|
|
|
|
toast.success("Material cargado en el formulario (no olvides Guardar Cambios)");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (res.success) {
|
|
|
|
|
toast.dismiss();
|
|
|
|
|
toast.success("Video guardado correctamente");
|
|
|
|
|
router.refresh(); // Update the UI to show the new video player
|
|
|
|
|
} else {
|
|
|
|
|
toast.error("Error al guardar el video en la base de datos");
|
|
|
|
|
const showSavedToast = () => {
|
|
|
|
|
const isSpanish = getClientLocale() === "es";
|
|
|
|
|
toast.success(isSpanish ? "Cambios guardados" : "Changes saved", {
|
|
|
|
|
description: isSpanish
|
|
|
|
|
? "Puedes seguir editando o volver al panel de cursos."
|
|
|
|
|
: "You can keep editing or go back to the courses dashboard.",
|
|
|
|
|
action: {
|
|
|
|
|
label: isSpanish ? "Volver a cursos" : "Back to courses",
|
|
|
|
|
onClick: () => router.push("/teacher"),
|
|
|
|
|
},
|
|
|
|
|
cancel: {
|
|
|
|
|
label: isSpanish ? "Seguir editando" : "Keep editing",
|
|
|
|
|
onClick: () => {},
|
|
|
|
|
},
|
|
|
|
|
duration: 7000,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const insertMarkdown = (prefix: string, suffix = "", placeholder = "texto") => {
|
|
|
|
|
const target = lectureRef.current;
|
|
|
|
|
if (!target) return;
|
|
|
|
|
const start = target.selectionStart;
|
|
|
|
|
const end = target.selectionEnd;
|
|
|
|
|
const selected = lectureContent.slice(start, end);
|
|
|
|
|
const replacement = `${prefix}${selected || placeholder}${suffix}`;
|
|
|
|
|
const nextContent = `${lectureContent.slice(0, start)}${replacement}${lectureContent.slice(end)}`;
|
|
|
|
|
setLectureContent(nextContent);
|
|
|
|
|
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
target.focus();
|
|
|
|
|
target.setSelectionRange(start + prefix.length, start + prefix.length + (selected || placeholder).length);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateQuestion = (questionId: string, updater: (question: EditableQuestion) => EditableQuestion) => {
|
|
|
|
|
setActivityQuestions((prev) => prev.map((question) => (question.id === questionId ? updater(question) : question)));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const changeQuestionKind = (questionId: string, kind: LessonQuestionKind) => {
|
|
|
|
|
updateQuestion(questionId, (question) => {
|
|
|
|
|
if (kind === question.kind) return question;
|
|
|
|
|
if (kind === "TRUE_FALSE") {
|
|
|
|
|
return {
|
|
|
|
|
...createTrueFalseQuestion(),
|
|
|
|
|
id: question.id,
|
|
|
|
|
prompt: question.prompt,
|
|
|
|
|
explanation: question.explanation,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
...createMultipleChoiceQuestion(),
|
|
|
|
|
id: question.id,
|
|
|
|
|
prompt: question.prompt,
|
|
|
|
|
explanation: question.explanation,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const validateForSave = (): { ok: boolean; normalizedQuestions: EditableQuestion[] } => {
|
|
|
|
|
if (!title.trim()) {
|
|
|
|
|
toast.error("El título de la lección es obligatorio.");
|
|
|
|
|
return { ok: false, normalizedQuestions: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (contentType === "VIDEO") {
|
|
|
|
|
const hasYouTube = getYouTubeEmbedUrl(youtubeUrl);
|
|
|
|
|
const hasDirectVideo = videoUrl.trim();
|
|
|
|
|
|
|
|
|
|
if (!hasYouTube && !hasDirectVideo) {
|
|
|
|
|
toast.error("Para video, sube un archivo o ingresa una URL válida de YouTube.");
|
|
|
|
|
return { ok: false, normalizedQuestions: [] };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (contentType === "LECTURE") {
|
|
|
|
|
const plainLecture = markdownToPlainText(lectureContent);
|
|
|
|
|
if (plainLecture.length < 20) {
|
|
|
|
|
toast.error("La lectura necesita contenido con formato (mínimo 20 caracteres).");
|
|
|
|
|
return { ok: false, normalizedQuestions: [] };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isAssessmentType(contentType)) {
|
|
|
|
|
if (activityQuestions.length === 0) {
|
|
|
|
|
toast.error("Agrega al menos una pregunta para esta actividad.");
|
|
|
|
|
return { ok: false, normalizedQuestions: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const normalized = activityQuestions.map(normalizeQuestion);
|
|
|
|
|
|
|
|
|
|
for (let index = 0; index < normalized.length; index += 1) {
|
|
|
|
|
const question = normalized[index];
|
|
|
|
|
if (!question.prompt.trim()) {
|
|
|
|
|
toast.error(`La pregunta ${index + 1} no tiene enunciado.`);
|
|
|
|
|
return { ok: false, normalizedQuestions: [] };
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 2. Save Text Changes (Title/Desc/YouTube/Preview)
|
|
|
|
|
const handleSave = async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
const res = await updateLesson(lesson.id, {
|
|
|
|
|
title,
|
|
|
|
|
description,
|
|
|
|
|
youtubeUrl: youtubeUrl.trim() || undefined,
|
|
|
|
|
materialUrl: materialUrl.trim() || undefined,
|
|
|
|
|
contentType,
|
|
|
|
|
estimatedDurationMinutes: durationMinutes,
|
|
|
|
|
isPreview: isFreePreview,
|
|
|
|
|
});
|
|
|
|
|
if (res.success) {
|
|
|
|
|
showSavedToast();
|
|
|
|
|
router.refresh();
|
|
|
|
|
} else {
|
|
|
|
|
toast.error("Error al guardar cambios");
|
|
|
|
|
if (question.options.length < 2) {
|
|
|
|
|
toast.error(`La pregunta ${index + 1} debe tener al menos 2 opciones.`);
|
|
|
|
|
return { ok: false, normalizedQuestions: [] };
|
|
|
|
|
}
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
|
|
|
|
const correctCount = question.options.filter((option) => option.isCorrect).length;
|
|
|
|
|
if (correctCount !== 1) {
|
|
|
|
|
toast.error(`La pregunta ${index + 1} debe tener exactamente 1 respuesta correcta.`);
|
|
|
|
|
return { ok: false, normalizedQuestions: [] };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { ok: true, normalizedQuestions: normalized };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { ok: true, normalizedQuestions: [] };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSave = async () => {
|
|
|
|
|
const validation = validateForSave();
|
|
|
|
|
if (!validation.ok) return;
|
|
|
|
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
const lectureSummary = markdownToPlainText(lectureContent).slice(0, 240);
|
|
|
|
|
const activitySummary = activityInstructions.trim().slice(0, 240);
|
|
|
|
|
|
|
|
|
|
const payload = {
|
|
|
|
|
title: title.trim(),
|
|
|
|
|
contentType,
|
|
|
|
|
youtubeUrl: contentType === "VIDEO" ? youtubeUrl.trim() : null,
|
|
|
|
|
videoUrl: contentType === "VIDEO" ? videoUrl.trim() : undefined,
|
|
|
|
|
materialUrl: materialUrl.trim() || undefined,
|
|
|
|
|
description:
|
|
|
|
|
contentType === "LECTURE"
|
|
|
|
|
? lectureSummary
|
|
|
|
|
: isAssessmentType(contentType)
|
|
|
|
|
? activitySummary
|
|
|
|
|
: title.trim(),
|
|
|
|
|
lectureContent: contentType === "LECTURE" ? lectureContent.trim() : "",
|
|
|
|
|
activity: isAssessmentType(contentType)
|
|
|
|
|
? {
|
|
|
|
|
instructions: activityInstructions.trim(),
|
|
|
|
|
passingScorePercent: contentType === "ACTIVITY" ? 0 : passingScorePercent,
|
|
|
|
|
questions: validation.normalizedQuestions,
|
|
|
|
|
}
|
|
|
|
|
: null,
|
|
|
|
|
estimatedDurationMinutes: durationMinutes,
|
|
|
|
|
isPreview: isFreePreview,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
|
|
|
const res = await updateLesson(lesson.id, payload);
|
|
|
|
|
if (res.success) {
|
|
|
|
|
showSavedToast();
|
|
|
|
|
router.refresh();
|
|
|
|
|
} else {
|
|
|
|
|
toast.error("Error al guardar cambios");
|
|
|
|
|
}
|
|
|
|
|
setLoading(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
{/* LEFT: Video & Content */}
|
|
|
|
|
<div className="lg:col-span-2 space-y-6">
|
|
|
|
|
const lecturePreview = markdownToSafeHtml(lectureContent);
|
|
|
|
|
const videoEmbedUrl = getYouTubeEmbedUrl(youtubeUrl);
|
|
|
|
|
|
|
|
|
|
{/* Video Upload Section */}
|
|
|
|
|
<section className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
|
|
|
|
<div className="p-6 border-b border-slate-100">
|
|
|
|
|
<h2 className="font-semibold text-slate-900">Video del Curso</h2>
|
|
|
|
|
<p className="text-sm text-slate-500">Sube el video principal de esta lección.</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="p-6 bg-slate-50/50">
|
|
|
|
|
<VideoUpload
|
|
|
|
|
lessonId={lesson.id}
|
|
|
|
|
currentVideoUrl={lesson.videoUrl}
|
|
|
|
|
onUploadComplete={handleVideoUploaded}
|
|
|
|
|
/>
|
|
|
|
|
</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>
|
|
|
|
|
return (
|
|
|
|
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
|
|
|
|
<div className="space-y-6 lg:col-span-2">
|
|
|
|
|
<section className="space-y-4 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
|
|
|
|
<div>
|
|
|
|
|
<label className="mb-1 block text-sm font-medium text-slate-700">Titulo de la Leccion</label>
|
|
|
|
|
<input
|
|
|
|
|
value={title}
|
|
|
|
|
onChange={(event) => setTitle(event.target.value)}
|
|
|
|
|
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Text Content */}
|
|
|
|
|
<section className="bg-white rounded-xl border border-slate-200 shadow-sm p-6 space-y-4">
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Título de la Lección</label>
|
|
|
|
|
<input
|
|
|
|
|
value={title}
|
|
|
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
|
|
|
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
{contentType === "VIDEO" ? (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<label className="block text-sm font-medium text-slate-700">Contenido de Video</label>
|
|
|
|
|
|
|
|
|
|
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
|
|
|
|
<p className="mb-2 text-xs font-semibold text-slate-500 uppercase">Opción 1: Subida directa</p>
|
|
|
|
|
<VideoUpload
|
|
|
|
|
lessonId={lesson.id}
|
|
|
|
|
currentVideoUrl={videoUrl}
|
|
|
|
|
onUploadComplete={handleVideoUploaded}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Descripción / Notas</label>
|
|
|
|
|
<textarea
|
|
|
|
|
rows={6}
|
|
|
|
|
value={description}
|
|
|
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
|
|
|
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
|
|
|
|
|
placeholder="Escribe aquí el contenido de la lección..."
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
|
|
|
|
<p className="mb-2 text-xs font-semibold text-slate-500 uppercase">Opción 2: YouTube URL</p>
|
|
|
|
|
<input
|
|
|
|
|
type="url"
|
|
|
|
|
value={youtubeUrl}
|
|
|
|
|
onChange={(event) => setYoutubeUrl(event.target.value)}
|
|
|
|
|
placeholder="https://www.youtube.com/watch?v=..."
|
|
|
|
|
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">URL de material (PDF / actividad / lectura)</label>
|
|
|
|
|
<input
|
|
|
|
|
type="url"
|
|
|
|
|
value={materialUrl}
|
|
|
|
|
onChange={(e) => setMaterialUrl(e.target.value)}
|
|
|
|
|
placeholder="https://.../material.pdf"
|
|
|
|
|
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
|
|
|
|
|
/>
|
|
|
|
|
<p className="mt-1 text-xs text-slate-500">Se usará en la vista del alumno como lectura o actividad descargable.</p>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
<p className="text-xs text-slate-500 italic">Si ambas están presentes, se priorizará YouTube en la vista del alumno.</p>
|
|
|
|
|
<div className="overflow-hidden rounded-lg border border-slate-200 bg-slate-100">
|
|
|
|
|
{videoEmbedUrl ? (
|
|
|
|
|
<iframe
|
|
|
|
|
className="aspect-video w-full"
|
|
|
|
|
src={videoEmbedUrl}
|
|
|
|
|
title="Vista previa de YouTube"
|
|
|
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
|
|
|
allowFullScreen
|
|
|
|
|
/>
|
|
|
|
|
) : videoUrl ? (
|
|
|
|
|
<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>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{/* RIGHT: Settings / Actions */}
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6">
|
|
|
|
|
<h3 className="font-semibold text-slate-900 mb-4">Tipo de contenido</h3>
|
|
|
|
|
<label className="block text-sm text-slate-700 mb-2">Formato de la lección</label>
|
|
|
|
|
<select
|
|
|
|
|
value={contentType}
|
|
|
|
|
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"
|
|
|
|
|
<section className="mt-8 border-t border-slate-100 pt-6">
|
|
|
|
|
<h3 className="mb-4 text-lg font-semibold text-slate-900">Material Descargable</h3>
|
|
|
|
|
<FileUpload
|
|
|
|
|
lessonId={lesson.id}
|
|
|
|
|
currentFileUrl={materialUrl}
|
|
|
|
|
onUploadComplete={handleMaterialUploaded}
|
|
|
|
|
onRemove={() => setMaterialUrl("")}
|
|
|
|
|
/>
|
|
|
|
|
<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) => (
|
|
|
|
|
<option key={type} value={type}>
|
|
|
|
|
{getLessonContentTypeLabel(type)}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
<p className="mt-2 text-xs text-slate-500">
|
|
|
|
|
Usa “Evaluación final” para marcar el examen obligatorio del curso.
|
|
|
|
|
</p>
|
|
|
|
|
+ Verdadero/Falso
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setActivityQuestions((prev) => [...prev, createMultipleChoiceQuestion()])}
|
|
|
|
|
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"
|
|
|
|
|
>
|
|
|
|
|
+ Opcion multiple
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<label className="block text-sm text-slate-700 mb-1 mt-4">Duración estimada (minutos)</label>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min={0}
|
|
|
|
|
value={durationMinutes}
|
|
|
|
|
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.length === 0 ? (
|
|
|
|
|
<p className="rounded-lg border border-dashed border-slate-300 bg-white px-3 py-4 text-sm text-slate-500">
|
|
|
|
|
Todavia no hay preguntas. Agrega la primera para construir la actividad.
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{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">
|
|
|
|
|
<h3 className="font-semibold text-slate-900 mb-4">Vista previa gratuita</h3>
|
|
|
|
|
<label className="flex cursor-pointer items-center gap-2">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={isFreePreview}
|
|
|
|
|
onChange={(e) => setIsFreePreview(e.target.checked)}
|
|
|
|
|
className="h-4 w-4 rounded border-slate-300"
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-sm text-slate-700">Accesible sin inscripción (teaser)</span>
|
|
|
|
|
</label>
|
|
|
|
|
<p className="mt-2 text-xs text-slate-500">Los no inscritos podrán ver esta lección sin comprar el curso.</p>
|
|
|
|
|
</div>
|
|
|
|
|
<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}
|
|
|
|
|
className="w-full bg-black text-white rounded-lg py-2.5 font-medium hover:bg-slate-800 disabled:opacity-50 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{loading ? "Guardando..." : "Guardar Cambios"}
|
|
|
|
|
</button>
|
|
|
|
|
{question.kind === "TRUE_FALSE" ? (
|
|
|
|
|
<div className="space-y-2 rounded-md border border-slate-200 bg-slate-50 p-3">
|
|
|
|
|
{question.options.map((option) => (
|
|
|
|
|
<label key={option.id} className="flex items-center gap-2 text-sm text-slate-700">
|
|
|
|
|
<input
|
|
|
|
|
type="radio"
|
|
|
|
|
checked={option.isCorrect}
|
|
|
|
|
onChange={() =>
|
|
|
|
|
updateQuestion(question.id, (current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
options: current.options.map((item) => ({
|
|
|
|
|
...item,
|
|
|
|
|
isCorrect: item.id === option.id,
|
|
|
|
|
})),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<span>{option.text}</span>
|
|
|
|
|
</label>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<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
|
|
|
|
|
onClick={() => router.push(`/teacher/courses/${courseSlug}/edit`)}
|
|
|
|
|
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"
|
|
|
|
|
>
|
|
|
|
|
Volver al Curso
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<input
|
|
|
|
|
value={question.explanation}
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
updateQuestion(question.id, (current) => ({ ...current, explanation: event.target.value }))
|
|
|
|
|
}
|
|
|
|
|
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm outline-none focus:border-black"
|
|
|
|
|
placeholder="Explicacion breve (opcional)"
|
|
|
|
|
/>
|
|
|
|
|
</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 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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|