Files
ACVE/components/teacher/TeacherEditCourseForm.tsx
Marcelo 02afbd7cfb MVP
2026-03-20 04:54:08 +00:00

599 lines
25 KiB
TypeScript
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// components/teacher/TeacherEditCourseForm.tsx
"use client";
import {
updateCourse,
createModule,
createLesson,
reorderModules,
reorderLessons,
updateModuleTitle,
} from "@/app/(protected)/teacher/actions";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { toast } from "sonner";
import { getClientLocale } from "@/lib/i18n/clientLocale";
import {
getLessonContentTypeLabel,
parseLessonDescriptionMeta,
type LessonContentType,
} from "@/lib/courses/lessonContent";
import { Prisma } from "@prisma/client";
function parseLearningOutcomes(val: Prisma.JsonValue | null | undefined): string[] {
if (val == null) return [];
if (Array.isArray(val)) return val.filter((x): x is string => typeof x === "string");
return [];
}
type CourseData = {
id: string;
slug: string;
title: Prisma.JsonValue;
description: Prisma.JsonValue;
level: string;
status: string;
price: number;
learningOutcomes?: Prisma.JsonValue | null;
modules: {
id: string;
title: Prisma.JsonValue;
lessons: { id: string; title: Prisma.JsonValue; description: Prisma.JsonValue | null }[];
}[];
};
const selectableLessonTypes: LessonContentType[] = [
"VIDEO",
"LECTURE",
"ACTIVITY",
"QUIZ",
"FINAL_EXAM",
];
function getLessonTypeBadgeClass(type: LessonContentType): string {
if (type === "LECTURE") return "border-indigo-200 bg-indigo-50 text-indigo-700";
if (type === "ACTIVITY") return "border-rose-200 bg-rose-50 text-rose-700";
if (type === "QUIZ" || type === "FINAL_EXAM") return "border-amber-200 bg-amber-50 text-amber-700";
return "border-sky-200 bg-sky-50 text-sky-700";
}
export default function TeacherEditCourseForm({ course }: { course: CourseData }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [optimisticModules, setOptimisticModules] = useState(course.modules);
const [editingModuleId, setEditingModuleId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState("");
const [newLessonTypeByModule, setNewLessonTypeByModule] = useState<Record<string, LessonContentType>>({});
const [learningOutcomes, setLearningOutcomes] = useState<string[]>(() =>
parseLearningOutcomes(course.learningOutcomes)
);
useEffect(() => {
setOptimisticModules(course.modules);
setNewLessonTypeByModule(
course.modules.reduce<Record<string, LessonContentType>>((acc, module) => {
acc[module.id] = acc[module.id] ?? "VIDEO";
return acc;
}, {}),
);
}, [course.modules]);
useEffect(() => {
setLearningOutcomes(parseLearningOutcomes(course.learningOutcomes));
}, [course.learningOutcomes]);
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,
});
};
// Helper for JSON/String fields
const getStr = (val: Prisma.JsonValue) => {
if (typeof val === "string") return val;
if (val && typeof val === "object" && !Array.isArray(val)) {
const v = val as Record<string, unknown>;
if (typeof v.en === "string") return v.en;
if (typeof v.es === "string") return v.es;
return "";
}
return "";
};
// 1. SAVE COURSE SETTINGS
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
const form = event.currentTarget;
const formData = new FormData(form);
formData.set("learningOutcomes", JSON.stringify(learningOutcomes));
const res = await updateCourse(course.id, course.slug, formData);
if (res.success) {
showSavedToast();
router.refresh();
} else {
toast.error("Error al guardar");
}
setLoading(false);
}
// 2. CREATE NEW MODULE
const handleAddModule = async () => {
setLoading(true); // Block UI while working
const res = await createModule(course.id);
if (res.success) {
const isSpanish = getClientLocale() === "es";
toast.success(isSpanish ? "Módulo creado" : "Module created", {
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,
});
router.refresh();
} else {
toast.error("Error al crear módulo");
}
setLoading(false);
};
// 3. CREATE NEW LESSON
const handleAddLesson = async (moduleId: string) => {
const selectedType = newLessonTypeByModule[moduleId] ?? "VIDEO";
setLoading(true);
const res = await createLesson(moduleId, selectedType);
if (res.success && res.lessonId) {
toast.success(`Lección creada (${getLessonContentTypeLabel(selectedType)})`);
// Redirect immediately to the video upload page
router.push(`/teacher/courses/${course.slug}/lessons/${res.lessonId}`);
} else {
toast.error("Error al crear lección");
setLoading(false); // Only stop loading if we failed (otherwise we are redirecting)
}
};
const startEditingModuleTitle = (moduleId: string, currentTitle: string) => {
setEditingModuleId(moduleId);
setEditingTitle(currentTitle || "");
};
const cancelEditingModuleTitle = () => {
setEditingModuleId(null);
setEditingTitle("");
};
const saveModuleTitle = async () => {
if (!editingModuleId || !editingTitle.trim()) {
cancelEditingModuleTitle();
return;
}
setLoading(true);
const res = await updateModuleTitle(editingModuleId, editingTitle.trim());
if (res.success) {
toast.success("Título actualizado");
cancelEditingModuleTitle();
router.push(`/teacher/courses/${course.slug}/edit`);
} else {
toast.error(res.error ?? "Error al guardar");
}
setLoading(false);
};
// 4. REORDER MODULES (optimistic)
const handleReorderModule = async (moduleIndex: number, direction: "up" | "down") => {
const swapWith = direction === "up" ? moduleIndex - 1 : moduleIndex + 1;
if (swapWith < 0 || swapWith >= optimisticModules.length) return;
const next = [...optimisticModules];
[next[moduleIndex], next[swapWith]] = [next[swapWith], next[moduleIndex]];
setOptimisticModules(next);
const res = await reorderModules(optimisticModules[moduleIndex].id, direction);
if (res.success) {
router.refresh();
} else {
setOptimisticModules(course.modules);
toast.error(res.error ?? "Error al reordenar");
}
};
// 5. REORDER LESSONS (optimistic)
const handleReorderLesson = async (
moduleIndex: number,
lessonIndex: number,
direction: "up" | "down"
) => {
const lessons = optimisticModules[moduleIndex].lessons;
const swapWith = direction === "up" ? lessonIndex - 1 : lessonIndex + 1;
if (swapWith < 0 || swapWith >= lessons.length) return;
const nextModules = [...optimisticModules];
const nextLessons = [...lessons];
[nextLessons[lessonIndex], nextLessons[swapWith]] = [
nextLessons[swapWith],
nextLessons[lessonIndex],
];
nextModules[moduleIndex] = { ...nextModules[moduleIndex], lessons: nextLessons };
setOptimisticModules(nextModules);
const res = await reorderLessons(lessons[lessonIndex].id, direction);
if (res.success) {
router.refresh();
} else {
setOptimisticModules(course.modules);
toast.error(res.error ?? "Error al reordenar");
}
};
return (
<div className="mx-auto max-w-4xl space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-slate-900">Editar Curso</h1>
<div className="flex gap-2">
<Link
href={`/courses/${course.slug}`}
target="_blank"
className="px-3 py-2 text-sm border border-slate-300 rounded-md hover:bg-slate-50 flex items-center gap-2"
>
<span>👁</span> Ver Vista Previa
</Link>
<button
form="edit-form"
type="submit"
disabled={loading}
className="bg-black text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-slate-800 disabled:opacity-50"
>
{loading ? "Guardando..." : "Guardar Cambios"}
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* LEFT COLUMN: Main Info */}
<div className="lg:col-span-2 space-y-6">
<form id="edit-form" onSubmit={handleSubmit} className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Título</label>
<input
name="title"
defaultValue={getStr(course.title)}
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black transition-all"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Resumen</label>
<textarea
name="summary"
rows={4}
defaultValue={getStr(course.description)}
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black transition-all"
/>
</div>
{/* What you will learn */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Qué aprenderán (What you will learn)
</label>
<p className="text-xs text-slate-500 mb-2">
Una línea por resultado de aprendizaje. Se muestra en la página pública del curso.
</p>
<ul className="space-y-2">
{learningOutcomes.map((text, index) => (
<li key={index} className="flex gap-2">
<input
type="text"
value={text}
onChange={(e) => {
const next = [...learningOutcomes];
next[index] = e.target.value;
setLearningOutcomes(next);
}}
placeholder="Ej. Comprender vocabulario jurídico en contexto"
className="flex-1 rounded-md border border-slate-300 px-3 py-2 text-sm outline-none focus:border-black transition-all"
/>
<button
type="button"
onClick={() => setLearningOutcomes(learningOutcomes.filter((_, i) => i !== index))}
className="rounded-md border border-slate-300 px-2 py-1 text-sm text-slate-600 hover:bg-slate-100"
aria-label="Quitar"
>
</button>
</li>
))}
</ul>
<button
type="button"
onClick={() => setLearningOutcomes([...learningOutcomes, ""])}
className="mt-2 text-sm text-blue-600 font-medium hover:underline"
>
+ Agregar resultado de aprendizaje
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Nivel</label>
<select
name="level"
defaultValue={course.level}
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
>
<option value="BEGINNER">Principiante</option>
<option value="INTERMEDIATE">Intermedio</option>
<option value="ADVANCED">Avanzado</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Estado</label>
<select
name="status"
defaultValue={course.status}
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
>
<option value="DRAFT">Borrador</option>
<option value="PUBLISHED">Publicado</option>
</select>
</div>
</div>
</form>
{/* MODULES & LESSONS SECTION */}
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Plan de Estudios</h2>
<button
type="button"
onClick={handleAddModule}
disabled={loading}
className="text-sm text-blue-600 font-medium hover:underline disabled:opacity-50"
>
+ Nuevo Módulo
</button>
</div>
<div className="space-y-4">
{optimisticModules.length === 0 && (
<div className="text-center py-8 border border-dashed border-slate-200 rounded-lg bg-slate-50">
<p className="text-slate-500 text-sm mb-2">Tu curso está vacío.</p>
<button onClick={handleAddModule} className="text-black underline text-sm font-medium">
Agrega el primer módulo
</button>
</div>
)}
{optimisticModules.map((module, moduleIndex) => (
<div key={module.id} className="border border-slate-200 rounded-xl overflow-hidden bg-white shadow-sm">
{/* Module Header */}
<div className="bg-slate-50 px-4 py-2 border-b border-slate-200 flex justify-between items-center">
<div className="flex items-center gap-2">
{editingModuleId === module.id ? (
<div className="flex items-center gap-2 flex-1 min-w-0">
<input
type="text"
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") saveModuleTitle();
if (e.key === "Escape") cancelEditingModuleTitle();
}}
className="flex-1 min-w-0 rounded-md border border-slate-300 px-2 py-1 text-sm font-medium text-slate-800 outline-none focus:border-black"
autoFocus
disabled={loading}
/>
<button
type="button"
onClick={saveModuleTitle}
disabled={loading || !editingTitle.trim()}
className="rounded-md bg-black px-2 py-1 text-xs font-medium text-white hover:bg-slate-800 disabled:opacity-50"
>
Guardar
</button>
<button
type="button"
onClick={cancelEditingModuleTitle}
disabled={loading}
className="rounded-md border border-slate-300 px-2 py-1 text-xs font-medium text-slate-700 hover:bg-slate-100"
>
Cancelar
</button>
</div>
) : (
<>
<span className="font-medium text-sm text-slate-800">{getStr(module.title)}</span>
<div className="flex items-center">
<button
type="button"
onClick={() => handleReorderModule(moduleIndex, "up")}
disabled={moduleIndex === 0}
className="p-1.5 rounded-lg text-slate-500 hover:bg-slate-200 hover:text-slate-800 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors"
aria-label="Mover módulo arriba"
>
<svg className="w-4 h-4" 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={() => handleReorderModule(moduleIndex, "down")}
disabled={moduleIndex === optimisticModules.length - 1}
className="p-1.5 rounded-lg text-slate-500 hover:bg-slate-200 hover:text-slate-800 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors"
aria-label="Mover módulo abajo"
>
<svg className="w-4 h-4" 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>
{editingModuleId !== module.id && (
<button
type="button"
onClick={() => startEditingModuleTitle(module.id, getStr(module.title))}
className="text-xs text-slate-500 hover:text-black"
>
Editar Título
</button>
)}
</div>
{/* Lessons List */}
<div className="divide-y divide-slate-100">
{module.lessons.map((lesson, lessonIndex) => {
const lessonMeta = parseLessonDescriptionMeta(lesson.description);
const lessonType = lessonMeta.contentType;
return (
<div
key={lesson.id}
className="px-4 py-3 flex items-center gap-2 hover:bg-slate-50 transition-colors group"
>
<div className="flex flex-col">
<button
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-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>
);
})}
<div className="flex flex-wrap items-center gap-2 px-4 py-3">
<select
value={newLessonTypeByModule[module.id] ?? "VIDEO"}
onChange={(event) =>
setNewLessonTypeByModule((prev) => ({
...prev,
[module.id]: event.target.value as LessonContentType,
}))
}
className="rounded-md border border-slate-300 px-2 py-1.5 text-xs text-slate-700 outline-none focus:border-black"
>
{selectableLessonTypes.map((type) => (
<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>
</div>
{/* RIGHT COLUMN: Price / Help */}
<div className="space-y-6">
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
<label className="block text-sm font-medium text-slate-700 mb-1">Precio (MXN)</label>
<input
form="edit-form"
name="price"
type="number"
step="0.01"
defaultValue={course.price}
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">
Si es 0, el curso será gratuito.
</p>
</div>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200">
<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">
<li>Crea módulos para organizar tus temas.</li>
<li>Dentro de cada módulo, agrega lecciones con tipo (video, lectura, actividad, quiz).</li>
<li>Haz clic en una lección para editar su contenido según el formato elegido.</li>
</ul>
</div>
</div>
</div>
</div>
);
}