├── .eslintrc.json
├── .env
├── logo.png
├── src
├── app
│ ├── favicon.ico
│ ├── fonts
│ │ ├── GeistVF.woff
│ │ └── GeistMonoVF.woff
│ ├── leaderboard
│ │ └── page.jsx
│ ├── users
│ │ └── [id]
│ │ │ └── page.jsx
│ ├── api
│ │ ├── generate-course-plan
│ │ │ └── route.js
│ │ ├── topics
│ │ │ └── [id]
│ │ │ │ └── route.js
│ │ ├── leaderboard
│ │ │ └── route.js
│ │ ├── users
│ │ │ └── [id]
│ │ │ │ ├── route.js
│ │ │ │ └── courses
│ │ │ │ └── route.js
│ │ ├── update-exp
│ │ │ └── route.js
│ │ ├── background-tasks
│ │ │ └── route.js
│ │ ├── login
│ │ │ └── route.js
│ │ ├── quizzes
│ │ │ └── [id]
│ │ │ │ └── route.js
│ │ ├── generate-quiz
│ │ │ └── route.js
│ │ ├── register
│ │ │ └── route.js
│ │ ├── courses
│ │ │ ├── [id]
│ │ │ │ └── route.js
│ │ │ └── route.js
│ │ └── enroll
│ │ │ └── route.js
│ ├── layout.js
│ ├── settings
│ │ └── page.js
│ ├── page.js
│ ├── globals.css
│ └── courses
│ │ └── [id]
│ │ └── page.js
├── lib
│ ├── .auth.js.swp
│ ├── utils.js
│ ├── loadBalancer.js
│ ├── backgroundService.js
│ └── gemini.js
├── components
│ ├── ThemeProvider.jsx
│ ├── ui
│ │ ├── label.jsx
│ │ ├── textarea.jsx
│ │ ├── input.jsx
│ │ ├── badge.jsx
│ │ ├── switch.jsx
│ │ ├── radio-group.jsx
│ │ ├── card.jsx
│ │ ├── accordion.jsx
│ │ ├── button.jsx
│ │ ├── table.jsx
│ │ ├── dialog.jsx
│ │ └── select.jsx
│ ├── ErrorBoundary.js
│ ├── ThemeToggle.jsx
│ ├── OnboardingInfo.jsx
│ ├── CourseView.jsx
│ ├── Navigation.jsx
│ ├── CourseTopicsModal.jsx
│ ├── CourseCard.jsx
│ ├── Quiz.jsx
│ ├── Leaderboard.jsx
│ ├── Onboarding.jsx
│ ├── UserProfile.jsx
│ ├── InterestingCourses.jsx
│ ├── CourseDetails.jsx
│ ├── MyCourses.jsx
│ └── CreateCourse.jsx
└── middleware
│ └── auth.js
├── jsconfig.json
├── prisma
├── migrations
│ ├── 20241019144804_add_streak_days_to_user
│ │ └── migration.sql
│ ├── migration_lock.toml
│ ├── 20241019171036_add_student_count
│ │ └── migration.sql
│ ├── 20241019144531_add_timestamps_to_course
│ │ └── migration.sql
│ ├── 20241019142330_quiz
│ │ └── migration.sql
│ └── 20241019130510_remove_email_field
│ │ └── migration.sql
└── schema.prisma
├── postcss.config.mjs
├── next.config.js
├── components.json
├── .gitignore
├── vercel.json
├── LICENSE.md
├── scripts
├── clearDatabase.js
└── seed.js
├── package.json
├── tailwind.config.js
└── README.md
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | GEMINI_API_KEYS=
2 | DATABASE_URL=""
3 | JWT_SECRET=your_secret_key_here
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vlad0n1m/decentrathon2-0/HEAD/logo.png
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vlad0n1m/decentrathon2-0/HEAD/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/lib/.auth.js.swp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vlad0n1m/decentrathon2-0/HEAD/src/lib/.auth.js.swp
--------------------------------------------------------------------------------
/src/app/fonts/GeistVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vlad0n1m/decentrathon2-0/HEAD/src/app/fonts/GeistVF.woff
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "paths": {
4 | "@/*": ["./src/*"]
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/fonts/GeistMonoVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vlad0n1m/decentrathon2-0/HEAD/src/app/fonts/GeistMonoVF.woff
--------------------------------------------------------------------------------
/prisma/migrations/20241019144804_add_streak_days_to_user/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "User" ADD COLUMN "streakDays" INTEGER NOT NULL DEFAULT 0;
3 |
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/src/components/ThemeProvider.jsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { ThemeProvider as NextThemesProvider } from 'next-themes'
4 |
5 | export function ThemeProvider({ children, ...props }) {
6 | return {children}
7 | }
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | env: {
4 | GEMINI_API_KEY: process.env.GEMINI_API_KEY,
5 | },
6 | images: {
7 | domains: ['via.placeholder.com'],
8 | },
9 | };
10 |
11 | module.exports = nextConfig;
--------------------------------------------------------------------------------
/src/app/leaderboard/page.jsx:
--------------------------------------------------------------------------------
1 | import { Leaderboard } from '@/components/Leaderboard';
2 |
3 | export default function LeaderboardPage() {
4 | return (
5 |
6 |
7 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/src/app/users/[id]/page.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { UserProfile } from '@/components/UserProfile';
4 | import { useParams } from 'next/navigation';
5 |
6 | export default function UserProfilePage() {
7 | const { id } = useParams();
8 | return ;
9 | }
--------------------------------------------------------------------------------
/prisma/migrations/20241019171036_add_student_count/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "Course" ADD COLUMN "studentCount" INTEGER NOT NULL DEFAULT 0;
3 |
4 | -- AlterTable
5 | ALTER TABLE "Quiz" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
6 | ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
7 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": false,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "builds": [
3 | {
4 | "src": "next.config.js",
5 | "use": "@vercel/next"
6 | }
7 | ],
8 | "build": {
9 | "env": {
10 | "NEXT_TELEMETRY_DISABLED": "1"
11 | }
12 | },
13 | "buildCommand": "npx prisma generate && npm run build",
14 | "outputDirectory": ".",
15 | "devCommand": "npm run dev",
16 | "installCommand": "npm install",
17 | "rewrites": [
18 | {
19 | "source": "/(.*)",
20 | "destination": "/index.html"
21 | }
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/api/generate-course-plan/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { generateCourseContent } from '@/lib/gemini';
3 |
4 | export async function POST(request) {
5 | try {
6 | const { topic, level } = await request.json();
7 |
8 | const coursePlan = await generateCourseContent(topic, level);
9 |
10 | return NextResponse.json(coursePlan);
11 | } catch (error) {
12 | console.error('Error generating course plan:', error);
13 | return NextResponse.json({ error: 'Failed to generate course plan' }, { status: 500 });
14 | }
15 | }
--------------------------------------------------------------------------------
/src/components/ui/label.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva } from "class-variance-authority";
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef(({ className, ...props }, ref) => (
14 |
15 | ))
16 | Label.displayName = LabelPrimitive.Root.displayName
17 |
18 | export { Label }
19 |
--------------------------------------------------------------------------------
/src/middleware/auth.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import jwt from 'jsonwebtoken';
3 |
4 | export function auth(handler) {
5 | return async (request) => {
6 | const token = request.headers.get('Authorization')?.split(' ')[1];
7 |
8 | if (!token) {
9 | return NextResponse.json({ error: 'No token provided' }, { status: 401 });
10 | }
11 |
12 | try {
13 | const decoded = jwt.verify(token, process.env.JWT_SECRET);
14 | request.user = decoded;
15 | return handler(request);
16 | } catch (error) {
17 | return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
18 | }
19 | };
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/api/topics/[id]/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { PrismaClient } from '@prisma/client';
3 |
4 | const prisma = new PrismaClient();
5 |
6 | export async function PUT(request, { params }) {
7 | try {
8 | const { content, status } = await request.json();
9 | const updatedTopic = await prisma.topic.update({
10 | where: { id: params.id },
11 | data: { content, status },
12 | });
13 | return NextResponse.json(updatedTopic);
14 | } catch (error) {
15 | console.error('Error updating topic:', error);
16 | return NextResponse.json({ error: 'Failed to update topic' }, { status: 500 });
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/ErrorBoundary.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | class ErrorBoundary extends React.Component {
4 | constructor(props) {
5 | super(props);
6 | this.state = { hasError: false, error: null };
7 | }
8 |
9 | static getDerivedStateFromError(error) {
10 | return { hasError: true, error };
11 | }
12 |
13 | componentDidCatch(error, errorInfo) {
14 | console.error("Uncaught error:", error, errorInfo);
15 | }
16 |
17 | render() {
18 | if (this.state.hasError) {
19 | return Something went wrong: {this.state.error.message}
;
20 | }
21 |
22 | return this.props.children;
23 | }
24 | }
25 |
26 | export default ErrorBoundary;
27 |
--------------------------------------------------------------------------------
/src/lib/utils.js:
--------------------------------------------------------------------------------
1 | import { clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
8 | export function getContrastColor(bgColor) {
9 | // Преобразуем цвет в RGB
10 | let color = bgColor.charAt(0) === '#' ? bgColor.substring(1, 7) : bgColor;
11 | let r = parseInt(color.substring(0, 2), 16);
12 | let g = parseInt(color.substring(2, 4), 16);
13 | let b = parseInt(color.substring(4, 6), 16);
14 |
15 | // Вычисляем яркость
16 | let yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
17 |
18 | // Возвращаем черный или белый в зависимости от яркости фона
19 | return (yiq >= 128) ? 'black' : 'white';
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Textarea = React.forwardRef(({ className, ...props }, ref) => {
6 | return (
7 | ()
14 | );
15 | })
16 | Textarea.displayName = "Textarea"
17 |
18 | export { Textarea }
19 |
--------------------------------------------------------------------------------
/src/components/ui/input.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Input = React.forwardRef(({ className, type, ...props }, ref) => {
6 | return (
7 | ()
15 | );
16 | })
17 | Input.displayName = "Input"
18 |
19 | export { Input }
20 |
--------------------------------------------------------------------------------
/src/app/layout.js:
--------------------------------------------------------------------------------
1 | import { Inter } from 'next/font/google'
2 | import './globals.css'
3 | import { Navigation } from '@/components/Navigation'
4 | import { ThemeProvider } from '@/components/ThemeProvider'
5 |
6 | const inter = Inter({ subsets: ['latin'] })
7 |
8 | export const metadata = {
9 | title: 'Your Course App',
10 | description: 'Learn anything with AI-generated courses',
11 | }
12 |
13 | export default function RootLayout({ children }) {
14 | return (
15 |
16 |
17 |
18 |
19 | {children}
20 |
21 |
22 |
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/src/app/api/leaderboard/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { PrismaClient } from '@prisma/client';
3 |
4 | const prisma = new PrismaClient();
5 |
6 | export async function GET() {
7 | try {
8 | const topUsers = await prisma.user.findMany({
9 | take: 50,
10 | orderBy: {
11 | exp: 'desc',
12 | },
13 | select: {
14 | id: true,
15 | username: true,
16 | exp: true,
17 | },
18 | });
19 |
20 | return NextResponse.json(topUsers);
21 | } catch (error) {
22 | console.error('Error fetching leaderboard:', error);
23 | return NextResponse.json({ error: 'Failed to fetch leaderboard' }, { status: 500 });
24 | } finally {
25 | await prisma.$disconnect();
26 | }
27 | }
--------------------------------------------------------------------------------
/src/components/ThemeToggle.jsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState, useEffect } from 'react'
4 | import { useTheme } from 'next-themes'
5 | import { Switch } from '@/components/ui/switch'
6 | import { Moon, Sun } from 'lucide-react'
7 |
8 | export function ThemeToggle() {
9 | const [mounted, setMounted] = useState(false)
10 | const { theme, setTheme } = useTheme()
11 |
12 | useEffect(() => {
13 | setMounted(true)
14 | }, [])
15 |
16 | if (!mounted) {
17 | return null
18 | }
19 |
20 | return (
21 |
22 |
23 | setTheme(theme === 'dark' ? 'light' : 'dark')}
26 | />
27 |
28 |
29 | )
30 | }
--------------------------------------------------------------------------------
/src/app/api/users/[id]/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { PrismaClient } from '@prisma/client';
3 |
4 | const prisma = new PrismaClient();
5 |
6 | export async function GET(request, { params }) {
7 | try {
8 | const user = await prisma.user.findUnique({
9 | where: { id: params.id },
10 | select: {
11 | id: true,
12 | username: true,
13 | exp: true,
14 | createdAt: true,
15 | authoredCourses: true,
16 | enrolledCourses: true,
17 | }
18 | });
19 |
20 | if (!user) {
21 | return NextResponse.json({ error: 'User not found' }, { status: 404 });
22 | }
23 |
24 | return NextResponse.json(user);
25 | } catch (error) {
26 | console.error('Error fetching user:', error);
27 | return NextResponse.json({ error: 'Failed to fetch user' }, { status: 500 });
28 | } finally {
29 | await prisma.$disconnect();
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/app/api/update-exp/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { PrismaClient } from '@prisma/client';
3 |
4 | const prisma = new PrismaClient();
5 |
6 | export async function POST(request) {
7 | console.log('Update EXP API route called');
8 | try {
9 | const { userId, exp } = await request.json();
10 | console.log('Received data:', { userId, exp });
11 |
12 | const updatedUser = await prisma.user.update({
13 | where: { id: userId },
14 | data: {
15 | exp: {
16 | increment: exp
17 | }
18 | }
19 | });
20 |
21 | console.log('Updated user:', updatedUser);
22 | return NextResponse.json({ success: true, newExp: updatedUser.exp });
23 | } catch (error) {
24 | console.error('Error updating EXP:', error);
25 | return NextResponse.json({ error: 'Failed to update EXP' }, { status: 500 });
26 | } finally {
27 | await prisma.$disconnect();
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/app/api/users/[id]/courses/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { PrismaClient } from '@prisma/client';
3 |
4 | const prisma = new PrismaClient();
5 |
6 | export async function GET(request, { params }) {
7 | try {
8 | const { id } = params;
9 |
10 | const user = await prisma.user.findUnique({
11 | where: { id },
12 | include: {
13 | enrolledCourses: true,
14 | authoredCourses: true,
15 | },
16 | });
17 |
18 | if (!user) {
19 | return NextResponse.json({ error: 'User not found' }, { status: 404 });
20 | }
21 |
22 | return NextResponse.json({
23 | enrolledCourses: user.enrolledCourses,
24 | createdCourses: user.authoredCourses,
25 | });
26 | } catch (error) {
27 | console.error('Error fetching user courses:', error);
28 | return NextResponse.json({ error: 'Failed to fetch user courses' }, { status: 500 });
29 | } finally {
30 | await prisma.$disconnect();
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/OnboardingInfo.jsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent } from "@/components/ui/card"
2 | import { Badge } from "@/components/ui/badge"
3 |
4 | export function OnboardingInfo() {
5 | return (
6 |
7 |
8 | Почему стоит присоединиться к нам?
9 |
10 | -
11 | AI
12 | Персонализированные курсы, созданные с помощью ИИ
13 |
14 | -
15 | EXP
16 | Зарабатывайте опыт и поднимайтесь в рейтинге
17 |
18 | -
19 | Гибкость
20 | Учитесь в своем темпе, в любое время
21 |
22 |
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/src/app/api/background-tasks/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { PrismaClient } from '@prisma/client';
3 | import { generateTopicContent } from '@/lib/gemini';
4 |
5 | const prisma = new PrismaClient();
6 |
7 | export async function POST(request) {
8 | try {
9 | const { courseId, topicId } = await request.json();
10 |
11 | const course = await prisma.course.findUnique({
12 | where: { id: courseId },
13 | include: { topics: true },
14 | });
15 |
16 | const topic = course.topics.find(t => t.id === topicId);
17 | const content = await generateTopicContent(course.title, topic.title);
18 |
19 | await prisma.topic.update({
20 | where: { id: topicId },
21 | data: { content, status: 'COMPLETED' },
22 | });
23 |
24 | return NextResponse.json({ success: true });
25 | } catch (error) {
26 | console.error('Error processing background task:', error);
27 | return NextResponse.json({ error: 'Failed to process background task' }, { status: 500 });
28 | } finally {
29 | await prisma.$disconnect();
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/app/settings/page.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useEffect } from 'react';
4 | import { useTheme } from 'next-themes';
5 | import { Button } from "@/components/ui/button"
6 | import { Input } from "@/components/ui/input"
7 | import { Switch } from "@/components/ui/switch"
8 | import { Label } from "@/components/ui/label"
9 | import { UserProfile } from '@/components/UserProfile';
10 | import { ThemeToggle } from '@/components/ThemeToggle';
11 | export default function SettingsPage() {
12 | const [userId, setUserId] = useState(null);
13 |
14 | useEffect(() => {
15 | const storedUserId = localStorage.getItem('userId');
16 | if (storedUserId) {
17 | setUserId(storedUserId);
18 | }
19 | }, []);
20 |
21 | return (
22 |
23 |
Настройки
24 | {userId &&
}
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Proprietary License
2 |
3 | Copyright (c) 2024 Vladislav Karachkov
4 |
5 | All rights reserved.
6 |
7 | The contents of this repository are proprietary and confidential.
8 | Unauthorized copying, transferring or reproduction of the contents of this repository, via any medium is strictly prohibited.
9 |
10 | The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
11 | for any purpose other than the purpose for which they were provided to you.
12 |
13 | The software is provided "AS IS", without warranty of any kind, express or implied, including but not limited to
14 | the warranties of merchantability, fitness for a particular purpose and non infringement.
15 | In no event shall the authors or copyright holders be liable for any claim, damages or other liability,
16 | whether in an action of contract, tort or otherwise, arising from, out of or in connection with the software
17 | or the use or other dealings in the software.
18 |
19 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
20 |
--------------------------------------------------------------------------------
/src/components/ui/badge.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva } from "class-variance-authority";
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | function Badge({
27 | className,
28 | variant,
29 | ...props
30 | }) {
31 | return ();
32 | }
33 |
34 | export { Badge, badgeVariants }
35 |
--------------------------------------------------------------------------------
/src/components/ui/switch.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SwitchPrimitives from "@radix-ui/react-switch"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Switch = React.forwardRef(({ className, ...props }, ref) => (
9 |
16 |
20 |
21 | ))
22 | Switch.displayName = SwitchPrimitives.Root.displayName
23 |
24 | export { Switch }
25 |
--------------------------------------------------------------------------------
/src/lib/loadBalancer.js:
--------------------------------------------------------------------------------
1 | const API_KEYS = process.env.GEMINI_API_KEYS ? process.env.GEMINI_API_KEYS.split(',') : [];
2 | let currentIndex = 0;
3 | const cooldownPeriod = 1000; // 1 second cooldown
4 | const lastRequestTime = new Map();
5 |
6 | export function getNextApiKey() {
7 | if (API_KEYS.length === 0) {
8 | console.error('No API keys available. Please set the GEMINI_API_KEYS environment variable.');
9 | return null;
10 | }
11 |
12 | const now = Date.now();
13 | let apiKey;
14 | let cooldownRemaining = 0;
15 |
16 | for (let i = 0; i < API_KEYS.length; i++) {
17 | currentIndex = (currentIndex + 1) % API_KEYS.length;
18 | apiKey = API_KEYS[currentIndex];
19 | const lastRequest = lastRequestTime.get(apiKey) || 0;
20 | cooldownRemaining = Math.max(0, cooldownPeriod - (now - lastRequest));
21 |
22 | if (cooldownRemaining === 0) {
23 | lastRequestTime.set(apiKey, now);
24 | return apiKey;
25 | }
26 | }
27 |
28 | // If all keys are on cooldown, wait for the shortest remaining cooldown
29 | return new Promise((resolve) => {
30 | setTimeout(() => {
31 | resolve(getNextApiKey());
32 | }, cooldownRemaining);
33 | });
34 | }
35 |
--------------------------------------------------------------------------------
/src/app/api/login/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { PrismaClient } from '@prisma/client';
3 | import bcrypt from 'bcryptjs';
4 | import jwt from 'jsonwebtoken';
5 |
6 | const prisma = new PrismaClient();
7 |
8 | export async function POST(request) {
9 | try {
10 | const { username, password } = await request.json();
11 |
12 | const user = await prisma.user.findUnique({ where: { username } });
13 |
14 | if (!user) {
15 | return NextResponse.json({ error: 'User not found' }, { status: 404 });
16 | }
17 |
18 | const isPasswordValid = await bcrypt.compare(password, user.password);
19 |
20 | if (!isPasswordValid) {
21 | return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
22 | }
23 |
24 | const token = jwt.sign(
25 | { userId: user.id, username: user.username },
26 | process.env.JWT_SECRET,
27 | { expiresIn: '1d' }
28 | );
29 |
30 | return NextResponse.json({
31 | token,
32 | user: {
33 | id: user.id,
34 | username: user.username,
35 | }
36 | });
37 |
38 | } catch (error) {
39 | console.error('Login error:', error);
40 | return NextResponse.json({ error: 'Server error' }, { status: 500 });
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/app/api/quizzes/[id]/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { PrismaClient } from '@prisma/client';
3 |
4 | const prisma = new PrismaClient();
5 |
6 | export async function GET(request, { params }) {
7 | try {
8 | const { id } = params;
9 |
10 | const quiz = await prisma.quiz.findUnique({
11 | where: { id },
12 | include: {
13 | topic: {
14 | select: {
15 | title: true,
16 | },
17 | },
18 | },
19 | });
20 |
21 | if (!quiz) {
22 | return NextResponse.json({ error: 'Quiz not found' }, { status: 404 });
23 | }
24 |
25 | // Parse the questions JSON string into an object
26 | const parsedQuestions = JSON.parse(quiz.questions);
27 |
28 | // Create a response object with parsed questions
29 | const quizResponse = {
30 | id: quiz.id,
31 | title: quiz.title,
32 | topicTitle: quiz.topic.title,
33 | questions: parsedQuestions,
34 | };
35 |
36 | return NextResponse.json(quizResponse);
37 | } catch (error) {
38 | console.error('Error fetching quiz:', error);
39 | return NextResponse.json({ error: 'Failed to fetch quiz' }, { status: 500 });
40 | } finally {
41 | await prisma.$disconnect();
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/prisma/migrations/20241019144531_add_timestamps_to_course/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `lastActiveDate` on the `User` table. All the data in the column will be lost.
5 | - You are about to drop the column `streakDays` on the `User` table. All the data in the column will be lost.
6 | - You are about to drop the `Question` table. If the table is not empty, all the data it contains will be lost.
7 | - You are about to drop the `Subtopic` table. If the table is not empty, all the data it contains will be lost.
8 | - Added the required column `title` to the `Quiz` table without a default value. This is not possible if the table is not empty.
9 |
10 | */
11 | -- DropForeignKey
12 | ALTER TABLE "Question" DROP CONSTRAINT "Question_quizId_fkey";
13 |
14 | -- DropForeignKey
15 | ALTER TABLE "Subtopic" DROP CONSTRAINT "Subtopic_quizId_fkey";
16 |
17 | -- DropForeignKey
18 | ALTER TABLE "Subtopic" DROP CONSTRAINT "Subtopic_topicId_fkey";
19 |
20 | -- AlterTable
21 | ALTER TABLE "Quiz" ADD COLUMN "title" TEXT NOT NULL,
22 | ALTER COLUMN "questions" SET DATA TYPE TEXT;
23 |
24 | -- AlterTable
25 | ALTER TABLE "User" DROP COLUMN "lastActiveDate",
26 | DROP COLUMN "streakDays";
27 |
28 | -- DropTable
29 | DROP TABLE "Question";
30 |
31 | -- DropTable
32 | DROP TABLE "Subtopic";
33 |
--------------------------------------------------------------------------------
/scripts/clearDatabase.js:
--------------------------------------------------------------------------------
1 | const { PrismaClient } = require('@prisma/client');
2 |
3 | const prisma = new PrismaClient();
4 |
5 | async function clearDatabase() {
6 | try {
7 | // Удаление всех записей из Question (если есть)
8 | if (prisma.question) {
9 | await prisma.question.deleteMany();
10 | console.log('Все записи из Question удалены');
11 | }
12 |
13 | // Удаление всех записей из Quiz
14 | await prisma.quiz.deleteMany();
15 | console.log('Все записи из Quiz удалены');
16 |
17 | // Удаление всех записей из Subtopic
18 | await prisma.subtopic.deleteMany();
19 | console.log('Все записи из Subtopic удалены');
20 |
21 | // Удаление всех записей из Topic
22 | await prisma.topic.deleteMany();
23 | console.log('Все записи из Topic удалены');
24 |
25 | // Удаление всех записей из Course
26 | await prisma.course.deleteMany();
27 | console.log('Все записи из Course удалены');
28 |
29 | // Удаление всех записей из User
30 | await prisma.user.deleteMany();
31 | console.log('Все записи из User удалены');
32 |
33 | console.log('База данных очищена успешно');
34 | } catch (error) {
35 | console.error('Ошибка при очистке базы данных:', error);
36 | } finally {
37 | await prisma.$disconnect();
38 | }
39 | }
40 |
41 | clearDatabase();
42 |
--------------------------------------------------------------------------------
/src/app/api/generate-quiz/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { PrismaClient } from '@prisma/client';
3 | import { generateQuiz } from '@/lib/gemini';
4 |
5 | const prisma = new PrismaClient();
6 |
7 | export async function POST(request) {
8 | try {
9 | const { topicId } = await request.json();
10 |
11 | const topic = await prisma.topic.findUnique({
12 | where: { id: topicId },
13 | include: { course: true }
14 | });
15 |
16 | if (!topic) {
17 | return NextResponse.json({ error: 'Topic not found' }, { status: 404 });
18 | }
19 |
20 | const quizQuestions = await generateQuiz(topic.course.title, topic.title, topic.content);
21 |
22 | const quiz = await prisma.quiz.create({
23 | data: {
24 | title: `Quiz for ${topic.title}`,
25 | questions: JSON.stringify(quizQuestions),
26 | topicId: topic.id
27 | }
28 | });
29 |
30 | // Parse the questions before sending in the response
31 | const parsedQuiz = {
32 | ...quiz,
33 | questions: JSON.parse(quiz.questions)
34 | };
35 |
36 | return NextResponse.json(parsedQuiz);
37 | } catch (error) {
38 | console.error('Error generating quiz:', error);
39 | return NextResponse.json({ error: 'Failed to generate quiz' }, { status: 500 });
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/ui/radio-group.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
5 | import { Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const RadioGroup = React.forwardRef(({ className, ...props }, ref) => {
10 | return ();
11 | })
12 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
13 |
14 | const RadioGroupItem = React.forwardRef(({ className, ...props }, ref) => {
15 | return (
16 | (
23 |
24 |
25 |
26 | )
27 | );
28 | })
29 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
30 |
31 | export { RadioGroup, RadioGroupItem }
32 |
--------------------------------------------------------------------------------
/src/components/CourseView.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { Button } from '@/components/ui/button';
3 | import { Card } from '@/components/ui/card';
4 |
5 | export function CourseView({ courseId }) {
6 | const [course, setCourse] = useState(null);
7 | const [currentSubtopic, setCurrentSubtopic] = useState(0);
8 |
9 | useEffect(() => {
10 | const fetchCourse = async () => {
11 | const response = await fetch(`/api/courses/${courseId}`);
12 | const data = await response.json();
13 | setCourse(data);
14 | };
15 | fetchCourse();
16 | }, [courseId]);
17 |
18 | if (!course) return Загрузка...
;
19 |
20 | const handleNextSubtopic = () => {
21 | if (currentSubtopic < course.subtopics.length - 1) {
22 | setCurrentSubtopic(currentSubtopic + 1);
23 | }
24 | };
25 |
26 | return (
27 |
28 |
{course.title}
29 |
30 | {course.subtopics[currentSubtopic].title}
31 | {course.subtopics[currentSubtopic].content}
32 |
33 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/Navigation.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Link from 'next/link';
4 | import { usePathname } from 'next/navigation';
5 | import { Home, BookOpen, Settings } from 'lucide-react';
6 |
7 | const navItems = [
8 | { href: '/', icon: Home, label: 'Главная' },
9 | { href: '/leaderboard', icon: BookOpen, label: 'Таблица лидеров' },
10 | { href: '/settings', icon: Settings, label: 'Настройки' },
11 | ];
12 |
13 | export function Navigation() {
14 | const pathname = usePathname();
15 |
16 | return (
17 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/app/api/register/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { PrismaClient } from '@prisma/client';
3 | import bcrypt from 'bcryptjs';
4 | import jwt from 'jsonwebtoken';
5 |
6 | const prisma = new PrismaClient();
7 |
8 | export async function POST(request) {
9 | try {
10 | const { username, password } = await request.json();
11 |
12 | // Check if user already exists
13 | const existingUser = await prisma.user.findUnique({ where: { username } });
14 |
15 | if (existingUser) {
16 | return NextResponse.json({ error: 'User with this username already exists' }, { status: 400 });
17 | }
18 |
19 | // Hash password
20 | const hashedPassword = await bcrypt.hash(password, 10);
21 |
22 | // Create new user
23 | const newUser = await prisma.user.create({
24 | data: {
25 | username,
26 | password: hashedPassword,
27 | },
28 | });
29 |
30 | // Create JWT token
31 | const token = jwt.sign(
32 | { userId: newUser.id, username: newUser.username },
33 | process.env.JWT_SECRET,
34 | { expiresIn: '1d' }
35 | );
36 |
37 | // Return token and user info
38 | return NextResponse.json({
39 | token,
40 | user: {
41 | id: newUser.id,
42 | username: newUser.username,
43 | }
44 | });
45 |
46 | } catch (error) {
47 | console.error('Registration error:', error);
48 | return NextResponse.json({ error: 'Server error' }, { status: 500 });
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/app/page.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useEffect } from 'react';
4 | import { Onboarding } from '@/components/Onboarding';
5 | import { CreateCourse } from '@/components/CreateCourse';
6 | import { MyCourses } from '@/components/MyCourses';
7 | import { InterestingCourses } from '@/components/InterestingCourses';
8 |
9 | export default function Home() {
10 | const [isAuthenticated, setIsAuthenticated] = useState(false);
11 | const [username, setUsername] = useState('');
12 | const [userId, setUserId] = useState(null);
13 |
14 | useEffect(() => {
15 | const storedUsername = localStorage.getItem('username');
16 | const storedUserId = localStorage.getItem('userId');
17 | if (storedUsername && storedUserId) {
18 | setIsAuthenticated(true);
19 | setUsername(storedUsername);
20 | setUserId(storedUserId);
21 | }
22 | }, []);
23 |
24 | const handleAuthComplete = (username, id) => {
25 | setIsAuthenticated(true);
26 | setUsername(username);
27 | setUserId(id);
28 | };
29 |
30 | if (!isAuthenticated) {
31 | return ;
32 | }
33 |
34 | return (
35 |
36 |
Привет, {username}!
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "lms",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "seed": "node scripts/seed.js"
11 | },
12 | "dependencies": {
13 | "@faker-js/faker": "^9.0.3",
14 | "@google/generative-ai": "^0.21.0",
15 | "@prisma/client": "^5.10.0",
16 | "@radix-ui/react-accordion": "^1.2.1",
17 | "@radix-ui/react-dialog": "^1.1.2",
18 | "@radix-ui/react-label": "^2.1.0",
19 | "@radix-ui/react-radio-group": "^1.2.1",
20 | "@radix-ui/react-select": "^2.1.2",
21 | "@radix-ui/react-slot": "^1.1.0",
22 | "@radix-ui/react-switch": "^1.1.1",
23 | "@radix-ui/react-toast": "^1.1.5",
24 | "bcrypt": "^5.1.1",
25 | "bcryptjs": "^2.4.3",
26 | "class-variance-authority": "^0.7.0",
27 | "clsx": "^2.1.1",
28 | "jsonwebtoken": "^9.0.2",
29 | "lodash": "^4.17.21",
30 | "lucide-react": "^0.344.0",
31 | "next": "14.2.15",
32 | "next-auth": "^4.24.6",
33 | "next-themes": "^0.3.0",
34 | "react": "^18",
35 | "react-dom": "^18",
36 | "react-hot-toast": "^2.4.1",
37 | "react-markdown": "^9.0.1",
38 | "remark-gfm": "^4.0.0",
39 | "tailwind-merge": "^2.5.4",
40 | "tailwindcss-animate": "^1.0.7"
41 | },
42 | "devDependencies": {
43 | "autoprefixer": "^10.0.1",
44 | "eslint": "^8",
45 | "eslint-config-next": "14.2.15",
46 | "postcss": "^8",
47 | "prisma": "^5.10.0",
48 | "shadcn-ui": "^0.8.0",
49 | "tailwindcss": "^3.4.1"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/components/ui/card.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef(({ className, ...props }, ref) => (
6 |
10 | ))
11 | Card.displayName = "Card"
12 |
13 | const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
14 |
18 | ))
19 | CardHeader.displayName = "CardHeader"
20 |
21 | const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
22 |
26 | ))
27 | CardTitle.displayName = "CardTitle"
28 |
29 | const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
30 |
34 | ))
35 | CardDescription.displayName = "CardDescription"
36 |
37 | const CardContent = React.forwardRef(({ className, ...props }, ref) => (
38 |
39 | ))
40 | CardContent.displayName = "CardContent"
41 |
42 | const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
43 |
47 | ))
48 | CardFooter.displayName = "CardFooter"
49 |
50 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
51 |
--------------------------------------------------------------------------------
/src/components/CourseTopicsModal.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
3 | import { Button } from "@/components/ui/button"
4 | import { Loader2 } from 'lucide-react';
5 |
6 | export function CourseTopicsModal({ isOpen, onClose, topics, onConfirm, onRegenerate }) {
7 | const [isRegenerating, setIsRegenerating] = useState(false);
8 |
9 | const handleRegenerate = async () => {
10 | setIsRegenerating(true);
11 | await onRegenerate();
12 | setIsRegenerating(false);
13 | };
14 |
15 | return (
16 |
44 | );
45 | }
46 |
47 |
--------------------------------------------------------------------------------
/src/app/api/courses/[id]/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { PrismaClient } from '@prisma/client';
3 |
4 | const prisma = new PrismaClient();
5 |
6 | export async function GET(request, { params }) {
7 | console.log('API route hit, course id:', params.id);
8 | try {
9 | const course = await prisma.course.findUnique({
10 | where: { id: params.id },
11 | include: {
12 | topics: {
13 | include: {
14 | quiz: {
15 | select: {
16 | id: true // We only need to know if the quiz exists
17 | }
18 | }
19 | }
20 | },
21 | author: {
22 | select: {
23 | id: true,
24 | username: true,
25 | },
26 | },
27 | participants: {
28 | select: {
29 | id: true,
30 | username: true,
31 | },
32 | },
33 | },
34 | });
35 |
36 | if (!course) {
37 | console.log('Course not found');
38 | return NextResponse.json({ error: 'Course not found' }, { status: 404 });
39 | }
40 |
41 | console.log('Course found:', course);
42 | return NextResponse.json(course);
43 | } catch (error) {
44 | console.error('Error fetching course:', error);
45 | return NextResponse.json({ error: 'Failed to fetch course' }, { status: 500 });
46 | } finally {
47 | await prisma.$disconnect();
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/ui/accordion.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AccordionPrimitive from "@radix-ui/react-accordion"
5 | import { ChevronDown } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Accordion = AccordionPrimitive.Root
10 |
11 | const AccordionItem = React.forwardRef(({ className, ...props }, ref) => (
12 |
13 | ))
14 | AccordionItem.displayName = "AccordionItem"
15 |
16 | const AccordionTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
17 |
18 | svg]:rotate-180",
22 | className
23 | )}
24 | {...props}>
25 | {children}
26 |
27 |
28 |
29 | ))
30 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
31 |
32 | const AccordionContent = React.forwardRef(({ className, children, ...props }, ref) => (
33 |
37 | {children}
38 |
39 | ))
40 |
41 | AccordionContent.displayName = AccordionPrimitive.Content.displayName
42 |
43 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
44 |
--------------------------------------------------------------------------------
/src/components/CourseCard.jsx:
--------------------------------------------------------------------------------
1 | import { Card } from "@/components/ui/card"
2 | import { Badge } from "@/components/ui/badge"
3 | import { getContrastColor } from "@/lib/utils"
4 |
5 | const difficultyColors = {
6 | easy: "bg-green-500",
7 | medium: "bg-yellow-500",
8 | hard: "bg-red-500"
9 | };
10 |
11 | const difficultyLabels = {
12 | easy: "Легкий",
13 | medium: "Средний",
14 | hard: "Сложный"
15 | };
16 |
17 | export function CourseCard({ course, onClick }) {
18 | const bgColor = `hsl(${Math.random() * 360}, 70%, 80%)`;
19 | const textColor = getContrastColor(bgColor);
20 | const words = course.title.split(' ');
21 | const displayText = words.slice(0, 3).join(' ');
22 |
23 | return (
24 | onClick(course)}
27 | >
28 |
32 | {displayText}
33 | {course.category}
34 |
35 |
36 |
{course.title}
37 |
38 |
39 |
40 | {difficultyLabels[course.level]}
41 |
42 |
{course.participants} участников
43 |
44 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/src/lib/backgroundService.js:
--------------------------------------------------------------------------------
1 | import { generateTopicContent } from './gemini';
2 | import { PrismaClient } from '@prisma/client';
3 | import debounce from 'lodash/debounce';
4 |
5 | const prisma = new PrismaClient();
6 | const pendingTasks = new Map();
7 | const processingDelay = 5000; // 5 seconds delay between processing tasks
8 |
9 | export function addBackgroundTask(courseId, topicId) {
10 | if (!pendingTasks.has(courseId)) {
11 | pendingTasks.set(courseId, new Set());
12 | }
13 | pendingTasks.get(courseId).add(topicId);
14 | debouncedProcessNextTask();
15 | }
16 |
17 | async function processNextTask() {
18 | for (const [courseId, topics] of pendingTasks.entries()) {
19 | const topicId = topics.values().next().value;
20 | if (topicId) {
21 | try {
22 | const response = await fetch('/api/background-tasks', {
23 | method: 'POST',
24 | headers: {
25 | 'Content-Type': 'application/json',
26 | },
27 | body: JSON.stringify({ courseId, topicId }),
28 | });
29 |
30 | if (!response.ok) {
31 | throw new Error('Failed to process background task');
32 | }
33 |
34 | topics.delete(topicId);
35 | if (topics.size === 0) {
36 | pendingTasks.delete(courseId);
37 | }
38 | } catch (error) {
39 | console.error('Error processing background task:', error);
40 | }
41 | }
42 | }
43 | if (pendingTasks.size > 0) {
44 | debouncedProcessNextTask();
45 | }
46 | }
47 |
48 | const debouncedProcessNextTask = debounce(processNextTask, processingDelay);
49 |
50 | async function updateTopicContent(topicId, content) {
51 | await prisma.topic.update({
52 | where: { id: topicId },
53 | data: { content, status: 'COMPLETED' },
54 | });
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/ui/button.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
37 | const Comp = asChild ? Slot : "button"
38 | return (
39 | ()
43 | );
44 | })
45 | Button.displayName = "Button"
46 |
47 | export { Button, buttonVariants }
48 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | generator client {
5 | provider = "prisma-client-js"
6 | }
7 |
8 | datasource db {
9 | provider = "postgresql"
10 | url = env("POSTGRES_PRISMA_URL")
11 | directUrl = env("POSTGRES_URL_NON_POOLING")
12 | }
13 |
14 | model User {
15 | id String @id @default(cuid())
16 | username String @unique
17 | password String
18 | exp Int @default(0)
19 | streakDays Int @default(0)
20 | createdAt DateTime @default(now())
21 | updatedAt DateTime @updatedAt
22 | authoredCourses Course[] @relation("AuthoredCourses")
23 | enrolledCourses Course[] @relation("EnrolledCourses")
24 | }
25 |
26 | model Course {
27 | id String @id @default(cuid())
28 | title String
29 | description String
30 | level String
31 | category String
32 | duration String
33 | studentCount Int @default(0)
34 | createdAt DateTime @default(now())
35 | updatedAt DateTime @updatedAt
36 | author User @relation("AuthoredCourses", fields: [authorId], references: [id])
37 | authorId String
38 | participants User[] @relation("EnrolledCourses")
39 | topics Topic[]
40 | }
41 |
42 | model Topic {
43 | id String @id @default(cuid())
44 | title String
45 | content String?
46 | order Int
47 | status String @default("PENDING")
48 | courseId String
49 | course Course @relation(fields: [courseId], references: [id])
50 | quiz Quiz?
51 | }
52 |
53 | model Quiz {
54 | id String @id @default(cuid())
55 | title String
56 | questions String
57 | topicId String @unique
58 | topic Topic @relation(fields: [topicId], references: [id])
59 | createdAt DateTime @default(now())
60 | updatedAt DateTime @default(now())
61 | }
62 |
--------------------------------------------------------------------------------
/src/components/Quiz.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Button } from "@/components/ui/button"
3 |
4 | const Quiz = ({ quiz, onComplete }) => {
5 | const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
6 | const [answers, setAnswers] = useState({});
7 |
8 | if (!quiz || !quiz.questions || quiz.questions.length === 0) {
9 | return No quiz questions available.
;
10 | }
11 |
12 | const currentQuestion = quiz.questions[currentQuestionIndex];
13 |
14 | const handleAnswer = (answer) => {
15 | setAnswers(prev => ({ ...prev, [currentQuestionIndex]: answer }));
16 | if (currentQuestionIndex < quiz.questions.length - 1) {
17 | setCurrentQuestionIndex(currentQuestionIndex + 1);
18 | } else {
19 | // Quiz completed
20 | const score = calculateScore();
21 | onComplete(score, quiz.topicId);
22 | }
23 | };
24 |
25 | const calculateScore = () => {
26 | let score = 0;
27 | quiz.questions.forEach((question, index) => {
28 | if (answers[index] === answers[question.correctAnswer]) {
29 | score++;
30 | }
31 | });
32 | return score;
33 | };
34 |
35 | return (
36 |
37 |
{quiz.title}
38 |
Question {currentQuestionIndex + 1} of {quiz.questions.length}
39 |
{currentQuestion.question}
40 | {currentQuestion.options.map((option, index) => (
41 |
48 | ))}
49 |
50 | );
51 | };
52 |
53 | export default Quiz;
54 |
--------------------------------------------------------------------------------
/src/app/api/enroll/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { PrismaClient } from '@prisma/client';
3 |
4 | const prisma = new PrismaClient();
5 |
6 | export async function POST(request) {
7 | try {
8 | const { courseId, userId } = await request.json();
9 |
10 | if (!userId) {
11 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
12 | }
13 |
14 | // Проверка существования пользователя и курса
15 | const user = await prisma.user.findUnique({ where: { id: userId } });
16 | const course = await prisma.course.findUnique({ where: { id: courseId } });
17 |
18 | if (!user || !course) {
19 | return NextResponse.json({ error: 'User or course not found' }, { status: 404 });
20 | }
21 |
22 | // Проверка на дублирование записи
23 | const existingEnrollment = await prisma.course.findFirst({
24 | where: {
25 | id: courseId,
26 | participants: {
27 | some: {
28 | id: userId
29 | }
30 | }
31 | }
32 | });
33 |
34 | if (existingEnrollment) {
35 | return NextResponse.json({ error: 'Already enrolled in this course' }, { status: 400 });
36 | }
37 |
38 | // Добавление пользователя к курсу и увеличение studentCount
39 | const updatedCourse = await prisma.course.update({
40 | where: { id: courseId },
41 | data: {
42 | participants: {
43 | connect: { id: userId }
44 | },
45 | studentCount: {
46 | increment: 1
47 | }
48 | },
49 | include: {
50 | participants: true,
51 | author: {
52 | select: {
53 | id: true,
54 | username: true,
55 | },
56 | },
57 | }
58 | });
59 |
60 | return NextResponse.json({ success: true, course: updatedCourse });
61 | } catch (error) {
62 | console.error('Error enrolling in course:', error);
63 | return NextResponse.json({ error: 'Failed to enroll in course' }, { status: 500 });
64 | } finally {
65 | await prisma.$disconnect();
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 222.2 84% 4.9%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 222.2 84% 4.9%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 222.2 84% 4.9%;
13 | --primary: 222.2 47.4% 11.2%;
14 | --primary-foreground: 210 40% 98%;
15 | --secondary: 210 40% 96.1%;
16 | --secondary-foreground: 222.2 47.4% 11.2%;
17 | --muted: 210 40% 96.1%;
18 | --muted-foreground: 215.4 16.3% 46.9%;
19 | --accent: 210 40% 96.1%;
20 | --accent-foreground: 222.2 47.4% 11.2%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 210 40% 98%;
23 | --border: 214.3 31.8% 91.4%;
24 | --input: 214.3 31.8% 91.4%;
25 | --ring: 222.2 84% 4.9%;
26 | --radius: 0.5rem;
27 | --chart-1: 12 76% 61%;
28 | --chart-2: 173 58% 39%;
29 | --chart-3: 197 37% 24%;
30 | --chart-4: 43 74% 66%;
31 | --chart-5: 27 87% 67%;
32 | }
33 |
34 | .dark {
35 | --background: 222.2 84% 4.9%;
36 | --foreground: 210 40% 98%;
37 | --card: 222.2 84% 4.9%;
38 | --card-foreground: 210 40% 98%;
39 | --popover: 222.2 84% 4.9%;
40 | --popover-foreground: 210 40% 98%;
41 | --primary: 210 40% 98%;
42 | --primary-foreground: 222.2 47.4% 11.2%;
43 | --secondary: 217.2 32.6% 17.5%;
44 | --secondary-foreground: 210 40% 98%;
45 | --muted: 217.2 32.6% 17.5%;
46 | --muted-foreground: 215 20.2% 65.1%;
47 | --accent: 217.2 32.6% 17.5%;
48 | --accent-foreground: 210 40% 98%;
49 | --destructive: 0 62.8% 30.6%;
50 | --destructive-foreground: 210 40% 98%;
51 | --border: 217.2 32.6% 17.5%;
52 | --input: 217.2 32.6% 17.5%;
53 | --ring: 212.7 26.8% 83.9%;
54 | --chart-1: 220 70% 50%;
55 | --chart-2: 160 60% 45%;
56 | --chart-3: 30 80% 55%;
57 | --chart-4: 280 65% 60%;
58 | --chart-5: 340 75% 55%;
59 | }
60 | }
61 |
62 | @layer base {
63 | * {
64 | @apply border-border;
65 | }
66 | body {
67 | @apply bg-background text-foreground;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/app/api/courses/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { PrismaClient } from '@prisma/client';
3 | import { auth } from '@/middleware/auth';
4 |
5 | const prisma = new PrismaClient();
6 |
7 | export async function GET() {
8 | try {
9 | const recentCourses = await prisma.course.findMany({
10 | take: 18, // Limit to 6 courses, adjust as needed
11 | orderBy: {
12 | createdAt: 'desc' // Order by creation date, most recent first
13 | },
14 | include: {
15 | author: {
16 | select: {
17 | username: true
18 | }
19 | },
20 | _count: {
21 | select: { participants: true }
22 | }
23 | }
24 | });
25 |
26 | return NextResponse.json(recentCourses);
27 | } catch (error) {
28 | console.error('Error fetching recent courses:', error);
29 | return NextResponse.json({ error: 'Failed to fetch recent courses' }, { status: 500 });
30 | } finally {
31 | await prisma.$disconnect();
32 | }
33 | }
34 |
35 | export async function POST(request) {
36 | try {
37 | const courseData = await request.json();
38 | console.log('Received course data:', courseData);
39 |
40 | // Проверяем наличие обязательных полей
41 | const requiredFields = ['title', 'level', 'description', 'category', 'duration', 'author', 'topics'];
42 | for (const field of requiredFields) {
43 | if (!courseData[field]) {
44 | return NextResponse.json({ error: `Missing required field: ${field}` }, { status: 400 });
45 | }
46 | }
47 |
48 | // Удаляем лишние поля, которые не должны быть переданы в Prisma
49 | const { updatedAt, createdAt, ...cleanedCourseData } = courseData;
50 |
51 | console.log('Cleaned course data:', cleanedCourseData);
52 |
53 | const course = await prisma.course.create({
54 | data: cleanedCourseData,
55 | include: {
56 | topics: true,
57 | author: {
58 | select: {
59 | id: true,
60 | username: true
61 | }
62 | }
63 | }
64 | });
65 |
66 | console.log('Created course:', course);
67 |
68 | return NextResponse.json(course);
69 | } catch (error) {
70 | console.error('Error creating course:', error);
71 | return NextResponse.json({ error: 'Failed to create course: ' + error.message }, { status: 500 });
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/prisma/migrations/20241019142330_quiz/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `createdAt` on the `Quiz` table. All the data in the column will be lost.
5 | - You are about to drop the column `subtopicId` on the `Quiz` table. All the data in the column will be lost.
6 | - You are about to drop the column `title` on the `Quiz` table. All the data in the column will be lost.
7 | - You are about to drop the column `updatedAt` on the `Quiz` table. All the data in the column will be lost.
8 | - You are about to drop the column `createdAt` on the `Topic` table. All the data in the column will be lost.
9 | - You are about to drop the column `updatedAt` on the `Topic` table. All the data in the column will be lost.
10 | - A unique constraint covering the columns `[topicId]` on the table `Quiz` will be added. If there are existing duplicate values, this will fail.
11 | - Added the required column `questions` to the `Quiz` table without a default value. This is not possible if the table is not empty.
12 | - Added the required column `topicId` to the `Quiz` table without a default value. This is not possible if the table is not empty.
13 | - Added the required column `order` to the `Topic` table without a default value. This is not possible if the table is not empty.
14 |
15 | */
16 | -- DropForeignKey
17 | ALTER TABLE "Quiz" DROP CONSTRAINT "Quiz_subtopicId_fkey";
18 |
19 | -- DropIndex
20 | DROP INDEX "Quiz_subtopicId_key";
21 |
22 | -- AlterTable
23 | ALTER TABLE "Quiz" DROP COLUMN "createdAt",
24 | DROP COLUMN "subtopicId",
25 | DROP COLUMN "title",
26 | DROP COLUMN "updatedAt",
27 | ADD COLUMN "questions" JSONB NOT NULL,
28 | ADD COLUMN "topicId" TEXT NOT NULL;
29 |
30 | -- AlterTable
31 | ALTER TABLE "Subtopic" ADD COLUMN "quizId" TEXT;
32 |
33 | -- AlterTable
34 | ALTER TABLE "Topic" DROP COLUMN "createdAt",
35 | DROP COLUMN "updatedAt",
36 | ADD COLUMN "order" INTEGER NOT NULL,
37 | ALTER COLUMN "content" DROP NOT NULL,
38 | ALTER COLUMN "status" SET DEFAULT 'PENDING';
39 |
40 | -- CreateIndex
41 | CREATE UNIQUE INDEX "Quiz_topicId_key" ON "Quiz"("topicId");
42 |
43 | -- AddForeignKey
44 | ALTER TABLE "Subtopic" ADD CONSTRAINT "Subtopic_quizId_fkey" FOREIGN KEY ("quizId") REFERENCES "Quiz"("id") ON DELETE SET NULL ON UPDATE CASCADE;
45 |
46 | -- AddForeignKey
47 | ALTER TABLE "Quiz" ADD CONSTRAINT "Quiz_topicId_fkey" FOREIGN KEY ("topicId") REFERENCES "Topic"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
48 |
--------------------------------------------------------------------------------
/src/components/Leaderboard.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useEffect } from 'react';
4 | import Link from 'next/link';
5 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
6 | import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
7 |
8 | export function Leaderboard() {
9 | const [leaders, setLeaders] = useState([]);
10 | const [isLoading, setIsLoading] = useState(true);
11 | const [error, setError] = useState(null);
12 |
13 | useEffect(() => {
14 | const fetchLeaderboard = async () => {
15 | try {
16 | setIsLoading(true);
17 | const response = await fetch('/api/leaderboard');
18 | if (!response.ok) {
19 | throw new Error('Failed to fetch leaderboard data');
20 | }
21 | const data = await response.json();
22 | setLeaders(data);
23 | } catch (err) {
24 | setError(err.message);
25 | } finally {
26 | setIsLoading(false);
27 | }
28 | };
29 |
30 | fetchLeaderboard();
31 | }, []);
32 |
33 | if (isLoading) {
34 | return Loading...
;
35 | }
36 |
37 | if (error) {
38 | return Error: {error}
;
39 | }
40 |
41 | return (
42 |
43 |
44 | Таблица лидеров
45 |
46 |
47 |
48 |
49 |
50 | Место
51 | Пользователь
52 | EXP
53 |
54 |
55 |
56 | {leaders.map((user, index) => (
57 |
58 | {index + 1}
59 |
60 |
61 | {user.username}
62 |
63 |
64 | {user.exp}
65 |
66 | ))}
67 |
68 |
69 |
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | './pages/**/*.{js,jsx}',
6 | './components/**/*.{js,jsx}',
7 | './app/**/*.{js,jsx}',
8 | './src/**/*.{js,jsx}',
9 | ],
10 | prefix: "",
11 | theme: {
12 | container: {
13 | center: true,
14 | padding: "2rem",
15 | screens: {
16 | "2xl": "1400px",
17 | },
18 | },
19 | extend: {
20 | colors: {
21 | border: "hsl(var(--border))",
22 | input: "hsl(var(--input))",
23 | ring: "hsl(var(--ring))",
24 | background: "hsl(var(--background))",
25 | foreground: "hsl(var(--foreground))",
26 | primary: {
27 | DEFAULT: "hsl(var(--primary))",
28 | foreground: "hsl(var(--primary-foreground))",
29 | },
30 | secondary: {
31 | DEFAULT: "hsl(var(--secondary))",
32 | foreground: "hsl(var(--secondary-foreground))",
33 | },
34 | destructive: {
35 | DEFAULT: "hsl(var(--destructive))",
36 | foreground: "hsl(var(--destructive-foreground))",
37 | },
38 | muted: {
39 | DEFAULT: "hsl(var(--muted))",
40 | foreground: "hsl(var(--muted-foreground))",
41 | },
42 | accent: {
43 | DEFAULT: "hsl(var(--accent))",
44 | foreground: "hsl(var(--accent-foreground))",
45 | },
46 | popover: {
47 | DEFAULT: "hsl(var(--popover))",
48 | foreground: "hsl(var(--popover-foreground))",
49 | },
50 | card: {
51 | DEFAULT: "hsl(var(--card))",
52 | foreground: "hsl(var(--card-foreground))",
53 | },
54 | },
55 | borderRadius: {
56 | lg: "var(--radius)",
57 | md: "calc(var(--radius) - 2px)",
58 | sm: "calc(var(--radius) - 4px)",
59 | },
60 | keyframes: {
61 | "accordion-down": {
62 | from: { height: "0" },
63 | to: { height: "var(--radix-accordion-content-height)" },
64 | },
65 | "accordion-up": {
66 | from: { height: "var(--radix-accordion-content-height)" },
67 | to: { height: "0" },
68 | },
69 | },
70 | animation: {
71 | "accordion-down": "accordion-down 0.2s ease-out",
72 | "accordion-up": "accordion-up 0.2s ease-out",
73 | },
74 | },
75 | },
76 | plugins: [require("tailwindcss-animate")],
77 | }
--------------------------------------------------------------------------------
/src/components/ui/table.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Table = React.forwardRef(({ className, ...props }, ref) => (
6 |
12 | ))
13 | Table.displayName = "Table"
14 |
15 | const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
16 |
17 | ))
18 | TableHeader.displayName = "TableHeader"
19 |
20 | const TableBody = React.forwardRef(({ className, ...props }, ref) => (
21 |
25 | ))
26 | TableBody.displayName = "TableBody"
27 |
28 | const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
29 | tr]:last:border-b-0", className)}
32 | {...props} />
33 | ))
34 | TableFooter.displayName = "TableFooter"
35 |
36 | const TableRow = React.forwardRef(({ className, ...props }, ref) => (
37 |
44 | ))
45 | TableRow.displayName = "TableRow"
46 |
47 | const TableHead = React.forwardRef(({ className, ...props }, ref) => (
48 | |
55 | ))
56 | TableHead.displayName = "TableHead"
57 |
58 | const TableCell = React.forwardRef(({ className, ...props }, ref) => (
59 | |
63 | ))
64 | TableCell.displayName = "TableCell"
65 |
66 | const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
67 |
71 | ))
72 | TableCaption.displayName = "TableCaption"
73 |
74 | export {
75 | Table,
76 | TableHeader,
77 | TableBody,
78 | TableFooter,
79 | TableHead,
80 | TableRow,
81 | TableCell,
82 | TableCaption,
83 | }
84 |
--------------------------------------------------------------------------------
/src/components/Onboarding.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import { Button } from '@/components/ui/button';
5 | import { Input } from '@/components/ui/input';
6 | import { Card } from '@/components/ui/card';
7 | import { OnboardingInfo } from '@/components/OnboardingInfo';
8 | export function Onboarding({ onComplete }) {
9 | const [isLogin, setIsLogin] = useState(true);
10 | const [username, setUsername] = useState('');
11 | const [password, setPassword] = useState('');
12 |
13 | const handleSubmit = async (e) => {
14 | e.preventDefault();
15 | if (username.trim() && password.trim()) {
16 | try {
17 | const response = await fetch(`/api/${isLogin ? 'login' : 'register'}`, {
18 | method: 'POST',
19 | headers: { 'Content-Type': 'application/json' },
20 | body: JSON.stringify({ username, password }),
21 | });
22 | if (response.ok) {
23 | const data = await response.json();
24 | localStorage.setItem('userId', data.user.id);
25 | localStorage.setItem('username', data.user.username);
26 | localStorage.setItem('token', data.token);
27 | onComplete(data.user.username, data.user.id);
28 | } else {
29 | throw new Error(isLogin ? 'Login failed' : 'Registration failed');
30 | }
31 | } catch (error) {
32 | console.error(error);
33 | // Here you might want to show an error message to the user
34 | }
35 | }
36 | };
37 |
38 | return (
39 |
40 |
41 | {isLogin ? 'Вход' : 'Регистрация'}
42 |
43 |
62 |
69 |
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/scripts/seed.js:
--------------------------------------------------------------------------------
1 | const { PrismaClient } = require('@prisma/client');
2 | const { faker } = require('@faker-js/faker');
3 | const bcrypt = require('bcryptjs');
4 |
5 | const prisma = new PrismaClient();
6 |
7 | async function main() {
8 | // Create test users
9 | const users = [];
10 | for (let i = 0; i < 50; i++) {
11 | const user = await prisma.user.create({
12 | data: {
13 | username: faker.internet.userName(),
14 | password: await bcrypt.hash(faker.internet.password(), 10),
15 | exp: faker.number.int({ min: 0, max: 1000 }),
16 | streakDays: faker.number.int({ min: 0, max: 30 }),
17 | },
18 | });
19 | users.push(user);
20 | }
21 |
22 | // Create courses
23 | const courseCategories = ['Programming', 'Web Development', 'Data Science', 'Mobile Development', 'DevOps'];
24 | const courseLevels = ['Beginner', 'Intermediate', 'Advanced'];
25 |
26 | for (let i = 0; i < 5; i++) {
27 | const author = faker.helpers.arrayElement(users);
28 | const course = await prisma.course.create({
29 | data: {
30 | title: faker.lorem.words(3),
31 | description: faker.lorem.sentence(),
32 | category: faker.helpers.arrayElement(courseCategories),
33 | level: faker.helpers.arrayElement(courseLevels),
34 | duration: `${faker.number.int({ min: 1, max: 12 })} weeks`,
35 | author: { connect: { id: author.id } },
36 | topics: {
37 | create: Array.from({ length: faker.number.int({ min: 3, max: 8 }) }, (_, index) => ({
38 | title: faker.lorem.words(3),
39 | content: faker.lorem.paragraphs(3),
40 | order: index + 1,
41 | status: faker.helpers.arrayElement(['PENDING', 'COMPLETED']),
42 | quiz: {
43 | create: {
44 | title: faker.lorem.words(2) + ' Quiz',
45 | questions: JSON.stringify(
46 | Array.from({ length: 5 }, () => ({
47 | question: faker.lorem.sentence() + '?',
48 | options: Array.from({ length: 4 }, () => faker.lorem.word()),
49 | correctAnswer: faker.number.int({ min: 0, max: 3 }),
50 | }))
51 | ),
52 | },
53 | },
54 | })),
55 | },
56 | participants: {
57 | connect: faker.helpers.arrayElements(
58 | users.filter(u => u.id !== author.id),
59 | { min: 0, max: 5 }
60 | ).map(u => ({ id: u.id })),
61 | },
62 | },
63 | });
64 |
65 | console.log(`Created course: ${course.title}`);
66 | }
67 |
68 | console.log('Seed data created successfully');
69 | }
70 |
71 | main()
72 | .catch((e) => {
73 | console.error(e);
74 | process.exit(1);
75 | })
76 | .finally(async () => {
77 | await prisma.$disconnect();
78 | });
79 |
--------------------------------------------------------------------------------
/src/lib/gemini.js:
--------------------------------------------------------------------------------
1 | import { GoogleGenerativeAI } from "@google/generative-ai";
2 | import { getNextApiKey } from './loadBalancer';
3 |
4 | export async function generateCourseContent(topic, level) {
5 | const apiKey = getNextApiKey();
6 | const genAI = new GoogleGenerativeAI(apiKey);
7 |
8 | const model = genAI.getGenerativeModel({ model: "gemini-pro" });
9 |
10 | const prompt = `Create a course outline for "${topic}" with difficulty level "${level}".
11 | Return only a JSON array of subtopics, with each subtopic being a string.
12 | For example: ["Introduction to ${topic}", "Basic concepts", "Advanced techniques", ...].
13 | The number of subtopics should be:
14 | - 5 for easy level
15 | - 15 for medium level
16 | - 30 for hard level`;
17 |
18 | try {
19 | const result = await model.generateContent(prompt);
20 | const response = await result.response;
21 | const text = response.text();
22 | return JSON.parse(text);
23 | } catch (error) {
24 | console.error("Error generating course content:", error);
25 | throw error;
26 | }
27 | }
28 |
29 | export async function generateTopicContent(courseTitle, topicTitle) {
30 | const apiKey = await getNextApiKey();
31 | const genAI = new GoogleGenerativeAI(apiKey);
32 |
33 | const model = genAI.getGenerativeModel({ model: "gemini-pro" });
34 |
35 | const prompt = `Create a concise study guide for the topic "${topicTitle}" within the course "${courseTitle}".
36 | Include key concepts, explanations, and examples. The content should be informative and easy to understand.`;
37 |
38 | try {
39 | const result = await model.generateContent(prompt);
40 | const response = await result.response;
41 | return response.text();
42 | } catch (error) {
43 | console.error("Error generating topic content:", error);
44 | throw error;
45 | }
46 | }
47 |
48 | export async function generateQuiz(courseTitle, topicTitle, topicContent) {
49 | const apiKey = await getNextApiKey();
50 | const genAI = new GoogleGenerativeAI(apiKey);
51 |
52 | const model = genAI.getGenerativeModel({ model: "gemini-pro" });
53 |
54 | const prompt = `Create a quiz with 5 multiple-choice questions based on the following topic content for the course "${courseTitle}", topic "${topicTitle}":
55 |
56 | ${topicContent}
57 |
58 | Format the quiz as a JSON array with the following structure for each question:
59 | {
60 | "question": "Question text",
61 | "options": ["Option A", "Option B", "Option C", "Option D"],
62 | "correctAnswer": 0 // Index of the correct answer (0-3)
63 | }
64 |
65 | Provide only the JSON array, without any additional text or formatting.`;
66 |
67 | try {
68 | const result = await model.generateContent(prompt);
69 | const response = await result.response;
70 | let jsonString = response.text();
71 |
72 | // Remove any Markdown formatting or extra text
73 | jsonString = jsonString.replace(/```json\n?|\n?```/g, '').trim();
74 |
75 | // Ensure the string starts with [ and ends with ]
76 | if (!jsonString.startsWith('[') || !jsonString.endsWith(']')) {
77 | throw new Error('Invalid JSON format');
78 | }
79 |
80 | return JSON.parse(jsonString);
81 | } catch (error) {
82 | console.error("Error generating quiz:", error);
83 | throw error;
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/components/UserProfile.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useEffect } from 'react';
4 | import Image from 'next/image';
5 |
6 | export function UserProfile({ userId }) {
7 | const [user, setUser] = useState(null);
8 | const [isLoading, setIsLoading] = useState(true);
9 | const [error, setError] = useState(null);
10 |
11 | useEffect(() => {
12 | const fetchUserProfile = async () => {
13 | try {
14 | setIsLoading(true);
15 | const response = await fetch(`/api/users/${userId}`);
16 | if (!response.ok) {
17 | throw new Error('Failed to fetch user profile');
18 | }
19 | const data = await response.json();
20 | setUser(data);
21 | } catch (err) {
22 | setError(err.message);
23 | } finally {
24 | setIsLoading(false);
25 | }
26 | };
27 |
28 | fetchUserProfile();
29 | }, [userId]);
30 |
31 | if (isLoading) {
32 | return Loading...
;
33 | }
34 |
35 | if (error) {
36 | return Error: {error}
;
37 | }
38 |
39 | if (!user) {
40 | return User not found
;
41 | }
42 |
43 | return (
44 |
45 |
46 |
47 |
48 |
49 |
56 |
57 |
58 |
{user.username}
59 |
Joined on {new Date(user.createdAt).toLocaleDateString()}
60 |
61 |
62 |
63 |
64 |
65 | -
66 | 🏆
67 |
68 | EXP: {user.exp}
69 |
70 |
71 | -
72 | 🔥
73 |
74 | Streak: {user.streakDays} days
75 |
76 |
77 | -
78 | 📚
79 |
80 | Courses Enrolled: {user.enrolledCourses.length}
81 |
82 |
83 | -
84 | 🎓
85 |
86 | Courses Created: 1
87 |
88 |
89 |
90 |
91 |
92 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/src/components/InterestingCourses.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useEffect } from 'react';
4 | import { CourseCard } from "@/components/CourseCard"
5 | import { CourseDetails } from "@/components/CourseDetails"
6 | import { useRouter } from 'next/navigation';
7 |
8 | export function InterestingCourses() {
9 | const [courses, setCourses] = useState([]);
10 | const [isLoading, setIsLoading] = useState(true);
11 | const [error, setError] = useState(null);
12 | const [selectedCourse, setSelectedCourse] = useState(null);
13 | const router = useRouter();
14 |
15 | useEffect(() => {
16 | fetchInterestingCourses();
17 | }, []);
18 |
19 | const fetchInterestingCourses = async () => {
20 | setIsLoading(true);
21 | setError(null);
22 | try {
23 | const response = await fetch('/api/courses');
24 | if (!response.ok) {
25 | throw new Error('Failed to fetch interesting courses');
26 | }
27 | const data = await response.json();
28 | setCourses(data);
29 | } catch (error) {
30 | console.error('Error fetching interesting courses:', error);
31 | setError(error.message);
32 | } finally {
33 | setIsLoading(false);
34 | }
35 | };
36 |
37 | const handleCourseClick = (course) => {
38 | setSelectedCourse(course);
39 | };
40 |
41 | const handleCloseDetails = () => {
42 | setSelectedCourse(null);
43 | };
44 |
45 | const handleEnroll = async (courseId) => {
46 | const userId = localStorage.getItem('userId');
47 | if (!userId) {
48 | router.push('/login');
49 | return;
50 | }
51 |
52 | try {
53 | const response = await fetch('/api/enroll', {
54 | method: 'POST',
55 | headers: {
56 | 'Content-Type': 'application/json',
57 | },
58 | body: JSON.stringify({ courseId, userId }),
59 | });
60 |
61 | if (!response.ok) {
62 | throw new Error('Failed to enroll in the course');
63 | }
64 |
65 | await fetchInterestingCourses();
66 | setSelectedCourse(null);
67 | router.push(`/courses/${courseId}`);
68 | } catch (error) {
69 | router.push(`/courses/${courseId}`);
70 | console.error('Error enrolling in course:', error);
71 | // Здесь можно добавить отображение ошибки пользователю
72 | }
73 | };
74 |
75 | if (isLoading) {
76 | return Loading interesting courses...
;
77 | }
78 |
79 | if (error) {
80 | return Error: {error}
;
81 | }
82 |
83 | return (
84 |
85 |
Интересные курсы
86 | {courses.length === 0 ? (
87 |
88 |
Курсы не найдены
89 |
90 | 🤷
91 |
92 |
93 | ) : (
94 |
95 | {courses.map(course => (
96 |
97 | ))}
98 |
99 | )}
100 | {selectedCourse && (
101 |
handleEnroll(selectedCourse.id)}
105 | />
106 | )}
107 |
108 | );
109 | }
110 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
18 |
25 | ))
26 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
27 |
28 | const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
29 |
30 |
31 |
38 | {children}
39 |
41 |
42 | Close
43 |
44 |
45 |
46 | ))
47 | DialogContent.displayName = DialogPrimitive.Content.displayName
48 |
49 | const DialogHeader = ({
50 | className,
51 | ...props
52 | }) => (
53 |
56 | )
57 | DialogHeader.displayName = "DialogHeader"
58 |
59 | const DialogFooter = ({
60 | className,
61 | ...props
62 | }) => (
63 |
66 | )
67 | DialogFooter.displayName = "DialogFooter"
68 |
69 | const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
70 |
74 | ))
75 | DialogTitle.displayName = DialogPrimitive.Title.displayName
76 |
77 | const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
78 |
82 | ))
83 | DialogDescription.displayName = DialogPrimitive.Description.displayName
84 |
85 | export {
86 | Dialog,
87 | DialogPortal,
88 | DialogOverlay,
89 | DialogClose,
90 | DialogTrigger,
91 | DialogContent,
92 | DialogHeader,
93 | DialogFooter,
94 | DialogTitle,
95 | DialogDescription,
96 | }
97 |
--------------------------------------------------------------------------------
/src/components/CourseDetails.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
3 | import { Badge } from "@/components/ui/badge"
4 | import { Button } from "@/components/ui/button"
5 | import { ThumbsUp, ThumbsDown, Clock, Calendar, User, Users, BookOpen } from 'lucide-react'
6 | import { getContrastColor } from "@/lib/utils"
7 |
8 | const difficultyColors = {
9 | Beginner: "bg-green-500",
10 | Intermediate: "bg-yellow-500",
11 | Advanced: "bg-red-500"
12 | };
13 |
14 | export function CourseDetails({ course, onClose, onEnroll }) {
15 | const [likeStatus, setLikeStatus] = useState(null);
16 | const [showEmoji, setShowEmoji] = useState(false);
17 |
18 | const bgColor = `hsl(${Math.random() * 360}, 70%, 80%)`;
19 | const textColor = getContrastColor(bgColor);
20 | const words = course.title.split(' ');
21 | const displayText = words.slice(0, 3).join(' ');
22 |
23 | const handleLike = (isLike) => {
24 | setLikeStatus(isLike);
25 | setShowEmoji(true);
26 | setTimeout(() => {
27 | setLikeStatus(null);
28 | setShowEmoji(false);
29 | }, 2000);
30 | };
31 |
32 | return (
33 |
97 | );
98 | }
99 |
--------------------------------------------------------------------------------
/src/components/MyCourses.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useEffect } from 'react';
4 | import { CourseCard } from "@/components/CourseCard"
5 | import { CourseDetails } from "@/components/CourseDetails"
6 | import { useRouter } from 'next/navigation';
7 | import Link from 'next/link';
8 |
9 | export function MyCourses() {
10 | const [enrolledCourses, setEnrolledCourses] = useState([]);
11 | const [createdCourses, setCreatedCourses] = useState([]);
12 | const [isLoading, setIsLoading] = useState(true);
13 | const [error, setError] = useState(null);
14 | const [selectedCourse, setSelectedCourse] = useState(null);
15 | const router = useRouter();
16 |
17 | useEffect(() => {
18 | fetchMyCourses();
19 | }, []);
20 |
21 | const fetchMyCourses = async () => {
22 | setIsLoading(true);
23 | setError(null);
24 | try {
25 | const userId = localStorage.getItem('userId');
26 | if (!userId) {
27 | throw new Error('User ID not found. Please log in again.');
28 | }
29 | const response = await fetch(`/api/users/${userId}/courses`);
30 | if (!response.ok) {
31 | const errorData = await response.json();
32 | throw new Error(errorData.error || 'Failed to fetch user courses');
33 | }
34 | const data = await response.json();
35 | setEnrolledCourses(data.enrolledCourses || []);
36 | setCreatedCourses(data.createdCourses || []);
37 | } catch (error) {
38 | console.error('Error fetching user courses:', error);
39 | setError(error.message);
40 | if (error.message.includes('User ID not found')) {
41 | router.push('/login');
42 | }
43 | } finally {
44 | setIsLoading(false);
45 | }
46 | };
47 |
48 | const handleCourseClick = (course) => {
49 | setSelectedCourse(course);
50 | };
51 |
52 | const handleCloseDetails = () => {
53 | setSelectedCourse(null);
54 | };
55 |
56 | if (isLoading) {
57 | return Loading your courses...
;
58 | }
59 |
60 | if (error) {
61 | return Error: {error}
;
62 | }
63 |
64 | return (
65 |
66 |
Мои курсы
67 | {enrolledCourses.length === 0 && createdCourses.length === 0 ? (
68 |
69 |
У вас пока нет курсов
70 |
71 | 😢
72 |
73 |
Начните обучение прямо сейчас!
74 |
75 | ) : (
76 | <>
77 | {enrolledCourses.length > 0 && (
78 |
79 |
Курсы, в которых я участвую
80 |
81 | {enrolledCourses.map(course => (
82 |
83 |
84 |
85 |
86 | ))}
87 |
88 |
89 | )}
90 | {createdCourses.length > 0 && (
91 |
92 |
Созданные мной курсы
93 |
94 | {createdCourses.map(course => (
95 |
96 |
97 |
98 | ))}
99 |
100 |
101 | )}
102 | >
103 | )}
104 | {selectedCourse && (
105 |
109 | )}
110 |
111 | );
112 | }
113 |
--------------------------------------------------------------------------------
/prisma/migrations/20241019130510_remove_email_field/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "User" (
3 | "id" TEXT NOT NULL,
4 | "username" TEXT NOT NULL,
5 | "password" TEXT NOT NULL,
6 | "exp" INTEGER NOT NULL DEFAULT 0,
7 | "streakDays" INTEGER NOT NULL DEFAULT 0,
8 | "lastActiveDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
9 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10 | "updatedAt" TIMESTAMP(3) NOT NULL,
11 |
12 | CONSTRAINT "User_pkey" PRIMARY KEY ("id")
13 | );
14 |
15 | -- CreateTable
16 | CREATE TABLE "Course" (
17 | "id" TEXT NOT NULL,
18 | "title" TEXT NOT NULL,
19 | "description" TEXT NOT NULL,
20 | "category" TEXT NOT NULL,
21 | "level" TEXT NOT NULL,
22 | "duration" TEXT NOT NULL,
23 | "authorId" TEXT NOT NULL,
24 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
25 | "updatedAt" TIMESTAMP(3) NOT NULL,
26 |
27 | CONSTRAINT "Course_pkey" PRIMARY KEY ("id")
28 | );
29 |
30 | -- CreateTable
31 | CREATE TABLE "Topic" (
32 | "id" TEXT NOT NULL,
33 | "title" TEXT NOT NULL,
34 | "content" TEXT NOT NULL,
35 | "status" TEXT NOT NULL,
36 | "courseId" TEXT NOT NULL,
37 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
38 | "updatedAt" TIMESTAMP(3) NOT NULL,
39 |
40 | CONSTRAINT "Topic_pkey" PRIMARY KEY ("id")
41 | );
42 |
43 | -- CreateTable
44 | CREATE TABLE "Subtopic" (
45 | "id" TEXT NOT NULL,
46 | "title" TEXT NOT NULL,
47 | "content" TEXT NOT NULL,
48 | "topicId" TEXT NOT NULL,
49 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
50 | "updatedAt" TIMESTAMP(3) NOT NULL,
51 |
52 | CONSTRAINT "Subtopic_pkey" PRIMARY KEY ("id")
53 | );
54 |
55 | -- CreateTable
56 | CREATE TABLE "Quiz" (
57 | "id" TEXT NOT NULL,
58 | "title" TEXT NOT NULL,
59 | "subtopicId" TEXT NOT NULL,
60 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
61 | "updatedAt" TIMESTAMP(3) NOT NULL,
62 |
63 | CONSTRAINT "Quiz_pkey" PRIMARY KEY ("id")
64 | );
65 |
66 | -- CreateTable
67 | CREATE TABLE "Question" (
68 | "id" TEXT NOT NULL,
69 | "text" TEXT NOT NULL,
70 | "options" TEXT[],
71 | "answer" INTEGER NOT NULL,
72 | "quizId" TEXT NOT NULL,
73 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
74 | "updatedAt" TIMESTAMP(3) NOT NULL,
75 |
76 | CONSTRAINT "Question_pkey" PRIMARY KEY ("id")
77 | );
78 |
79 | -- CreateTable
80 | CREATE TABLE "_EnrolledCourses" (
81 | "A" TEXT NOT NULL,
82 | "B" TEXT NOT NULL
83 | );
84 |
85 | -- CreateIndex
86 | CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
87 |
88 | -- CreateIndex
89 | CREATE UNIQUE INDEX "Quiz_subtopicId_key" ON "Quiz"("subtopicId");
90 |
91 | -- CreateIndex
92 | CREATE UNIQUE INDEX "_EnrolledCourses_AB_unique" ON "_EnrolledCourses"("A", "B");
93 |
94 | -- CreateIndex
95 | CREATE INDEX "_EnrolledCourses_B_index" ON "_EnrolledCourses"("B");
96 |
97 | -- AddForeignKey
98 | ALTER TABLE "Course" ADD CONSTRAINT "Course_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
99 |
100 | -- AddForeignKey
101 | ALTER TABLE "Topic" ADD CONSTRAINT "Topic_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
102 |
103 | -- AddForeignKey
104 | ALTER TABLE "Subtopic" ADD CONSTRAINT "Subtopic_topicId_fkey" FOREIGN KEY ("topicId") REFERENCES "Topic"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
105 |
106 | -- AddForeignKey
107 | ALTER TABLE "Quiz" ADD CONSTRAINT "Quiz_subtopicId_fkey" FOREIGN KEY ("subtopicId") REFERENCES "Subtopic"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
108 |
109 | -- AddForeignKey
110 | ALTER TABLE "Question" ADD CONSTRAINT "Question_quizId_fkey" FOREIGN KEY ("quizId") REFERENCES "Quiz"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
111 |
112 | -- AddForeignKey
113 | ALTER TABLE "_EnrolledCourses" ADD CONSTRAINT "_EnrolledCourses_A_fkey" FOREIGN KEY ("A") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;
114 |
115 | -- AddForeignKey
116 | ALTER TABLE "_EnrolledCourses" ADD CONSTRAINT "_EnrolledCourses_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
117 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🚀 Система управления обучением на базе ИИ (LMS)
2 |
3 | Добро пожаловать в будущее образования! Эта LMS использует мощь искусственного интеллекта для создания персонализированного опыта обучения, который взорвет ваш мозг. 🤯
4 |
5 | [](https://t.me/LearnovAI_bot/) кликни на баннер
6 |
7 |
8 | ## 🎥 Демонстрация в действии
9 |
10 | Посмотрите, как наша LMS работает в реальном времени! Нажмите на изображение ниже, чтобы увидеть видео-демонстрацию:
11 |
12 | [](https://www.youtube.com/watch?v=FberVdlg14g)
13 |
14 |
15 |
16 | ## 🌟 Функции, которые заставят вас сказать "Вау!"
17 |
18 | - 🧠 **Курсы, созданные ИИ**: Изучайте что угодно, адаптированное специально для вас!
19 | - 🏆 **Геймифицированное обучение**: Зарабатывайте опыт, поднимайтесь в рейтинге и чувствуйте себя супергероем обучения!
20 | - 🔥 **Система серий**: Поддерживайте свою серию обучения и наблюдайте, как растут ваши знания!
21 | - 📊 **Аналитика прогресса в реальном времени**: Визуализируйте свой путь обучения как никогда раньше!
22 |
23 | ## 🎨 Красивый интерфейс, который порадует ваши глаза
24 |
25 | Наш интерфейс настолько элегантен, что вы захотите учиться только ради того, чтобы на него смотреть! Создан с использованием:
26 |
27 | - Next.js для молниеносной производительности
28 | - Tailwind CSS для потрясающего, адаптивного дизайна
29 | - shadcn/ui для элегантных, настраиваемых компонентов
30 |
31 |
36 |
37 | ## 🧪 Технологический стек, который впечатлит ваших друзей-разработчиков
38 |
39 | - **Фронтенд**: Next.js, React, Tailwind CSS
40 | - **Бэкенд**: Next.js API Routes, Prisma ORM
41 | - **База данных**: PostgreSQL
42 | - **ИИ-магия**: Google's Gemini API
43 | - **Развертывание**: Vercel (потому что мы любим жить на грани! 😎)
44 |
45 | ## 📊 Впечатляющая статистика
46 |
47 | Вот наш амбициозный план роста после получения инвестиций:
48 | ``` mermaid
49 | gantt
50 | title План развития LMS на ближайшие 2 года
51 | dateFormat YYYY-MM-DD
52 | section Пользователи
53 | 10k пользователей :2024-07-01, 90d
54 | 50k пользователей :2024-10-01, 90d
55 | 100k пользователей :2025-01-01, 90d
56 | 500k пользователей :2025-04-01, 90d
57 | 1M пользователей :2025-07-01, 90d
58 | section Курсы
59 | 1k курсов :2024-07-01, 60d
60 | 5k курсов :2024-09-01, 60d
61 | 10k курсов :2024-11-01, 60d
62 | 25k курсов :2025-01-01, 90d
63 | 50k курсов :2025-04-01, 90d
64 | 100k курсов :2025-07-01, 90d
65 | section Функции
66 | Интеграция с ИИ :2024-07-01, 120d
67 | Мобильное приложение:2024-11-01, 90d
68 | VR-обучение :2025-02-01, 120d
69 | Корпоративные решения:2025-06-01, 90d
70 | ```
71 |
72 | ## 🚀 Быстрый старт
73 |
74 | 1. Клонируйте этот репозиторий (вы знаете, что хотите)
75 | 2. Установите зависимости: `npm install`
76 | 3. Настройте переменные окружения (не забудьте про магический соус ИИ!)
77 | 4. Запустите сервер разработки: `npm run dev`
78 | 5. Откройте [http://localhost:3000](http://localhost:3000) и приготовьтесь к взрыву мозга!
79 |
80 | ## 🧠 Как это работает
81 |
82 | Наша система генерации курсов на базе ИИ настолько крута, что заслуживает собственной диаграммы:
83 | ``` mermaid
84 | graph TD
85 | A[Ввод пользователя] -->|Тема и сложность| B(Gemini API)
86 | B --> C{Генерация курса}
87 | C -->|Подтемы| D[Создание контента]
88 | C -->|Вопросы| E[Генерация тестов]
89 | D --> F[Структура курса]
90 | E --> F
91 | F --> G[Обучение пользователя]
92 | G -->|Прогресс| H[Опыт и таблица лидеров]
93 | G -->|Завершение| I[Новые навыки разблокированы!]
94 | ```
95 |
96 | ## 🌈 Внесите свой вклад и сделайте его еще более потрясающим!
97 |
98 | Мы всегда ищем блестящие умы, чтобы сделать нашу LMS еще более невероятной. Ознакомьтесь с нашим [CONTRIBUTING.md](CONTRIBUTING.md), чтобы начать!
99 |
100 | ## 📜 Лицензия
101 |
102 | Этот проект лицензирован под - см. файл [LICENSE.md](LICENSE.md) для подробностей.
103 |
104 | ---
105 |
106 |
107 | Создано с ❤️ энтузиастами ИИ для пожизненных учеников
108 |
109 |
--------------------------------------------------------------------------------
/src/components/ui/select.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Select = SelectPrimitive.Root
10 |
11 | const SelectGroup = SelectPrimitive.Group
12 |
13 | const SelectValue = SelectPrimitive.Value
14 |
15 | const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
16 | span]:line-clamp-1",
20 | className
21 | )}
22 | {...props}>
23 | {children}
24 |
25 |
26 |
27 |
28 | ))
29 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
30 |
31 | const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
32 |
36 |
37 |
38 | ))
39 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
40 |
41 | const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
42 |
46 |
47 |
48 | ))
49 | SelectScrollDownButton.displayName =
50 | SelectPrimitive.ScrollDownButton.displayName
51 |
52 | const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
53 |
54 |
64 |
65 |
68 | {children}
69 |
70 |
71 |
72 |
73 | ))
74 | SelectContent.displayName = SelectPrimitive.Content.displayName
75 |
76 | const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
77 |
81 | ))
82 | SelectLabel.displayName = SelectPrimitive.Label.displayName
83 |
84 | const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
85 |
92 |
93 |
94 |
95 |
96 |
97 |
98 | {children}
99 |
100 | ))
101 | SelectItem.displayName = SelectPrimitive.Item.displayName
102 |
103 | const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
104 |
108 | ))
109 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
110 |
111 | export {
112 | Select,
113 | SelectGroup,
114 | SelectValue,
115 | SelectTrigger,
116 | SelectContent,
117 | SelectLabel,
118 | SelectItem,
119 | SelectSeparator,
120 | SelectScrollUpButton,
121 | SelectScrollDownButton,
122 | }
123 |
--------------------------------------------------------------------------------
/src/components/CreateCourse.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useEffect } from 'react';
4 | import { Button } from "@/components/ui/button"
5 | import { Input } from "@/components/ui/input"
6 | import {
7 | Select,
8 | SelectContent,
9 | SelectItem,
10 | SelectTrigger,
11 | SelectValue,
12 | } from "@/components/ui/select"
13 | import { Loader2 } from 'lucide-react';
14 | import { CourseTopicsModal } from './CourseTopicsModal';
15 | import { useRouter } from 'next/navigation';
16 | import { Textarea } from "@/components/ui/textarea"
17 |
18 | export function CreateCourse() {
19 | const [topic, setTopic] = useState('');
20 | const [level, setLevel] = useState('');
21 | const [category, setCategory] = useState('');
22 | const [description, setDescription] = useState('');
23 | const [isLoading, setIsLoading] = useState(false);
24 | const [generatedTopics, setGeneratedTopics] = useState([]);
25 | const [isModalOpen, setIsModalOpen] = useState(false);
26 | const router = useRouter();
27 | const [userId, setUserId] = useState(null);
28 |
29 | useEffect(() => {
30 | // Fetch the user ID from localStorage or your authentication system
31 | const storedUserId = localStorage.getItem('userId');
32 | if (storedUserId) {
33 | setUserId(storedUserId);
34 | }
35 | }, []);
36 |
37 | const generateCoursePlan = async () => {
38 | setIsLoading(true);
39 | try {
40 | const response = await fetch('/api/generate-course-plan', {
41 | method: 'POST',
42 | headers: { 'Content-Type': 'application/json' },
43 | body: JSON.stringify({ topic, level }),
44 | });
45 | if (response.ok) {
46 | const data = await response.json();
47 | setGeneratedTopics(data);
48 | setIsModalOpen(true);
49 | } else {
50 | throw new Error('Failed to generate course plan');
51 | }
52 | } catch (error) {
53 | console.error('Error generating course plan:', error);
54 | } finally {
55 | setIsLoading(false);
56 | }
57 | };
58 |
59 | const handleSubmit = (e) => {
60 | e.preventDefault();
61 | generateCoursePlan();
62 | };
63 |
64 | const handleConfirm = async () => {
65 | try {
66 | const token = localStorage.getItem('token');
67 | const userId = localStorage.getItem('userId');
68 | const duration = level === 'easy' ? '1 день' : level === 'medium' ? '1-3 дня' : '7-14 дней';
69 | const response = await fetch('/api/courses', {
70 | method: 'POST',
71 | headers: {
72 | 'Content-Type': 'application/json',
73 | 'Authorization': `Bearer ${token}`
74 | },
75 | body: JSON.stringify({
76 | title: topic,
77 | level,
78 | category,
79 | description,
80 | duration,
81 | topics: {
82 | create: generatedTopics.map((topicTitle, index) => ({
83 | title: topicTitle,
84 | content: '',
85 | status: 'PENDING',
86 | order: index + 1
87 | }))
88 | },
89 | author: { connect: { id: userId } },
90 | }),
91 | });
92 | if (response.ok) {
93 | const course = await response.json();
94 | router.push(`/courses/${course.id}`);
95 | } else {
96 | throw new Error('Failed to create course');
97 | }
98 | } catch (error) {
99 | console.error('Error creating course:', error);
100 | }
101 | };
102 |
103 | return (
104 | <>
105 |
151 | setIsModalOpen(false)}
154 | topics={generatedTopics}
155 | onConfirm={handleConfirm}
156 | onRegenerate={generateCoursePlan}
157 | />
158 | >
159 | );
160 | }
161 |
--------------------------------------------------------------------------------
/src/app/courses/[id]/page.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useEffect } from 'react';
4 | import { useParams } from 'next/navigation';
5 | import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
6 | import { Button } from "@/components/ui/button"
7 | import { Loader2, ChevronDown, CheckCircle, Circle, BookOpen } from 'lucide-react';
8 | import { addBackgroundTask } from '@/lib/backgroundService';
9 | import ErrorBoundary from '@/components/ErrorBoundary';
10 | import ReactMarkdown from 'react-markdown';
11 | import remarkGfm from 'remark-gfm';
12 | import Quiz from '@/components/Quiz';
13 | import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
14 | import toast, { Toaster } from 'react-hot-toast';
15 |
16 | export default function CoursePage() {
17 | const { id } = useParams();
18 | const [course, setCourse] = useState(null);
19 | const [loadingTopics, setLoadingTopics] = useState({});
20 | const [error, setError] = useState(null);
21 | const [quizzes, setQuizzes] = useState({});
22 | const [currentQuiz, setCurrentQuiz] = useState(null);
23 | const [isQuizModalOpen, setIsQuizModalOpen] = useState(false);
24 | const [completedQuizzes, setCompletedQuizzes] = useState({});
25 |
26 | useEffect(() => {
27 | console.log('CoursePage mounted, fetching course with id:', id);
28 | fetchCourse();
29 | const storedCompletedQuizzes = JSON.parse(localStorage.getItem('completedQuizzes') || '{}');
30 | setCompletedQuizzes(storedCompletedQuizzes);
31 | }, [id]);
32 |
33 | const fetchCourse = async () => {
34 | try {
35 | const response = await fetch(`/api/courses/${id}`);
36 | if (!response.ok) {
37 | throw new Error('Failed to fetch course');
38 | }
39 | const data = await response.json();
40 | console.log('Course data fetched:', data);
41 | // Sort topics by their order
42 | data.topics.sort((a, b) => a.order - b.order);
43 | setCourse(data);
44 | initializeTopicContent(data.topics, data.id);
45 | // Initialize quizzes state
46 | const initialQuizzes = {};
47 | data.topics.forEach(topic => {
48 | if (topic.quiz) {
49 | initialQuizzes[topic.id] = topic.quiz;
50 | }
51 | });
52 | setQuizzes(initialQuizzes);
53 | } catch (error) {
54 | console.error('Error fetching course:', error);
55 | setError(error.message);
56 | }
57 | };
58 |
59 | const initializeTopicContent = (topics, courseId) => {
60 | const newLoadingTopics = {};
61 | topics.forEach(topic => {
62 | if (topic.status === 'PENDING') {
63 | addBackgroundTask(courseId, topic.id);
64 | newLoadingTopics[topic.id] = true;
65 | }
66 | });
67 | setLoadingTopics(newLoadingTopics);
68 | };
69 |
70 | const handleStartQuiz = async (topicId) => {
71 | if (completedQuizzes[topicId]) {
72 | toast.error("You've already completed this quiz!");
73 | return;
74 | }
75 |
76 | if (quizzes[topicId]) {
77 | try {
78 | const response = await fetch(`/api/quizzes/${quizzes[topicId].id}`);
79 | if (response.ok) {
80 | const quizData = await response.json();
81 | console.log('Quiz data fetched:', quizData);
82 | setCurrentQuiz({...quizData, topicId});
83 | setIsQuizModalOpen(true);
84 | } else {
85 | throw new Error('Failed to fetch quiz data');
86 | }
87 | } catch (error) {
88 | console.error('Error fetching quiz:', error);
89 | toast.error('Failed to load quiz. Please try again.');
90 | }
91 | } else {
92 | try {
93 | const response = await fetch('/api/generate-quiz', {
94 | method: 'POST',
95 | headers: { 'Content-Type': 'application/json' },
96 | body: JSON.stringify({ topicId }),
97 | });
98 | if (response.ok) {
99 | const quiz = await response.json();
100 | setQuizzes(prev => ({ ...prev, [topicId]: quiz }));
101 | setCurrentQuiz({...quiz, topicId});
102 | setIsQuizModalOpen(true);
103 | } else {
104 | throw new Error('Failed to generate quiz');
105 | }
106 | } catch (error) {
107 | console.error('Error starting quiz:', error);
108 | toast.error('Failed to generate quiz. Please try again.');
109 | }
110 | }
111 | };
112 |
113 | const handleQuizComplete = async (score, topicId) => {
114 | const userId = localStorage.getItem('userId');
115 | if (!userId) {
116 | console.error('User ID not found in localStorage');
117 | return;
118 | }
119 | try {
120 | const expGained = score * 10; // 10 EXP per correct answer
121 | const response = await fetch('/api/update-exp', {
122 | method: 'POST',
123 | headers: { 'Content-Type': 'application/json' },
124 | body: JSON.stringify({ userId, exp: expGained }),
125 | });
126 | if (response.ok) {
127 | const data = await response.json();
128 | toast.success(`Congratulations! You earned ${expGained} EXP!`);
129 | const updatedCompletedQuizzes = {
130 | ...completedQuizzes,
131 | [course.id]: {
132 | ...completedQuizzes[course.id],
133 | [topicId]: true
134 | }
135 | };
136 | setCompletedQuizzes(updatedCompletedQuizzes);
137 | localStorage.setItem('completedQuizzes', JSON.stringify(updatedCompletedQuizzes));
138 | } else {
139 | throw new Error('Failed to update EXP');
140 | }
141 | } catch (error) {
142 | console.error('Error updating EXP:', error);
143 | toast.error('Failed to update EXP. Please try again.');
144 | }
145 | setCurrentQuiz(null);
146 | setIsQuizModalOpen(false);
147 | };
148 |
149 | if (error) {
150 | return Error: {error}
;
151 | }
152 |
153 | if (!course) {
154 | return Loading course...
;
155 | }
156 |
157 | return (
158 |
159 |
160 |
161 |
{course.title}
162 |
163 | {course.topics.map((topic) => (
164 |
165 |
166 |
167 | {topic.status === 'COMPLETED' ? (
168 |
169 | ) : (
170 |
171 | )}
172 | {topic.title}
173 |
174 | {quizzes[topic.id] && }
175 |
176 |
177 | {loadingTopics[topic.id] ? (
178 |
179 |
180 | Generating content...
181 |
182 | ) : (
183 | <>
184 |
188 | {topic.content || 'Content is not available yet.'}
189 |
190 | {topic.content && (
191 |
202 | )}
203 | >
204 | )}
205 |
206 |
207 | ))}
208 |
209 |
210 |
220 |
221 | );
222 | }
223 |
--------------------------------------------------------------------------------