373 lines
16 KiB
TypeScript
Executable File
373 lines
16 KiB
TypeScript
Executable File
// 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>
|
||
);
|
||
} |