Files
ACVE/components/teacher/TeacherEditCourseForm.tsx
2026-02-17 00:07:00 +00:00

373 lines
16 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.
// components/teacher/TeacherEditCourseForm.tsx
"use client";
import {
updateCourse,
createModule,
createLesson,
reorderModules,
reorderLessons,
} 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 { Prisma } from "@prisma/client";
type CourseData = {
id: string;
slug: string;
title: Prisma.JsonValue;
description: Prisma.JsonValue;
level: string;
status: string;
price: number;
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);
useEffect(() => {
setOptimisticModules(course.modules);
}, [course.modules]);
// 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(formData: FormData) {
setLoading(true);
const res = await updateCourse(course.id, course.slug, formData);
if (res.success) {
toast.success("Curso actualizado");
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) {
toast.success("Módulo agregado");
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)
}
};
// 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" action={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>
<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">
<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>
<button 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>
);
}