Stage 1: Course/Module/Lesson CRUD admin UI with TipTap editor

This commit is contained in:
2026-04-07 11:36:27 +05:00
parent 9d82b73e58
commit d356dddc96
30 changed files with 2090 additions and 41 deletions
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useState, useTransition } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { updateCourse, deleteCourse } from "@/app/admin/courses/actions";
interface Course {
id: string;
title: string;
slug: string;
description: string | null;
coverImage: string | null;
published: boolean;
}
export function CourseEditForm({ course }: { course: Course }) {
const [published, setPublished] = useState(course.published);
const [coverImage, setCoverImage] = useState(course.coverImage ?? "");
const [uploading, setUploading] = useState(false);
const [pending, startTransition] = useTransition();
async function handleImageUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/admin/upload", { method: "POST", body: fd });
const data = await res.json();
if (data.url) setCoverImage(data.url);
setUploading(false);
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
fd.set("published", String(published));
fd.set("coverImage", coverImage);
startTransition(() => updateCourse(course.id, fd));
}
function handleDelete() {
if (!confirm("Удалить курс? Это действие нельзя отменить.")) return;
startTransition(() => deleteCourse(course.id));
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="title">Название</Label>
<Input id="title" name="title" defaultValue={course.title} required />
</div>
<div className="space-y-1.5">
<Label htmlFor="slug">Slug</Label>
<Input id="slug" name="slug" defaultValue={course.slug} required />
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Описание</Label>
<Textarea id="description" name="description" defaultValue={course.description ?? ""} rows={3} />
</div>
<div className="space-y-1.5">
<Label>Обложка</Label>
<div className="flex items-center gap-3">
{coverImage && (
// eslint-disable-next-line @next/next/no-img-element
<img src={coverImage} alt="cover" className="w-16 h-10 object-cover rounded-md border" />
)}
<Input type="file" accept="image/*" onChange={handleImageUpload} disabled={uploading} className="max-w-xs" />
{uploading && <span className="text-sm text-slate-400">Загрузка...</span>}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
role="switch"
aria-checked={published}
onClick={() => setPublished(!published)}
className={`relative w-10 h-6 rounded-full transition-colors ${published ? "bg-green-500" : "bg-slate-300"}`}
>
<span className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${published ? "translate-x-4" : ""}`} />
</button>
<span className="text-sm text-slate-600">{published ? "Опубликован" : "Черновик"}</span>
</div>
<div className="flex justify-between pt-2">
<Button type="button" variant="destructive" onClick={handleDelete} disabled={pending}>
Удалить курс
</Button>
<Button type="submit" disabled={pending || uploading}>
{pending ? "Сохранение..." : "Сохранить"}
</Button>
</div>
</form>
);
}