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} пользователей
+
+
+
+
+
+
+ | Пользователь |
+ Роль |
+ Курсов |
+ Email подтверждён |
+ Зарегистрирован |
+
+
+
+ {users.map((user) => (
+
+ |
+ {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 (
+
+ );
+}
diff --git a/src/components/admin/create-course-dialog.tsx b/src/components/admin/create-course-dialog.tsx
new file mode 100644
index 0000000..c10299e
--- /dev/null
+++ b/src/components/admin/create-course-dialog.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { createCourse } from "@/app/admin/courses/actions";
+
+export function CreateCourseDialog() {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+ );
+}
diff --git a/src/components/admin/enrollment-manager.tsx b/src/components/admin/enrollment-manager.tsx
new file mode 100644
index 0000000..728520a
--- /dev/null
+++ b/src/components/admin/enrollment-manager.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { grantAccess, revokeAccess } from "@/app/admin/courses/[courseId]/actions";
+
+interface Student {
+ id: string;
+ name: string;
+ email: string;
+}
+
+interface Props {
+ courseId: string;
+ allStudents: Student[];
+ enrolledIds: string[];
+}
+
+export function EnrollmentManager({ courseId, allStudents, enrolledIds }: Props) {
+ const [enrolled, setEnrolled] = useState(new Set(enrolledIds));
+ const [search, setSearch] = useState("");
+ const [pending, startTransition] = useTransition();
+
+ const filtered = allStudents.filter(
+ (s) =>
+ s.name.toLowerCase().includes(search.toLowerCase()) ||
+ s.email.toLowerCase().includes(search.toLowerCase())
+ );
+
+ function toggle(userId: string) {
+ if (enrolled.has(userId)) {
+ setEnrolled((prev) => { const s = new Set(prev); s.delete(userId); return s; });
+ startTransition(() => revokeAccess(courseId, userId));
+ } else {
+ setEnrolled((prev) => new Set(prev).add(userId));
+ startTransition(() => grantAccess(courseId, userId));
+ }
+ }
+
+ const enrolledStudents = allStudents.filter((s) => enrolled.has(s.id));
+
+ return (
+
+ {enrolledStudents.length > 0 && (
+
+
Доступ открыт ({enrolledStudents.length}):
+
+ {enrolledStudents.map((s) => (
+
+ {s.name}
+
+
+ ))}
+
+
+ )}
+
+
+
Добавить ученика:
+
setSearch(e.target.value)}
+ className="max-w-sm mb-3"
+ />
+
+ {filtered.map((student) => (
+
+
+
{student.name}
+
{student.email}
+
+
+
+ ))}
+ {filtered.length === 0 && (
+
Студентов не найдено
+ )}
+
+
+
+ );
+}
diff --git a/src/components/admin/lesson-editor.tsx b/src/components/admin/lesson-editor.tsx
new file mode 100644
index 0000000..5c8bb57
--- /dev/null
+++ b/src/components/admin/lesson-editor.tsx
@@ -0,0 +1,183 @@
+"use client";
+
+import { useState, useCallback, useTransition } from "react";
+import { useEditor, EditorContent } from "@tiptap/react";
+import StarterKit from "@tiptap/starter-kit";
+import Image from "@tiptap/extension-image";
+import Link from "@tiptap/extension-link";
+import Placeholder from "@tiptap/extension-placeholder";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import { saveLesson } from "@/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/actions";
+
+interface LessonData {
+ id: string;
+ title: string;
+ kinescopeId: string;
+ content: object;
+ published: boolean;
+}
+
+export function LessonEditor({
+ lesson,
+ courseId,
+ moduleId,
+}: {
+ lesson: LessonData;
+ courseId: string;
+ moduleId: string;
+}) {
+ const [title, setTitle] = useState(lesson.title);
+ const [kinescopeId, setKinescopeId] = useState(lesson.kinescopeId);
+ const [published, setPublished] = useState(lesson.published);
+ const [uploading, setUploading] = useState(false);
+ const [saved, setSaved] = useState(false);
+ const [pending, startTransition] = useTransition();
+
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ Image.configure({ inline: false }),
+ Link.configure({ openOnClick: false }),
+ Placeholder.configure({ placeholder: "Начните писать текст урока..." }),
+ ],
+ content: Object.keys(lesson.content).length ? lesson.content : undefined,
+ editorProps: {
+ attributes: {
+ class: "prose prose-slate max-w-none min-h-[300px] focus:outline-none p-4",
+ },
+ },
+ });
+
+ const uploadImage = useCallback(async () => {
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = "image/*";
+ input.onchange = async () => {
+ const file = input.files?.[0];
+ if (!file || !editor) 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) editor.chain().focus().setImage({ src: data.url }).run();
+ setUploading(false);
+ };
+ input.click();
+ }, [editor]);
+
+ function handleSave() {
+ if (!editor) return;
+ startTransition(async () => {
+ await saveLesson(lesson.id, courseId, moduleId, {
+ title,
+ kinescopeId,
+ content: editor.getJSON(),
+ published,
+ });
+ setSaved(true);
+ setTimeout(() => setSaved(false), 2000);
+ });
+ }
+
+ return (
+
+ {/* Header controls */}
+
+
+
+ {published ? "Опубликован" : "Черновик"}
+
+
+
+
+ {/* Title */}
+
+
+ setTitle(e.target.value)}
+ className="text-lg font-medium"
+ />
+
+
+ {/* Kinescope ID */}
+
+
+ setKinescopeId(e.target.value)}
+ placeholder="Оставьте пустым если видео нет"
+ className="font-mono text-sm"
+ />
+
+
+ {/* TipTap Editor */}
+
+
+
+ {/* Toolbar */}
+
+
editor?.chain().focus().toggleBold().run()} active={editor?.isActive("bold")}>Ж
+
editor?.chain().focus().toggleItalic().run()} active={editor?.isActive("italic")}>К
+
editor?.chain().focus().toggleHeading({ level: 2 }).run()} active={editor?.isActive("heading", { level: 2 })}>H2
+
editor?.chain().focus().toggleHeading({ level: 3 }).run()} active={editor?.isActive("heading", { level: 3 })}>H3
+
+
editor?.chain().focus().toggleBulletList().run()} active={editor?.isActive("bulletList")}>• Список
+
editor?.chain().focus().toggleOrderedList().run()} active={editor?.isActive("orderedList")}>1. Список
+
editor?.chain().focus().toggleBlockquote().run()} active={editor?.isActive("blockquote")}>“”
+
editor?.chain().focus().toggleCodeBlock().run()} active={editor?.isActive("codeBlock")}>{'>'}
+
+
{uploading ? "Загрузка..." : "🖼 Фото"}
+
+
editor?.chain().focus().undo().run()}>↩
+
editor?.chain().focus().redo().run()}>↪
+
+
+ {/* Editor content */}
+
+
+
+
+
+ );
+}
+
+function ToolBtn({
+ onClick,
+ active,
+ disabled,
+ children,
+}: {
+ onClick: () => void;
+ active?: boolean;
+ disabled?: boolean;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/admin/sortable-lessons.tsx b/src/components/admin/sortable-lessons.tsx
new file mode 100644
index 0000000..e31b643
--- /dev/null
+++ b/src/components/admin/sortable-lessons.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import {
+ DndContext,
+ closestCenter,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ DragEndEvent,
+} from "@dnd-kit/core";
+import {
+ SortableContext,
+ verticalListSortingStrategy,
+ useSortable,
+ arrayMove,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import Link from "next/link";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { createLesson, deleteLesson, updateLesson, reorderLessons } from "@/app/admin/courses/[courseId]/modules/[moduleId]/actions";
+
+interface Lesson {
+ id: string;
+ title: string;
+ order: number;
+ published: boolean;
+}
+
+function SortableLesson({
+ lesson,
+ courseId,
+ moduleId,
+}: {
+ lesson: Lesson;
+ courseId: string;
+ moduleId: string;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [pending, startTransition] = useTransition();
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
+ useSortable({ id: lesson.id });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ };
+
+ function handleUpdate(e: React.FormEvent) {
+ e.preventDefault();
+ const fd = new FormData(e.currentTarget);
+ startTransition(() => updateLesson(lesson.id, courseId, moduleId, fd));
+ setEditing(false);
+ }
+
+ function handleDelete() {
+ if (!confirm(`Удалить урок "${lesson.title}"?`)) return;
+ startTransition(() => deleteLesson(lesson.id, courseId, moduleId));
+ }
+
+ return (
+
+
+ {editing ? (
+
+ ) : (
+ <>
+ {lesson.title}
+
+ {lesson.published ? "Опубликован" : "Черновик"}
+
+
+ Редактировать
+
+
+
+ >
+ )}
+
+ );
+}
+
+export function SortableLessons({
+ courseId,
+ moduleId,
+ lessons,
+}: {
+ courseId: string;
+ moduleId: string;
+ lessons: Lesson[];
+}) {
+ const [items, setItems] = useState(lessons);
+ const [, startTransition] = useTransition();
+
+ const sensors = useSensors(useSensor(PointerSensor));
+
+ function handleDragEnd(event: DragEndEvent) {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+
+ const oldIndex = items.findIndex((l) => l.id === active.id);
+ const newIndex = items.findIndex((l) => l.id === over.id);
+ const newItems = arrayMove(items, oldIndex, newIndex);
+ setItems(newItems);
+ startTransition(() => reorderLessons(moduleId, courseId, newItems.map((l) => l.id)));
+ }
+
+ function handleCreate(e: React.FormEvent) {
+ e.preventDefault();
+ const fd = new FormData(e.currentTarget);
+ e.currentTarget.reset();
+ startTransition(() => createLesson(moduleId, courseId, fd));
+ }
+
+ return (
+
+
+ l.id)} strategy={verticalListSortingStrategy}>
+ {items.map((lesson) => (
+
+ ))}
+
+
+
+ {items.length === 0 && (
+
Уроков пока нет. Добавьте первый.
+ )}
+
+
+
+ );
+}
diff --git a/src/components/admin/sortable-modules.tsx b/src/components/admin/sortable-modules.tsx
new file mode 100644
index 0000000..bc643de
--- /dev/null
+++ b/src/components/admin/sortable-modules.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import {
+ DndContext,
+ closestCenter,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ DragEndEvent,
+} from "@dnd-kit/core";
+import {
+ SortableContext,
+ verticalListSortingStrategy,
+ useSortable,
+ arrayMove,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { createModule, deleteModule, updateModule, reorderModules } from "@/app/admin/courses/[courseId]/actions";
+
+interface Module {
+ id: string;
+ title: string;
+ order: number;
+ _count: { lessons: number };
+}
+
+function SortableModule({
+ mod,
+ courseId,
+}: {
+ mod: Module;
+ courseId: string;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [pending, startTransition] = useTransition();
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
+ useSortable({ id: mod.id });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ };
+
+ function handleUpdate(e: React.FormEvent) {
+ e.preventDefault();
+ const fd = new FormData(e.currentTarget);
+ startTransition(() => updateModule(mod.id, courseId, fd));
+ setEditing(false);
+ }
+
+ function handleDelete() {
+ if (!confirm(`Удалить модуль "${mod.title}"? Все уроки будут удалены.`)) return;
+ startTransition(() => deleteModule(mod.id, courseId));
+ }
+
+ return (
+
+
+ {editing ? (
+
+ ) : (
+ <>
+ {mod.title}
+ {mod._count.lessons} уроков
+
+ Уроки
+
+
+
+ >
+ )}
+
+ );
+}
+
+export function SortableModules({ courseId, modules }: { courseId: string; modules: Module[] }) {
+ const [items, setItems] = useState(modules);
+ const [, startTransition] = useTransition();
+
+ const sensors = useSensors(useSensor(PointerSensor));
+
+ function handleDragEnd(event: DragEndEvent) {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+
+ const oldIndex = items.findIndex((m) => m.id === active.id);
+ const newIndex = items.findIndex((m) => m.id === over.id);
+ const newItems = arrayMove(items, oldIndex, newIndex);
+ setItems(newItems);
+ startTransition(() => reorderModules(courseId, newItems.map((m) => m.id)));
+ }
+
+ function handleCreate(e: React.FormEvent) {
+ e.preventDefault();
+ const fd = new FormData(e.currentTarget);
+ e.currentTarget.reset();
+ startTransition(() => createModule(courseId, fd));
+ }
+
+ return (
+
+
+ m.id)} strategy={verticalListSortingStrategy}>
+ {items.map((mod) => (
+
+ ))}
+
+
+
+ {items.length === 0 && (
+
Модулей пока нет. Добавьте первый.
+ )}
+
+
+
+ );
+}
diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..0ee2c5f
--- /dev/null
+++ b/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,187 @@
+"use client"
+
+import * as React from "react"
+import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
+ return
+}
+
+function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: AlertDialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Popup.Props & {
+ size?: "default" | "sm"
+}) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogMedia({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ variant = "outline",
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Close.Props &
+ Pick, "variant" | "size">) {
+ return (
+ }
+ {...props}
+ />
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+}
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000..b20959d
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,52 @@
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ secondary:
+ "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+ destructive:
+ "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
+ outline:
+ "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+ ghost:
+ "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant = "default",
+ render,
+ ...props
+}: useRender.ComponentProps<"span"> & VariantProps) {
+ return useRender({
+ defaultTagName: "span",
+ props: mergeProps<"span">(
+ {
+ className: cn(badgeVariants({ variant }), className),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "badge",
+ variant,
+ },
+ })
+}
+
+export { Badge, badgeVariants }
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..014f5aa
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..7d21bab
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import * as React from "react"
+import { Input as InputPrimitive } from "@base-ui/react/input"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx
new file mode 100644
index 0000000..74da65c
--- /dev/null
+++ b/src/components/ui/label.tsx
@@ -0,0 +1,20 @@
+"use client"
+
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Label({ className, ...props }: React.ComponentProps<"label">) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
new file mode 100644
index 0000000..e8021f5
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -0,0 +1,201 @@
+"use client"
+
+import * as React from "react"
+import { Select as SelectPrimitive } from "@base-ui/react/select"
+
+import { cn } from "@/lib/utils"
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+
+const Select = SelectPrimitive.Root
+
+function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
+ return (
+
+ )
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+ return (
+
+ )
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: SelectPrimitive.Trigger.Props & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+ }
+ />
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ side = "bottom",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ alignItemWithTrigger = true,
+ ...props
+}: SelectPrimitive.Popup.Props &
+ Pick<
+ SelectPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
+ >) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: SelectPrimitive.GroupLabel.Props) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: SelectPrimitive.Item.Props) {
+ return (
+
+
+ {children}
+
+
+ }
+ >
+
+
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: SelectPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx
new file mode 100644
index 0000000..9280ee5
--- /dev/null
+++ b/src/components/ui/sonner.tsx
@@ -0,0 +1,49 @@
+"use client"
+
+import { useTheme } from "next-themes"
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme()
+
+ return (
+
+ ),
+ info: (
+
+ ),
+ warning: (
+
+ ),
+ error: (
+
+ ),
+ loading: (
+
+ ),
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ toastOptions={{
+ classNames: {
+ toast: "cn-toast",
+ },
+ }}
+ {...props}
+ />
+ )
+}
+
+export { Toaster }
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..04d27f7
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }