advance
This commit is contained in:
493
components/teacher/TeacherEditCourseForm.tsx
Normal file → Executable file
493
components/teacher/TeacherEditCourseForm.tsx
Normal file → Executable file
@@ -1,176 +1,373 @@
|
||||
// components/teacher/TeacherEditCourseForm.tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import {
|
||||
updateCourse,
|
||||
createModule,
|
||||
createLesson,
|
||||
reorderModules,
|
||||
reorderLessons,
|
||||
} from "@/app/(protected)/teacher/actions";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getTeacherCourseBySlug, teacherCoursesUpdatedEventName, updateTeacherCourse } from "@/lib/data/teacherCourses";
|
||||
import type { Course, CourseLevel } from "@/types/course";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const levels: CourseLevel[] = ["Beginner", "Intermediate", "Advanced"];
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
type TeacherEditCourseFormProps = {
|
||||
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({ slug }: TeacherEditCourseFormProps) {
|
||||
export default function TeacherEditCourseForm({ course }: { course: CourseData }) {
|
||||
const router = useRouter();
|
||||
const [course, setCourse] = useState<Course | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [level, setLevel] = useState<CourseLevel>("Beginner");
|
||||
const [summary, setSummary] = useState("");
|
||||
const [instructor, setInstructor] = useState("");
|
||||
const [weeks, setWeeks] = useState(4);
|
||||
const [rating, setRating] = useState(5);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [optimisticModules, setOptimisticModules] = useState(course.modules);
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
const found = getTeacherCourseBySlug(slug) ?? null;
|
||||
setCourse(found);
|
||||
if (!found) return;
|
||||
setTitle(found.title);
|
||||
setLevel(found.level);
|
||||
setSummary(found.summary);
|
||||
setInstructor(found.instructor);
|
||||
setWeeks(found.weeks);
|
||||
setRating(found.rating);
|
||||
};
|
||||
setOptimisticModules(course.modules);
|
||||
}, [course.modules]);
|
||||
|
||||
load();
|
||||
window.addEventListener(teacherCoursesUpdatedEventName, load);
|
||||
return () => window.removeEventListener(teacherCoursesUpdatedEventName, load);
|
||||
}, [slug]);
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!course) return;
|
||||
|
||||
updateTeacherCourse(course.slug, {
|
||||
title: title.trim(),
|
||||
level,
|
||||
summary: summary.trim(),
|
||||
instructor: instructor.trim(),
|
||||
weeks: Math.max(1, weeks),
|
||||
rating: Math.min(5, Math.max(0, rating)),
|
||||
});
|
||||
setSaved(true);
|
||||
window.setTimeout(() => setSaved(false), 1200);
|
||||
// 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 "";
|
||||
};
|
||||
|
||||
if (!course) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-3 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Teacher course not found</h1>
|
||||
<p className="text-slate-600">This editor only works for courses created in the teacher area.</p>
|
||||
<Link className="inline-flex rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white" href="/teacher">
|
||||
Back to dashboard
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
// 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-3xl space-y-5">
|
||||
<form className="space-y-4 rounded-xl border border-slate-200 bg-white p-6 shadow-sm" onSubmit={submit}>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Edit Course</h1>
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-700">Title</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-brand"
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
value={title}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-700">Level</span>
|
||||
<select
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-brand"
|
||||
onChange={(event) => setLevel(event.target.value as CourseLevel)}
|
||||
value={level}
|
||||
{/* 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"
|
||||
>
|
||||
{levels.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-700">Summary</span>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-brand"
|
||||
onChange={(event) => setSummary(event.target.value)}
|
||||
rows={3}
|
||||
value={summary}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-700">Instructor</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-brand"
|
||||
onChange={(event) => setInstructor(event.target.value)}
|
||||
value={instructor}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-700">Weeks</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-brand"
|
||||
min={1}
|
||||
onChange={(event) => setWeeks(Number(event.target.value))}
|
||||
type="number"
|
||||
value={weeks}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-700">Rating (0-5)</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-brand"
|
||||
max={5}
|
||||
min={0}
|
||||
onChange={(event) => setRating(Number(event.target.value))}
|
||||
step={0.1}
|
||||
type="number"
|
||||
value={rating}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white hover:brightness-105" type="submit">
|
||||
Save changes
|
||||
</button>
|
||||
{saved ? <span className="text-sm font-medium text-emerald-700">Saved</span> : null}
|
||||
<span>👁️</span> Ver Vista Previa
|
||||
</Link>
|
||||
<button
|
||||
className="rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-50"
|
||||
onClick={() => router.push(`/teacher/courses/${course.slug}/lessons/new`)}
|
||||
type="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"
|
||||
>
|
||||
Add lesson
|
||||
{loading ? "Guardando..." : "Guardar Cambios"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Lessons</h2>
|
||||
<div className="mt-3 space-y-2">
|
||||
{course.lessons.map((lesson) => (
|
||||
<div key={lesson.id} className="rounded-md border border-slate-200 p-3 text-sm">
|
||||
<p className="font-medium text-slate-900">{lesson.title}</p>
|
||||
<p className="text-slate-600">
|
||||
{lesson.type} | {lesson.minutes} min {lesson.isPreview ? "| Preview" : ""}
|
||||
</p>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user