Pending course, rest ready for launch
This commit is contained in:
@@ -7,14 +7,22 @@ import {
|
||||
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;
|
||||
@@ -23,6 +31,7 @@ type CourseData = {
|
||||
level: string;
|
||||
status: string;
|
||||
price: number;
|
||||
learningOutcomes?: Prisma.JsonValue | null;
|
||||
modules: {
|
||||
id: string;
|
||||
title: Prisma.JsonValue;
|
||||
@@ -34,11 +43,38 @@ 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;
|
||||
@@ -52,12 +88,16 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
|
||||
};
|
||||
|
||||
// 1. SAVE COURSE SETTINGS
|
||||
async function handleSubmit(formData: FormData) {
|
||||
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) {
|
||||
toast.success("Curso actualizado");
|
||||
showSavedToast();
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("Error al guardar");
|
||||
@@ -70,7 +110,21 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
|
||||
setLoading(true); // Block UI while working
|
||||
const res = await createModule(course.id);
|
||||
if (res.success) {
|
||||
toast.success("Módulo agregado");
|
||||
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");
|
||||
@@ -92,6 +146,33 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -167,7 +248,7 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
|
||||
|
||||
{/* 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">
|
||||
<form id="edit-form" onSubmit={handleSubmit} className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm space-y-4">
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
@@ -190,6 +271,48 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
|
||||
/>
|
||||
</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>
|
||||
@@ -247,33 +370,76 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
|
||||
{/* 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>
|
||||
{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>
|
||||
<button className="text-xs text-slate-500 hover:text-black">Editar Título</button>
|
||||
{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 */}
|
||||
@@ -370,4 +536,4 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user