From d356dddc969e4dbff8e1caec5e18eb55ca7d3d4d Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Tue, 7 Apr 2026 11:36:27 +0500 Subject: [PATCH] Stage 1: Course/Module/Lesson CRUD admin UI with TipTap editor --- package-lock.json | 49 ++++- package.json | 3 + src/app/admin/courses/[courseId]/actions.ts | 64 ++++++ .../[courseId]/modules/[moduleId]/actions.ts | 43 ++++ .../[moduleId]/lessons/[lessonId]/actions.ts | 36 ++++ .../[moduleId]/lessons/[lessonId]/page.tsx | 53 +++++ .../[courseId]/modules/[moduleId]/page.tsx | 46 ++++ src/app/admin/courses/[courseId]/page.tsx | 72 +++++++ src/app/admin/courses/actions.ts | 62 ++++++ src/app/admin/courses/page.tsx | 56 +++++ src/app/admin/dashboard/page.tsx | 61 ++---- src/app/admin/layout.tsx | 30 +++ src/app/admin/users/page.tsx | 70 ++++++ src/app/api/admin/upload/route.ts | 23 ++ src/app/globals.css | 1 + src/components/admin/admin-nav.tsx | 34 +++ src/components/admin/course-edit-form.tsx | 99 +++++++++ src/components/admin/create-course-dialog.tsx | 52 +++++ src/components/admin/enrollment-manager.tsx | 98 +++++++++ src/components/admin/lesson-editor.tsx | 183 ++++++++++++++++ src/components/admin/sortable-lessons.tsx | 151 +++++++++++++ src/components/admin/sortable-modules.tsx | 138 ++++++++++++ src/components/ui/alert-dialog.tsx | 187 ++++++++++++++++ src/components/ui/badge.tsx | 52 +++++ src/components/ui/dialog.tsx | 160 ++++++++++++++ src/components/ui/input.tsx | 20 ++ src/components/ui/label.tsx | 20 ++ src/components/ui/select.tsx | 201 ++++++++++++++++++ src/components/ui/sonner.tsx | 49 +++++ src/components/ui/textarea.tsx | 18 ++ 30 files changed, 2090 insertions(+), 41 deletions(-) create mode 100644 src/app/admin/courses/[courseId]/actions.ts create mode 100644 src/app/admin/courses/[courseId]/modules/[moduleId]/actions.ts create mode 100644 src/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/actions.ts create mode 100644 src/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/page.tsx create mode 100644 src/app/admin/courses/[courseId]/modules/[moduleId]/page.tsx create mode 100644 src/app/admin/courses/[courseId]/page.tsx create mode 100644 src/app/admin/courses/actions.ts create mode 100644 src/app/admin/courses/page.tsx create mode 100644 src/app/admin/layout.tsx create mode 100644 src/app/admin/users/page.tsx create mode 100644 src/app/api/admin/upload/route.ts create mode 100644 src/components/admin/admin-nav.tsx create mode 100644 src/components/admin/course-edit-form.tsx create mode 100644 src/components/admin/create-course-dialog.tsx create mode 100644 src/components/admin/enrollment-manager.tsx create mode 100644 src/components/admin/lesson-editor.tsx create mode 100644 src/components/admin/sortable-lessons.tsx create mode 100644 src/components/admin/sortable-modules.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/sonner.tsx create mode 100644 src/components/ui/textarea.tsx diff --git a/package-lock.json b/package-lock.json index 8812a9e..5fd58cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@dnd-kit/utilities": "^3.2.2", "@prisma/adapter-pg": "^7.6.0", "@prisma/client": "^7.6.0", + "@tailwindcss/typography": "^0.5.19", "@tiptap/extension-image": "^3.22.2", "@tiptap/extension-link": "^3.22.2", "@tiptap/extension-placeholder": "^3.22.2", @@ -28,11 +29,13 @@ "clsx": "^2.1.1", "lucide-react": "^1.7.0", "next": "16.2.2", + "next-themes": "^0.4.6", "pg": "^8.20.0", "react": "19.2.4", "react-dom": "19.2.4", "resend": "^6.10.0", "shadcn": "^4.1.2", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", "zod": "^4.3.6" @@ -4586,6 +4589,31 @@ "tailwindcss": "4.2.2" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@tiptap/core": { "version": "3.22.2", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.2.tgz", @@ -10729,6 +10757,16 @@ } } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -12768,6 +12806,16 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -13175,7 +13223,6 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "dev": true, "license": "MIT" }, "node_modules/tapable": { diff --git a/package.json b/package.json index 98e361d..b24b65f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@dnd-kit/utilities": "^3.2.2", "@prisma/adapter-pg": "^7.6.0", "@prisma/client": "^7.6.0", + "@tailwindcss/typography": "^0.5.19", "@tiptap/extension-image": "^3.22.2", "@tiptap/extension-link": "^3.22.2", "@tiptap/extension-placeholder": "^3.22.2", @@ -33,11 +34,13 @@ "clsx": "^2.1.1", "lucide-react": "^1.7.0", "next": "16.2.2", + "next-themes": "^0.4.6", "pg": "^8.20.0", "react": "19.2.4", "react-dom": "19.2.4", "resend": "^6.10.0", "shadcn": "^4.1.2", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", "zod": "^4.3.6" diff --git a/src/app/admin/courses/[courseId]/actions.ts b/src/app/admin/courses/[courseId]/actions.ts new file mode 100644 index 0000000..ddf3882 --- /dev/null +++ b/src/app/admin/courses/[courseId]/actions.ts @@ -0,0 +1,64 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { revalidatePath } from "next/cache"; + +async function requireAdmin() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session || session.user.role !== "admin") throw new Error("Forbidden"); +} + +// ── Modules ────────────────────────────────────────────────────────────────── + +export async function createModule(courseId: string, formData: FormData) { + await requireAdmin(); + const title = formData.get("title") as string; + const count = await prisma.module.count({ where: { courseId } }); + await prisma.module.create({ data: { courseId, title, order: count } }); + revalidatePath(`/admin/courses/${courseId}`); +} + +export async function updateModule(moduleId: string, courseId: string, formData: FormData) { + await requireAdmin(); + const title = formData.get("title") as string; + await prisma.module.update({ where: { id: moduleId }, data: { title } }); + revalidatePath(`/admin/courses/${courseId}`); +} + +export async function deleteModule(moduleId: string, courseId: string) { + await requireAdmin(); + await prisma.module.delete({ where: { id: moduleId } }); + revalidatePath(`/admin/courses/${courseId}`); +} + +export async function reorderModules(courseId: string, orderedIds: string[]) { + await requireAdmin(); + await Promise.all( + orderedIds.map((id, index) => + prisma.module.update({ where: { id }, data: { order: index } }) + ) + ); + revalidatePath(`/admin/courses/${courseId}`); +} + +// ── Enrollment ─────────────────────────────────────────────────────────────── + +export async function grantAccess(courseId: string, userId: string) { + await requireAdmin(); + await prisma.courseEnrollment.upsert({ + where: { userId_courseId: { userId, courseId } }, + update: {}, + create: { userId, courseId }, + }); + revalidatePath(`/admin/courses/${courseId}`); +} + +export async function revokeAccess(courseId: string, userId: string) { + await requireAdmin(); + await prisma.courseEnrollment.delete({ + where: { userId_courseId: { userId, courseId } }, + }); + revalidatePath(`/admin/courses/${courseId}`); +} diff --git a/src/app/admin/courses/[courseId]/modules/[moduleId]/actions.ts b/src/app/admin/courses/[courseId]/modules/[moduleId]/actions.ts new file mode 100644 index 0000000..b102c01 --- /dev/null +++ b/src/app/admin/courses/[courseId]/modules/[moduleId]/actions.ts @@ -0,0 +1,43 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { revalidatePath } from "next/cache"; + +async function requireAdmin() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session || session.user.role !== "admin") throw new Error("Forbidden"); +} + +export async function createLesson(moduleId: string, courseId: string, formData: FormData) { + await requireAdmin(); + const title = formData.get("title") as string; + const count = await prisma.lesson.count({ where: { moduleId } }); + const lesson = await prisma.lesson.create({ data: { moduleId, title, order: count } }); + revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`); + return lesson.id; +} + +export async function updateLesson(lessonId: string, courseId: string, moduleId: string, formData: FormData) { + await requireAdmin(); + const title = formData.get("title") as string; + await prisma.lesson.update({ where: { id: lessonId }, data: { title } }); + revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`); +} + +export async function deleteLesson(lessonId: string, courseId: string, moduleId: string) { + await requireAdmin(); + await prisma.lesson.delete({ where: { id: lessonId } }); + revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`); +} + +export async function reorderLessons(moduleId: string, courseId: string, orderedIds: string[]) { + await requireAdmin(); + await Promise.all( + orderedIds.map((id, index) => + prisma.lesson.update({ where: { id }, data: { order: index } }) + ) + ); + revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`); +} diff --git a/src/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/actions.ts b/src/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/actions.ts new file mode 100644 index 0000000..df419b5 --- /dev/null +++ b/src/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/actions.ts @@ -0,0 +1,36 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { revalidatePath } from "next/cache"; + +async function requireAdmin() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session || session.user.role !== "admin") throw new Error("Forbidden"); +} + +export async function saveLesson( + lessonId: string, + courseId: string, + moduleId: string, + data: { + title: string; + kinescopeId: string; + content: object; + published: boolean; + } +) { + await requireAdmin(); + await prisma.lesson.update({ + where: { id: lessonId }, + data: { + title: data.title, + kinescopeId: data.kinescopeId || null, + content: data.content, + published: data.published, + }, + }); + revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`); + revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}/lessons/${lessonId}`); +} diff --git a/src/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/page.tsx b/src/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/page.tsx new file mode 100644 index 0000000..30f0817 --- /dev/null +++ b/src/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/page.tsx @@ -0,0 +1,53 @@ +import { prisma } from "@/lib/prisma"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { LessonEditor } from "@/components/admin/lesson-editor"; + +interface Props { + params: Promise<{ courseId: string; moduleId: string; lessonId: string }>; +} + +export default async function LessonEditorPage({ params }: Props) { + const { courseId, moduleId, lessonId } = await params; + + const lesson = await prisma.lesson.findUnique({ + where: { id: lessonId }, + include: { + module: { + include: { course: { select: { title: true } } }, + }, + }, + }); + + if (!lesson || lesson.moduleId !== moduleId) notFound(); + + return ( +
+ + + +
+ ); +} diff --git a/src/app/admin/courses/[courseId]/modules/[moduleId]/page.tsx b/src/app/admin/courses/[courseId]/modules/[moduleId]/page.tsx new file mode 100644 index 0000000..8e5e07a --- /dev/null +++ b/src/app/admin/courses/[courseId]/modules/[moduleId]/page.tsx @@ -0,0 +1,46 @@ +import { prisma } from "@/lib/prisma"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; +import { SortableLessons } from "@/components/admin/sortable-lessons"; + +interface Props { + params: Promise<{ courseId: string; moduleId: string }>; +} + +export default async function ModulePage({ params }: Props) { + const { courseId, moduleId } = await params; + + const module = await prisma.module.findUnique({ + where: { id: moduleId }, + include: { + course: { select: { title: true } }, + lessons: { orderBy: { order: "asc" } }, + }, + }); + + if (!module || module.courseId !== courseId) notFound(); + + return ( +
+ + +
+
+

{module.title}

+

{module.lessons.length} уроков

+
+
+ +
+ +
+
+ ); +} diff --git a/src/app/admin/courses/[courseId]/page.tsx b/src/app/admin/courses/[courseId]/page.tsx new file mode 100644 index 0000000..ab1ef8b --- /dev/null +++ b/src/app/admin/courses/[courseId]/page.tsx @@ -0,0 +1,72 @@ +import { prisma } from "@/lib/prisma"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { CourseEditForm } from "@/components/admin/course-edit-form"; +import { SortableModules } from "@/components/admin/sortable-modules"; +import { EnrollmentManager } from "@/components/admin/enrollment-manager"; + +interface Props { + params: Promise<{ courseId: string }>; +} + +export default async function CourseDetailPage({ params }: Props) { + const { courseId } = await params; + + const [course, allStudents] = await Promise.all([ + prisma.course.findUnique({ + where: { id: courseId }, + include: { + modules: { + orderBy: { order: "asc" }, + include: { _count: { select: { lessons: true } } }, + }, + enrollments: { include: { user: { select: { id: true, name: true, email: true } } } }, + }, + }), + prisma.user.findMany({ + where: { role: "student" }, + select: { id: true, name: true, email: true }, + orderBy: { name: "asc" }, + }), + ]); + + if (!course) notFound(); + + const enrolledIds = new Set(course.enrollments.map((e) => e.userId)); + + return ( +
+ {/* Breadcrumb */} + + + {/* Course metadata */} +
+

Основная информация

+ +
+ + {/* Modules */} +
+
+

Модули

+ {course.modules.length} модулей +
+ +
+ + {/* Access management */} +
+

Доступ к курсу

+ +
+
+ ); +} diff --git a/src/app/admin/courses/actions.ts b/src/app/admin/courses/actions.ts new file mode 100644 index 0000000..f679b79 --- /dev/null +++ b/src/app/admin/courses/actions.ts @@ -0,0 +1,62 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; + +async function requireAdmin() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session || session.user.role !== "admin") throw new Error("Forbidden"); +} + +function slugify(str: string): string { + const map: Record = { + а:"a",б:"b",в:"v",г:"g",д:"d",е:"e",ё:"yo",ж:"zh",з:"z",и:"i",й:"y", + к:"k",л:"l",м:"m",н:"n",о:"o",п:"p",р:"r",с:"s",т:"t",у:"u",ф:"f", + х:"kh",ц:"ts",ч:"ch",ш:"sh",щ:"shch",ъ:"",ы:"y",ь:"",э:"e",ю:"yu",я:"ya", + }; + return str.toLowerCase() + .replace(/[а-яё]/g, (c) => map[c] ?? c) + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +export async function createCourse(formData: FormData) { + await requireAdmin(); + const title = formData.get("title") as string; + const slug = (formData.get("slug") as string).trim() || slugify(title); + const description = (formData.get("description") as string) || null; + + const course = await prisma.course.create({ + data: { title, slug, description }, + }); + + revalidatePath("/admin/courses"); + redirect(`/admin/courses/${course.id}`); +} + +export async function updateCourse(courseId: string, formData: FormData) { + await requireAdmin(); + const title = formData.get("title") as string; + const slug = formData.get("slug") as string; + const description = (formData.get("description") as string) || null; + const published = formData.get("published") === "true"; + const coverImage = (formData.get("coverImage") as string) || null; + + await prisma.course.update({ + where: { id: courseId }, + data: { title, slug, description, published, coverImage }, + }); + + revalidatePath("/admin/courses"); + revalidatePath(`/admin/courses/${courseId}`); +} + +export async function deleteCourse(courseId: string) { + await requireAdmin(); + await prisma.course.delete({ where: { id: courseId } }); + revalidatePath("/admin/courses"); + redirect("/admin/courses"); +} diff --git a/src/app/admin/courses/page.tsx b/src/app/admin/courses/page.tsx new file mode 100644 index 0000000..f523d52 --- /dev/null +++ b/src/app/admin/courses/page.tsx @@ -0,0 +1,56 @@ +import { prisma } from "@/lib/prisma"; +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { CreateCourseDialog } from "@/components/admin/create-course-dialog"; + +export default async function CoursesPage() { + const courses = await prisma.course.findMany({ + orderBy: { order: "asc" }, + include: { _count: { select: { modules: true, enrollments: true } } }, + }); + + return ( +
+
+
+

Курсы

+

{courses.length} курсов

+
+ +
+ + {courses.length === 0 ? ( +
+

📚

+

Курсов пока нет

+

Создайте первый курс

+
+ ) : ( +
+ {courses.map((course) => ( + +
+
+

{course.title}

+

/{course.slug}

+
+
+
+ {course._count.modules} модулей + {course._count.enrollments} учеников + + {course.published ? "Опубликован" : "Черновик"} + +
+ + ))} +
+ )} +
+ ); +} diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx index e4c7679..eb96368 100644 --- a/src/app/admin/dashboard/page.tsx +++ b/src/app/admin/dashboard/page.tsx @@ -1,46 +1,27 @@ -import { headers } from "next/headers"; -import { auth } from "@/lib/auth"; -import { redirect } from "next/navigation"; -import { LogoutButton } from "@/components/layout/logout-button"; - -export default async function AdminDashboard() { - const session = await auth.api.getSession({ headers: await headers() }); - - if (!session) redirect("/login"); - if (session.user.role !== "admin") redirect("/dashboard"); +import Link from "next/link"; +export default function AdminDashboard() { return ( -
-
-

Second Brain — Админ

-
- {session.user.name} - +
+

Обзор

+

Управление платформой Second Brain.

+
+ +

📚

+

Курсы

+

Управление контентом

+ + +

👥

+

Пользователи

+

Управление доступом

+ +
+

📊

+

Аналитика

+

Этап 10

-
-
-

- Панель администратора -

-

Управление платформой Second Brain.

-
-
-

📚

-

Курсы

-

CRUD — Этап 1

-
-
-

👥

-

Пользователи

-

Управление — Этап 1

-
-
-

📊

-

Аналитика

-

Этап 10

-
-
-
+
); } diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..3ad902c --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,30 @@ +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { AdminNav } from "@/components/admin/admin-nav"; +import { LogoutButton } from "@/components/layout/logout-button"; + +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) redirect("/login"); + if (session.user.role !== "admin") redirect("/dashboard"); + + return ( +
+ +
{children}
+
+ ); +} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx new file mode 100644 index 0000000..2d2c11f --- /dev/null +++ b/src/app/admin/users/page.tsx @@ -0,0 +1,70 @@ +import { prisma } from "@/lib/prisma"; +import { Badge } from "@/components/ui/badge"; + +const roleLabel: Record = { + admin: "Администратор", + curator: "Куратор", + student: "Ученик", +}; + +const roleVariant: Record = { + admin: "default", + curator: "secondary", + student: "outline", +}; + +export default async function UsersPage() { + const users = await prisma.user.findMany({ + orderBy: { createdAt: "desc" }, + include: { _count: { select: { enrollments: true } } }, + }); + + return ( +
+
+

Пользователи

+

{users.length} пользователей

+
+ +
+ + + + + + + + + + + + {users.map((user) => ( + + + + + + + + ))} + +
ПользовательРольКурсовEmail подтверждёнЗарегистрирован
+

{user.name}

+

{user.email}

+
+ + {roleLabel[user.role] ?? user.role} + + + {user._count.enrollments} + + + {user.emailVerified ? "Да" : "Нет"} + + + {new Date(user.createdAt).toLocaleDateString("ru-RU")} +
+
+
+ ); +} diff --git a/src/app/api/admin/upload/route.ts b/src/app/api/admin/upload/route.ts new file mode 100644 index 0000000..5748087 --- /dev/null +++ b/src/app/api/admin/upload/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { uploadFile } from "@/lib/s3"; +import { randomUUID } from "crypto"; + +export async function POST(req: NextRequest) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session || session.user.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const form = await req.formData(); + const file = form.get("file") as File | null; + if (!file) return NextResponse.json({ error: "No file" }, { status: 400 }); + + const ext = file.name.split(".").pop() ?? "bin"; + const key = `uploads/${randomUUID()}.${ext}`; + const buffer = Buffer.from(await file.arrayBuffer()); + + const url = await uploadFile(key, buffer, file.type); + return NextResponse.json({ url, key }); +} diff --git a/src/app/globals.css b/src/app/globals.css index c56032b..168f29c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @import "tw-animate-css"; @import "shadcn/tailwind.css"; +@plugin "@tailwindcss/typography"; @custom-variant dark (&:is(.dark *)); diff --git a/src/components/admin/admin-nav.tsx b/src/components/admin/admin-nav.tsx new file mode 100644 index 0000000..04848a4 --- /dev/null +++ b/src/components/admin/admin-nav.tsx @@ -0,0 +1,34 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; + +const links = [ + { href: "/admin/dashboard", label: "Обзор" }, + { href: "/admin/courses", label: "Курсы" }, + { href: "/admin/users", label: "Пользователи" }, +]; + +export function AdminNav() { + const pathname = usePathname(); + + return ( + <> + {links.map(({ href, label }) => ( + + {label} + + ))} + + ); +} diff --git a/src/components/admin/course-edit-form.tsx b/src/components/admin/course-edit-form.tsx new file mode 100644 index 0000000..049091f --- /dev/null +++ b/src/components/admin/course-edit-form.tsx @@ -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) { + 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) { + 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 ( +
+
+
+ + +
+
+ + +
+
+
+ +