540 lines
22 KiB
TypeScript
Executable File
540 lines
22 KiB
TypeScript
Executable File
// 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 { 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 }[];
|
||
}[];
|
||
};
|
||
|
||
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 [learningOutcomes, setLearningOutcomes] = useState<string[]>(() =>
|
||
parseLearningOutcomes(course.learningOutcomes)
|
||
);
|
||
|
||
useEffect(() => {
|
||
setOptimisticModules(course.modules);
|
||
}, [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) => {
|
||
setLoading(true);
|
||
const res = await createLesson(moduleId);
|
||
if (res.success && res.lessonId) {
|
||
toast.success("Lección creada");
|
||
// 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) => (
|
||
<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">
|
||
<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 */}
|
||
<button
|
||
type="button"
|
||
onClick={() => handleAddLesson(module.id)}
|
||
disabled={loading}
|
||
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"
|
||
>
|
||
<span className="text-lg leading-none">+</span> Agregar Lección
|
||
</button>
|
||
</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.</li>
|
||
<li>Haz clic en una lección para <strong>subir el video</strong>.</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|