project init

This commit is contained in:
2026-02-06 02:17:59 +03:00
commit b9d9b9ed17
129 changed files with 22835 additions and 0 deletions

View File

@ -0,0 +1,13 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex items-center justify-center bg-muted/30">
<div className="w-full max-w-md p-6">
{children}
</div>
</div>
);
}

View File

@ -0,0 +1,133 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Sparkles, Mail, Lock, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { useToast } from '@/components/ui/use-toast';
import { useAuth } from '@/contexts/auth-context';
export default function LoginPage() {
const router = useRouter();
const { toast } = useToast();
const { signIn } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const { error } = await signIn(email, password);
if (error) {
toast({
title: 'Ошибка входа',
description: error.message || 'Неверный email или пароль.',
variant: 'destructive',
});
return;
}
toast({
title: 'Добро пожаловать!',
description: 'Вы успешно вошли в систему.',
});
router.push('/dashboard');
} catch (error) {
toast({
title: 'Ошибка',
description: 'Произошла непредвиденная ошибка.',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
return (
<Card>
<CardHeader className="text-center">
<Link href="/" className="flex items-center justify-center gap-2 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
<Sparkles className="h-5 w-5 text-primary-foreground" />
</div>
<span className="text-xl font-bold">CourseCraft</span>
</Link>
<CardTitle>Вход в аккаунт</CardTitle>
<CardDescription>
Введите email и пароль для входа
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="you@example.com"
className="pl-10"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Пароль</Label>
<Link
href="/forgot-password"
className="text-sm text-primary hover:underline"
>
Забыли пароль?
</Link>
</div>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="••••••••"
className="pl-10"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Вход...
</>
) : (
'Войти'
)}
</Button>
<p className="text-sm text-center text-muted-foreground">
Нет аккаунта?{' '}
<Link href="/register" className="text-primary hover:underline">
Зарегистрироваться
</Link>
</p>
</CardFooter>
</form>
</Card>
);
}

View File

@ -0,0 +1,179 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Sparkles, Mail, Lock, User, Loader2, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { useToast } from '@/components/ui/use-toast';
import { useAuth } from '@/contexts/auth-context';
export default function RegisterPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
const { signUp } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const plan = searchParams.get('plan');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const { error } = await signUp(email, password, name);
if (error) {
toast({
title: 'Ошибка регистрации',
description: error.message || 'Не удалось создать аккаунт.',
variant: 'destructive',
});
return;
}
toast({
title: 'Аккаунт создан!',
description: 'Проверьте email для подтверждения (если включено) или войдите в систему.',
});
router.push('/dashboard');
} catch (error) {
toast({
title: 'Ошибка',
description: 'Произошла непредвиденная ошибка.',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
return (
<Card>
<CardHeader className="text-center">
<Link href="/" className="flex items-center justify-center gap-2 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
<Sparkles className="h-5 w-5 text-primary-foreground" />
</div>
<span className="text-xl font-bold">CourseCraft</span>
</Link>
<CardTitle>Создать аккаунт</CardTitle>
<CardDescription>
{plan ? (
<>Регистрация с планом <span className="font-medium capitalize">{plan}</span></>
) : (
'Начните создавать курсы с AI бесплатно'
)}
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Имя</Label>
<div className="relative">
<User className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="name"
type="text"
placeholder="Ваше имя"
className="pl-10"
value={name}
onChange={(e) => setName(e.target.value)}
required
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="you@example.com"
className="pl-10"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Пароль</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="Минимум 6 символов"
className="pl-10"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={6}
required
disabled={isLoading}
/>
</div>
</div>
{/* Benefits */}
<div className="rounded-lg bg-muted p-4 space-y-2">
<p className="text-sm font-medium">Что вы получите:</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-primary" />
2 бесплатных курса в месяц
</li>
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-primary" />
AI-генерация контента
</li>
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-primary" />
WYSIWYG редактор
</li>
</ul>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Создание аккаунта...
</>
) : (
'Создать аккаунт'
)}
</Button>
<p className="text-sm text-center text-muted-foreground">
Уже есть аккаунт?{' '}
<Link href="/login" className="text-primary hover:underline">
Войти
</Link>
</p>
<p className="text-xs text-center text-muted-foreground">
Регистрируясь, вы соглашаетесь с{' '}
<Link href="/terms" className="underline">
условиями использования
</Link>{' '}
и{' '}
<Link href="/privacy" className="underline">
политикой конфиденциальности
</Link>
</p>
</CardFooter>
</form>
</Card>
);
}

View File

@ -0,0 +1,139 @@
'use client';
import { Check, Zap } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import { SUBSCRIPTION_PLANS, formatPlanPrice } from '@coursecraft/shared';
const locale = 'ru';
const plans = SUBSCRIPTION_PLANS.map((plan) => ({
tier: plan.tier,
name: plan.nameRu,
priceFormatted: formatPlanPrice(plan, locale).formatted,
features: plan.featuresRu,
}));
const currentPlan = {
tier: 'FREE' as const,
coursesUsed: 1,
coursesLimit: 2,
renewalDate: null as string | null,
};
export default function BillingPage() {
const usagePercent = (currentPlan.coursesUsed / currentPlan.coursesLimit) * 100;
return (
<div className="space-y-6 max-w-4xl">
<div>
<h1 className="text-3xl font-bold">Подписка</h1>
<p className="text-muted-foreground">
Управляйте вашей подпиской и лимитами
</p>
</div>
{/* Current usage */}
<Card>
<CardHeader>
<CardTitle>Текущее использование</CardTitle>
<CardDescription>
Ваш тарифный план: {plans.find((p) => p.tier === currentPlan.tier)?.name}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Курсы в этом месяце</span>
<span className="text-sm text-muted-foreground">
{currentPlan.coursesUsed} / {currentPlan.coursesLimit}
</span>
</div>
<Progress value={usagePercent} className="h-2" />
</div>
{currentPlan.renewalDate && (
<p className="text-sm text-muted-foreground">
Следующее обновление: {currentPlan.renewalDate}
</p>
)}
</CardContent>
</Card>
{/* Plans */}
<div className="grid gap-6 md:grid-cols-3">
{plans.map((plan) => {
const isCurrent = plan.tier === currentPlan.tier;
const isUpgrade =
(currentPlan.tier === 'FREE' && plan.tier !== 'FREE') ||
(currentPlan.tier === 'PREMIUM' && plan.tier === 'PRO');
return (
<Card
key={plan.tier}
className={cn(isCurrent && 'border-primary')}
>
<CardHeader>
<CardTitle className="flex items-center justify-between">
{plan.name}
{isCurrent && (
<span className="text-xs bg-primary text-primary-foreground px-2 py-1 rounded-full">
Текущий
</span>
)}
</CardTitle>
<CardDescription>
<span className="text-3xl font-bold">{plan.priceFormatted}</span>
{plan.priceFormatted !== 'Бесплатно' && (
<span className="text-muted-foreground">/месяц</span>
)}
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm">
<Check className="h-4 w-4 text-primary shrink-0" />
{feature}
</li>
))}
</ul>
</CardContent>
<CardFooter>
{isCurrent ? (
<Button variant="outline" className="w-full" disabled>
Текущий план
</Button>
) : isUpgrade ? (
<Button className="w-full">
<Zap className="mr-2 h-4 w-4" />
Улучшить
</Button>
) : (
<Button variant="outline" className="w-full">
Выбрать
</Button>
)}
</CardFooter>
</Card>
);
})}
</div>
{currentPlan.tier !== 'FREE' && (
<Card>
<CardHeader>
<CardTitle>Управление подпиской</CardTitle>
</CardHeader>
<CardContent className="flex gap-4">
<Button variant="outline">Изменить способ оплаты</Button>
<Button variant="outline" className="text-destructive">
Отменить подписку
</Button>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -0,0 +1,218 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { ChevronLeft, ChevronRight, Save, Wand2, Eye, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { CourseEditor } from '@/components/editor/course-editor';
import { LessonContentViewer } from '@/components/editor/lesson-content-viewer';
import { LessonSidebar } from '@/components/editor/lesson-sidebar';
import { cn } from '@/lib/utils';
import { api } from '@/lib/api';
import { useAuth } from '@/contexts/auth-context';
type Lesson = { id: string; title: string };
type Chapter = { id: string; title: string; lessons: Lesson[] };
type CourseData = { id: string; title: string; chapters: Chapter[] };
const emptyDoc = { type: 'doc', content: [] };
export default function CourseEditPage() {
const params = useParams();
const { loading: authLoading } = useAuth();
const courseId = params?.id as string;
const [course, setCourse] = useState<CourseData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [activeLesson, setActiveLesson] = useState<{ chapterId: string; lessonId: string } | null>(null);
const [content, setContent] = useState<Record<string, unknown>>(emptyDoc);
const [contentLoading, setContentLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [readOnly, setReadOnly] = useState(false);
useEffect(() => {
if (!courseId || authLoading) return;
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const data = await api.getCourse(courseId);
if (!cancelled) {
setCourse(data);
const firstChapter = data.chapters?.[0];
const firstLesson = firstChapter?.lessons?.[0];
if (firstChapter && firstLesson) {
setActiveLesson({ chapterId: firstChapter.id, lessonId: firstLesson.id });
}
}
} catch (e: any) {
if (!cancelled) setError(e?.message || 'Не удалось загрузить курс');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [courseId, authLoading]);
// Load lesson content when active lesson changes
useEffect(() => {
if (!courseId || !activeLesson) {
setContent(emptyDoc);
return;
}
let cancelled = false;
setContentLoading(true);
(async () => {
try {
const lessonData = await api.getLesson(courseId, activeLesson.lessonId);
if (!cancelled && lessonData?.content) {
setContent(
typeof lessonData.content === 'object' && lessonData.content !== null
? (lessonData.content as Record<string, unknown>)
: emptyDoc
);
} else if (!cancelled) {
setContent(emptyDoc);
}
} catch {
if (!cancelled) setContent(emptyDoc);
} finally {
if (!cancelled) setContentLoading(false);
}
})();
return () => { cancelled = true; };
}, [courseId, activeLesson?.lessonId]);
const handleSelectLesson = (lessonId: string) => {
if (!course) return;
for (const ch of course.chapters) {
const lesson = ch.lessons.find((l) => l.id === lessonId);
if (lesson) {
setActiveLesson({ chapterId: ch.id, lessonId: lesson.id });
return;
}
}
};
const handleSave = async () => {
if (!courseId || !activeLesson || saving) return;
setSaving(true);
try {
await api.updateLesson(courseId, activeLesson.lessonId, { content });
} catch (e: any) {
console.error('Save failed:', e);
} finally {
setSaving(false);
}
};
if (authLoading || loading) {
return (
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
<p className="text-muted-foreground">Загрузка курса...</p>
</div>
);
}
if (error || !course) {
return (
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
<p className="text-destructive">{error || 'Курс не найден'}</p>
</div>
);
}
const flatLessons = course.chapters.flatMap((ch) =>
ch.lessons.map((l) => ({ ...l, chapterId: ch.id }))
);
const activeLessonMeta = activeLesson
? flatLessons.find((l) => l.id === activeLesson.lessonId)
: null;
return (
<div className="relative flex h-[calc(100vh-4rem)] -m-6">
<div
className={cn(
'border-r bg-muted/30 transition-all duration-300',
sidebarOpen ? 'w-72' : 'w-0'
)}
>
{sidebarOpen && (
<LessonSidebar
course={course}
activeLesson={activeLesson?.lessonId ?? ''}
onSelectLesson={handleSelectLesson}
/>
)}
</div>
<button
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 flex h-8 w-6 items-center justify-center rounded-r-md border border-l-0 bg-background shadow-sm hover:bg-muted transition-colors"
style={{ left: sidebarOpen ? '288px' : '0px' }}
onClick={() => setSidebarOpen(!sidebarOpen)}
>
{sidebarOpen ? (
<ChevronLeft className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
<div className="flex-1 flex flex-col min-h-0">
<div className="flex shrink-0 items-center justify-between border-b px-4 py-2">
<h2 className="font-medium truncate">
{activeLessonMeta?.title ?? 'Выберите урок'}
</h2>
<div className="flex items-center gap-2">
{readOnly ? (
<>
<Button variant="outline" size="sm" asChild>
<Link href={`/dashboard/courses/${courseId}`}>
<Eye className="mr-2 h-4 w-4" />
Просмотр курса
</Link>
</Button>
<Button size="sm" onClick={() => setReadOnly(false)}>
<Pencil className="mr-2 h-4 w-4" />
Редактировать
</Button>
</>
) : (
<>
<Button variant="outline" size="sm" onClick={() => setReadOnly(true)}>
<Eye className="mr-2 h-4 w-4" />
Режим просмотра
</Button>
<Button variant="outline" size="sm">
<Wand2 className="mr-2 h-4 w-4" />
AI помощник
</Button>
<Button size="sm" onClick={handleSave} disabled={saving || !activeLesson}>
<Save className="mr-2 h-4 w-4" />
{saving ? 'Сохранение...' : 'Сохранить'}
</Button>
</>
)}
</div>
</div>
<div className="flex-1 flex flex-col min-h-0 overflow-auto p-6">
<div className="w-full max-w-3xl mx-auto flex-1 min-h-0 flex flex-col">
{contentLoading ? (
<p className="text-muted-foreground">Загрузка контента...</p>
) : readOnly ? (
<LessonContentViewer content={content} className="prose-reader min-h-[400px]" />
) : (
<CourseEditor content={content} onChange={setContent} />
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,235 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { api } from '@/lib/api';
import { useAuth } from '@/contexts/auth-context';
import { LessonContentViewer } from '@/components/editor/lesson-content-viewer';
import { LessonSidebar } from '@/components/editor/lesson-sidebar';
import { cn } from '@/lib/utils';
type Lesson = { id: string; title: string; durationMinutes?: number | null; order: number };
type Chapter = { id: string; title: string; description?: string | null; order: number; lessons: Lesson[] };
type CourseData = {
id: string;
title: string;
description?: string | null;
status: string;
chapters: Chapter[];
};
export default function CoursePage() {
const params = useParams();
const router = useRouter();
const { loading: authLoading } = useAuth();
const id = params?.id as string;
const [course, setCourse] = useState<CourseData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
const [lessonContent, setLessonContent] = useState<Record<string, unknown> | null>(null);
const [lessonContentLoading, setLessonContentLoading] = useState(false);
useEffect(() => {
if (!id || authLoading) return;
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const data = await api.getCourse(id);
if (!cancelled) {
setCourse(data);
const first = data.chapters?.[0]?.lessons?.[0];
if (first) setSelectedLessonId(first.id);
}
} catch (e: any) {
if (!cancelled) setError(e?.message || 'Не удалось загрузить курс');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [id, authLoading]);
useEffect(() => {
if (!id || !selectedLessonId) {
setLessonContent(null);
return;
}
let cancelled = false;
setLessonContentLoading(true);
(async () => {
try {
const data = await api.getLesson(id, selectedLessonId);
const content = data?.content;
if (!cancelled)
setLessonContent(
typeof content === 'object' && content !== null ? (content as Record<string, unknown>) : null
);
} catch {
if (!cancelled) setLessonContent(null);
} finally {
if (!cancelled) setLessonContentLoading(false);
}
})();
return () => { cancelled = true; };
}, [id, selectedLessonId]);
const handleDelete = async () => {
if (!course?.id || deleting) return;
setDeleting(true);
try {
await api.deleteCourse(course.id);
router.push('/dashboard');
router.refresh();
} catch (e: any) {
setError(e?.message || 'Не удалось удалить курс');
} finally {
setDeleting(false);
}
};
if (authLoading || loading) {
return (
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
<p className="text-muted-foreground">Загрузка курса...</p>
</div>
);
}
if (error || !course) {
return (
<div className="flex flex-col gap-4 p-6">
<Button variant="ghost" asChild>
<Link href="/dashboard"><ArrowLeft className="mr-2 h-4 w-4" />Назад к курсам</Link>
</Button>
<p className="text-destructive">{error || 'Курс не найден'}</p>
</div>
);
}
const activeLessonTitle = selectedLessonId
? (() => {
for (const ch of course.chapters) {
const lesson = ch.lessons.find((l) => l.id === selectedLessonId);
if (lesson) return lesson.title;
}
return null;
})()
: null;
return (
<div className="flex h-[calc(100vh-4rem)] -m-6 flex-col">
{/* Top bar */}
<div className="flex shrink-0 items-center justify-between border-b bg-background px-4 py-3">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<Link href="/dashboard">
<ArrowLeft className="mr-2 h-4 w-4" />
К курсам
</Link>
</Button>
<span className="text-muted-foreground">/</span>
<span className="font-medium truncate max-w-[200px] sm:max-w-xs">{course.title}</span>
</div>
<div className="flex items-center gap-2">
<Button size="sm" asChild>
<Link href={`/dashboard/courses/${course.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Редактировать
</Link>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="icon" className="text-destructive hover:text-destructive hover:bg-destructive/10">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить курс?</AlertDialogTitle>
<AlertDialogDescription>
Курс «{course.title}» будет удалён безвозвратно.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => { e.preventDefault(); handleDelete(); }}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleting}
>
{deleting ? 'Удаление...' : 'Удалить'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="relative flex flex-1 min-h-0">
{/* Left: list of lessons (paragraphs) */}
<div
className={cn(
'border-r bg-muted/20 flex flex-col transition-[width] duration-200',
sidebarOpen ? 'w-72 shrink-0' : 'w-0 overflow-hidden'
)}
>
{sidebarOpen && (
<LessonSidebar
course={course}
activeLesson={selectedLessonId ?? ''}
onSelectLesson={setSelectedLessonId}
readOnly
/>
)}
</div>
<button
type="button"
className="absolute top-4 z-10 flex h-8 w-6 items-center justify-center rounded-r-md border border-l-0 bg-background shadow-sm hover:bg-muted transition-colors"
style={{ left: sidebarOpen ? '17rem' : 0 }}
onClick={() => setSidebarOpen(!sidebarOpen)}
>
{sidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{/* Center: lesson content (read-only) */}
<main className="flex-1 flex flex-col min-h-0 overflow-auto">
<div className="max-w-3xl mx-auto w-full px-6 py-8">
{activeLessonTitle && (
<h1 className="text-2xl font-bold mb-6 text-foreground">{activeLessonTitle}</h1>
)}
{lessonContentLoading ? (
<p className="text-muted-foreground">Загрузка...</p>
) : selectedLessonId ? (
<LessonContentViewer
content={lessonContent}
className="prose-reader min-h-[400px] [&_.ProseMirror]:min-h-[300px]"
/>
) : (
<p className="text-muted-foreground">Выберите урок в списке слева.</p>
)}
</div>
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,512 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { Send, Sparkles, Loader2, Check, ArrowRight, X, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import { api } from '@/lib/api';
import { useToast } from '@/components/ui/use-toast';
type Step = 'prompt' | 'questions' | 'generating' | 'complete' | 'error';
interface ClarifyingQuestion {
id: string;
question: string;
type: 'single_choice' | 'multiple_choice' | 'text';
options?: string[];
required: boolean;
}
export default function NewCoursePage() {
const router = useRouter();
const { toast } = useToast();
const [step, setStep] = useState<Step>('prompt');
const [prompt, setPrompt] = useState('');
const [generationId, setGenerationId] = useState<string | null>(null);
const [questions, setQuestions] = useState<ClarifyingQuestion[]>([]);
const [answers, setAnswers] = useState<Record<string, string | string[]>>({});
const [progress, setProgress] = useState(0);
const [currentStepText, setCurrentStepText] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [courseId, setCourseId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Poll for generation status
const pollStatus = useCallback(async () => {
if (!generationId) return;
try {
const status = await api.getGenerationStatus(generationId);
setProgress(status.progress);
setCurrentStepText(status.currentStep || '');
// Normalize status to uppercase for comparison
const normalizedStatus = status.status?.toUpperCase();
switch (normalizedStatus) {
case 'WAITING_FOR_ANSWERS':
if (status.questions) {
// questions can be array or object with questions array
const questionsArray = Array.isArray(status.questions)
? status.questions
: (status.questions as any)?.questions || [];
if (questionsArray.length > 0) {
setQuestions(questionsArray as ClarifyingQuestion[]);
setStep('questions');
}
}
break;
case 'COMPLETED':
setStep('complete');
if (status.course) {
setCourseId(status.course.id);
}
break;
case 'FAILED':
case 'CANCELLED':
setStep('error');
setErrorMessage(status.errorMessage || 'Генерация не удалась');
break;
default:
// Continue polling for other statuses (PENDING, ANALYZING, ASKING_QUESTIONS, RESEARCHING, GENERATING_OUTLINE, GENERATING_CONTENT)
if (step !== 'questions') {
setStep('generating');
}
}
} catch (error) {
console.error('Failed to get status:', error);
}
}, [generationId, step]);
// Start polling when we have a generation ID
useEffect(() => {
if (!generationId || step === 'complete' || step === 'error' || step === 'questions') {
return;
}
const interval = setInterval(pollStatus, 2000);
return () => clearInterval(interval);
}, [generationId, step, pollStatus]);
const handleSubmitPrompt = async () => {
if (!prompt.trim() || isSubmitting) return;
setIsSubmitting(true);
try {
const result = await api.startGeneration(prompt);
setGenerationId(result.id);
setStep('generating');
setProgress(result.progress);
// Start polling immediately
setTimeout(pollStatus, 1000);
} catch (error: any) {
toast({
title: 'Ошибка',
description: error.message || 'Не удалось начать генерацию',
variant: 'destructive',
});
} finally {
setIsSubmitting(false);
}
};
const handleAnswerQuestion = (questionId: string, answer: string) => {
setAnswers((prev) => ({ ...prev, [questionId]: answer }));
};
const handleAnswerMultiple = (questionId: string, option: string) => {
setAnswers((prev) => {
const current = prev[questionId] as string[] || [];
const updated = current.includes(option)
? current.filter((o) => o !== option)
: [...current, option];
return { ...prev, [questionId]: updated };
});
};
const handleSubmitAnswers = async () => {
if (!generationId || isSubmitting) return;
setIsSubmitting(true);
try {
await api.answerQuestions(generationId, answers);
setStep('generating');
// Resume polling
setTimeout(pollStatus, 1000);
} catch (error: any) {
toast({
title: 'Ошибка',
description: error.message || 'Не удалось отправить ответы',
variant: 'destructive',
});
} finally {
setIsSubmitting(false);
}
};
const handleCancel = async () => {
if (!generationId) return;
try {
await api.cancelGeneration(generationId);
router.push('/dashboard');
} catch (error) {
router.push('/dashboard');
}
};
const handleRetry = () => {
setStep('prompt');
setGenerationId(null);
setQuestions([]);
setAnswers({});
setProgress(0);
setCurrentStepText('');
setErrorMessage('');
setCourseId(null);
};
const allRequiredAnswered = questions
.filter((q) => q.required)
.every((q) => {
const answer = answers[q.id];
if (Array.isArray(answer)) return answer.length > 0;
return Boolean(answer);
});
return (
<div className="max-w-3xl mx-auto space-y-8">
<div className="text-center">
<h1 className="text-3xl font-bold">Создать новый курс</h1>
<p className="text-muted-foreground mt-2">
Опишите тему курса, и AI создаст его за вас
</p>
</div>
<AnimatePresence mode="wait">
{/* Step 1: Prompt */}
{step === 'prompt' && (
<motion.div
key="prompt"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<Card>
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Sparkles className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 space-y-4">
<p className="text-sm text-muted-foreground">
Привет! Я помогу вам создать курс. Просто опишите, о чём
должен быть ваш курс.
</p>
<textarea
className="w-full min-h-[120px] rounded-lg border bg-background p-4 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
placeholder="Например: Сделай курс по маркетингу для начинающих с акцентом на социальные сети..."
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
disabled={isSubmitting}
/>
<div className="flex justify-end">
<Button
onClick={handleSubmitPrompt}
disabled={!prompt.trim() || isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Отправка...
</>
) : (
<>
Продолжить
<Send className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
)}
{/* Step 2: Questions */}
{step === 'questions' && (
<motion.div
key="questions"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-4"
>
{/* User prompt */}
<Card className="bg-muted/50">
<CardContent className="p-4">
<p className="text-sm">{prompt}</p>
</CardContent>
</Card>
{/* AI questions */}
<Card>
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Sparkles className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 space-y-6">
<p className="text-sm text-muted-foreground">
Отлично! Чтобы создать идеальный курс, мне нужно уточнить
несколько деталей:
</p>
{questions.map((question, index) => (
<div key={question.id} className="space-y-3">
<p className="font-medium">
{index + 1}. {question.question}
{question.required && (
<span className="text-destructive">*</span>
)}
</p>
{question.type === 'single_choice' && question.options && (
<div className="grid gap-2">
{question.options.map((option) => (
<button
key={option}
className={cn(
'flex items-center gap-3 rounded-lg border p-3 text-left text-sm transition-colors',
answers[question.id] === option
? 'border-primary bg-primary/5'
: 'hover:bg-muted'
)}
onClick={() =>
handleAnswerQuestion(question.id, option)
}
>
<div
className={cn(
'flex h-5 w-5 items-center justify-center rounded-full border-2',
answers[question.id] === option
? 'border-primary bg-primary'
: 'border-muted-foreground'
)}
>
{answers[question.id] === option && (
<Check className="h-3 w-3 text-primary-foreground" />
)}
</div>
{option}
</button>
))}
</div>
)}
{question.type === 'multiple_choice' && question.options && (
<div className="grid gap-2">
{question.options.map((option) => {
const selected = (answers[question.id] as string[] || []).includes(option);
return (
<button
key={option}
className={cn(
'flex items-center gap-3 rounded-lg border p-3 text-left text-sm transition-colors',
selected
? 'border-primary bg-primary/5'
: 'hover:bg-muted'
)}
onClick={() =>
handleAnswerMultiple(question.id, option)
}
>
<div
className={cn(
'flex h-5 w-5 items-center justify-center rounded border-2',
selected
? 'border-primary bg-primary'
: 'border-muted-foreground'
)}
>
{selected && (
<Check className="h-3 w-3 text-primary-foreground" />
)}
</div>
{option}
</button>
);
})}
</div>
)}
{question.type === 'text' && (
<textarea
className="w-full rounded-lg border bg-background p-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
placeholder="Введите ответ..."
rows={3}
value={(answers[question.id] as string) || ''}
onChange={(e) =>
handleAnswerQuestion(question.id, e.target.value)
}
/>
)}
</div>
))}
<div className="flex justify-between pt-4">
<Button variant="ghost" onClick={handleCancel}>
<X className="mr-2 h-4 w-4" />
Отменить
</Button>
<Button
onClick={handleSubmitAnswers}
disabled={!allRequiredAnswered || isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Отправка...
</>
) : (
<>
Создать курс
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
)}
{/* Step 3: Generating */}
{step === 'generating' && (
<motion.div
key="generating"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<Card>
<CardContent className="p-8 text-center space-y-6">
<div className="flex justify-center">
<div className="relative">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/10">
<Loader2 className="h-10 w-10 text-primary animate-spin" />
</div>
</div>
</div>
<div className="space-y-2">
<h2 className="text-xl font-semibold">Генерация курса</h2>
<p className="text-muted-foreground">{currentStepText || 'Подготовка...'}</p>
</div>
<div className="max-w-md mx-auto space-y-2">
<Progress value={progress} className="h-2" />
<p className="text-sm text-muted-foreground">{progress}%</p>
</div>
<Button variant="ghost" onClick={handleCancel}>
<X className="mr-2 h-4 w-4" />
Отменить генерацию
</Button>
</CardContent>
</Card>
</motion.div>
)}
{/* Step 4: Complete */}
{step === 'complete' && (
<motion.div
key="complete"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<Card>
<CardContent className="p-8 text-center space-y-6">
<div className="flex justify-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
<Check className="h-10 w-10 text-green-600 dark:text-green-400" />
</div>
</div>
<div className="space-y-2">
<h2 className="text-xl font-semibold">Курс готов!</h2>
<p className="text-muted-foreground">
Ваш курс успешно создан. Теперь вы можете просмотреть и
отредактировать его.
</p>
</div>
<div className="flex justify-center gap-4">
<Button variant="outline" asChild>
<a href="/dashboard">К списку курсов</a>
</Button>
{courseId && (
<Button asChild>
<a href={`/dashboard/courses/${courseId}/edit`}>
Редактировать курс
<ArrowRight className="ml-2 h-4 w-4" />
</a>
</Button>
)}
</div>
</CardContent>
</Card>
</motion.div>
)}
{/* Error state */}
{step === 'error' && (
<motion.div
key="error"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<Card>
<CardContent className="p-8 text-center space-y-6">
<div className="flex justify-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-red-100 dark:bg-red-900">
<AlertCircle className="h-10 w-10 text-red-600 dark:text-red-400" />
</div>
</div>
<div className="space-y-2">
<h2 className="text-xl font-semibold">Ошибка генерации</h2>
<p className="text-muted-foreground">
{errorMessage || 'Произошла ошибка при генерации курса.'}
</p>
</div>
<div className="flex justify-center gap-4">
<Button variant="outline" asChild>
<a href="/dashboard">К списку курсов</a>
</Button>
<Button onClick={handleRetry}>
Попробовать снова
</Button>
</div>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -0,0 +1,139 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Plus, BookOpen, Clock, TrendingUp, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { CourseCard } from '@/components/dashboard/course-card';
import { api } from '@/lib/api';
import { useToast } from '@/components/ui/use-toast';
import { useAuth } from '@/contexts/auth-context';
interface Course {
id: string;
title: string;
description: string | null;
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED' | 'GENERATING';
chaptersCount: number;
lessonsCount: number;
updatedAt: string;
}
export default function DashboardPage() {
const { toast } = useToast();
const { loading: authLoading, user } = useAuth();
const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({
total: 0,
drafts: 0,
published: 0,
});
const loadCourses = async () => {
setLoading(true);
try {
const result = await api.getCourses();
setCourses(result.data);
const total = result.data.length;
const drafts = result.data.filter((c: Course) => c.status === 'DRAFT').length;
const published = result.data.filter((c: Course) => c.status === 'PUBLISHED').length;
setStats({ total, drafts, published });
} catch (error: any) {
if (error.message !== 'Unauthorized') {
toast({
title: 'Ошибка загрузки',
description: 'Не удалось загрузить курсы',
variant: 'destructive',
});
}
} finally {
setLoading(false);
}
};
useEffect(() => {
if (authLoading) return;
if (!user) {
setLoading(false);
return;
}
loadCourses();
}, [toast, authLoading, user]);
const statsCards = [
{ name: 'Всего курсов', value: stats.total.toString(), icon: BookOpen },
{ name: 'Черновики', value: stats.drafts.toString(), icon: Clock },
{ name: 'Опубликовано', value: stats.published.toString(), icon: TrendingUp },
];
if (authLoading || loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Мои курсы</h1>
<p className="text-muted-foreground">
Управляйте своими курсами и создавайте новые
</p>
</div>
<Button asChild>
<Link href="/dashboard/courses/new">
<Plus className="mr-2 h-4 w-4" />
Создать курс
</Link>
</Button>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
{statsCards.map((stat) => (
<Card key={stat.name}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{stat.name}</CardTitle>
<stat.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stat.value}</div>
</CardContent>
</Card>
))}
</div>
{/* Courses grid */}
{courses.length > 0 ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{courses.map((course) => (
<CourseCard key={course.id} course={course} onDeleted={loadCourses} />
))}
</div>
) : (
<Card className="p-12 text-center">
<CardHeader>
<CardTitle>Нет курсов</CardTitle>
<CardDescription>
Создайте свой первый курс с помощью AI
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link href="/dashboard/courses/new">
<Plus className="mr-2 h-4 w-4" />
Создать курс
</Link>
</Button>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -0,0 +1,137 @@
'use client';
import { useState } from 'react';
import { Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useToast } from '@/components/ui/use-toast';
export default function SettingsPage() {
const { toast } = useToast();
const [settings, setSettings] = useState({
name: 'John Doe',
email: 'john@example.com',
customAiModel: '',
emailNotifications: true,
marketingEmails: false,
});
const handleSave = () => {
toast({
title: 'Настройки сохранены',
description: 'Ваши настройки успешно обновлены.',
});
};
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-3xl font-bold">Настройки</h1>
<p className="text-muted-foreground">
Управляйте настройками вашего аккаунта
</p>
</div>
{/* Profile */}
<Card>
<CardHeader>
<CardTitle>Профиль</CardTitle>
<CardDescription>Информация о вашем аккаунте</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Имя</Label>
<Input
id="name"
value={settings.name}
onChange={(e) => setSettings({ ...settings, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" value={settings.email} disabled />
<p className="text-xs text-muted-foreground">
Email нельзя изменить
</p>
</div>
</CardContent>
</Card>
{/* AI Settings */}
<Card>
<CardHeader>
<CardTitle>Настройки AI</CardTitle>
<CardDescription>
Настройте модель нейросети для генерации курсов
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="aiModel">Пользовательская модель</Label>
<Input
id="aiModel"
placeholder="qwen/qwen3-coder-next"
value={settings.customAiModel}
onChange={(e) =>
setSettings({ ...settings, customAiModel: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
Укажите модель в формате provider/model-name. Если оставить пустым,
будет использована модель по умолчанию для вашего тарифа.
</p>
</div>
</CardContent>
</Card>
{/* Notifications */}
<Card>
<CardHeader>
<CardTitle>Уведомления</CardTitle>
<CardDescription>Настройки email уведомлений</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Уведомления о курсах</Label>
<p className="text-sm text-muted-foreground">
Получать уведомления о статусе генерации
</p>
</div>
<input
type="checkbox"
checked={settings.emailNotifications}
onChange={(e) =>
setSettings({ ...settings, emailNotifications: e.target.checked })
}
className="h-4 w-4"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Маркетинговые письма</Label>
<p className="text-sm text-muted-foreground">
Получать новости и специальные предложения
</p>
</div>
<input
type="checkbox"
checked={settings.marketingEmails}
onChange={(e) =>
setSettings({ ...settings, marketingEmails: e.target.checked })
}
className="h-4 w-4"
/>
</div>
</CardContent>
</Card>
<Button onClick={handleSave}>
<Save className="mr-2 h-4 w-4" />
Сохранить настройки
</Button>
</div>
);
}

View File

@ -0,0 +1,18 @@
import { Sidebar } from '@/components/dashboard/sidebar';
import { DashboardHeader } from '@/components/dashboard/header';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex">
<Sidebar />
<div className="flex-1 flex flex-col">
<DashboardHeader />
<main className="flex-1 p-6">{children}</main>
</div>
</div>
);
}

View File

@ -0,0 +1,106 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 262.1 83.3% 57.8%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 262.1 83.3% 57.8%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 263.4 70% 50.4%;
--primary-foreground: 210 40% 98%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 263.4 70% 50.4%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: hsl(var(--muted));
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Selection */
::selection {
background: hsl(var(--primary) / 0.2);
}

View File

@ -0,0 +1,45 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { ThemeProvider } from '@/components/providers/theme-provider';
import { AuthProvider } from '@/contexts/auth-context';
import { Toaster } from '@/components/ui/toaster';
const inter = Inter({ subsets: ['latin', 'cyrillic'] });
export const metadata: Metadata = {
title: 'CourseCraft - AI-Powered Course Creation',
description:
'Create professional courses in minutes with AI. Generate comprehensive educational content with just a simple prompt.',
keywords: ['course creation', 'AI', 'education', 'online learning', 'course generator'],
authors: [{ name: 'CourseCraft' }],
openGraph: {
title: 'CourseCraft - AI-Powered Course Creation',
description: 'Create professional courses in minutes with AI.',
type: 'website',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru" suppressHydrationWarning>
<body className={inter.className}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<AuthProvider>
{children}
<Toaster />
</AuthProvider>
</ThemeProvider>
</body>
</html>
);
}

23
apps/web/src/app/page.tsx Normal file
View File

@ -0,0 +1,23 @@
import { Header } from '@/components/landing/header';
import { Hero } from '@/components/landing/hero';
import { Features } from '@/components/landing/features';
import { HowItWorks } from '@/components/landing/how-it-works';
import { Pricing } from '@/components/landing/pricing';
import { FAQ } from '@/components/landing/faq';
import { Footer } from '@/components/landing/footer';
export default function LandingPage() {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<Hero />
<Features />
<HowItWorks />
<Pricing />
<FAQ />
</main>
<Footer />
</div>
);
}

View File

@ -0,0 +1,199 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { MoreHorizontal, BookOpen, Clock, Edit, Trash2, Eye } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import { formatRelativeTime } from '@coursecraft/shared';
import { api } from '@/lib/api';
interface CourseCardProps {
course: {
id: string;
title: string;
description: string | null;
status: 'DRAFT' | 'GENERATING' | 'PUBLISHED' | 'ARCHIVED';
chaptersCount: number;
lessonsCount: number;
updatedAt: string;
generationProgress?: number;
};
onDeleted?: () => void;
}
const statusConfig = {
DRAFT: {
label: 'Черновик',
className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
},
GENERATING: {
label: 'Генерация',
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
},
PUBLISHED: {
label: 'Опубликован',
className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
},
ARCHIVED: {
label: 'Архив',
className: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
},
};
export function CourseCard({ course, onDeleted }: CourseCardProps) {
const router = useRouter();
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const handleDelete = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setDeleting(true);
try {
await api.deleteCourse(course.id);
setDeleteOpen(false);
onDeleted?.();
router.refresh();
} finally {
setDeleting(false);
}
};
const status = statusConfig[course.status];
return (
<Card className="group relative overflow-hidden transition-shadow hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span
className={cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
status.className
)}
>
{status.label}
</span>
</div>
<CardTitle className="line-clamp-1">{course.title}</CardTitle>
{course.description && (
<CardDescription className="line-clamp-2 mt-1">
{course.description}
</CardDescription>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Действия</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/dashboard/courses/${course.id}`}>
<Eye className="mr-2 h-4 w-4" />
Просмотр
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/dashboard/courses/${course.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Редактировать
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={(e) => { e.preventDefault(); setDeleteOpen(true); }}
>
<Trash2 className="mr-2 h-4 w-4" />
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent>
{course.status === 'GENERATING' && course.generationProgress !== undefined ? (
<div className="space-y-2">
<Progress value={course.generationProgress} className="h-2" />
<p className="text-xs text-muted-foreground">
Генерация: {course.generationProgress}%
</p>
</div>
) : (
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<BookOpen className="h-4 w-4" />
<span>{course.chaptersCount} глав</span>
</div>
<div className="flex items-center gap-1">
<Clock className="h-4 w-4" />
<span>{course.lessonsCount} уроков</span>
</div>
</div>
)}
<p className="mt-4 text-xs text-muted-foreground">
Обновлён {formatRelativeTime(course.updatedAt)}
</p>
</CardContent>
{/* Clickable overlay */}
<Link
href={`/dashboard/courses/${course.id}`}
className="absolute inset-0"
aria-label={`Открыть курс ${course.title}`}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>Удалить курс?</AlertDialogTitle>
<AlertDialogDescription>
Курс «{course.title}» будет удалён безвозвратно. Все главы и уроки также будут удалены.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>Отмена</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleting}
>
{deleting ? 'Удаление...' : 'Удалить'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
);
}

View File

@ -0,0 +1,95 @@
'use client';
import Link from 'next/link';
import { Bell, Menu, User, LogOut, Settings, CreditCard } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAuth } from '@/contexts/auth-context';
export function DashboardHeader() {
const { user, signOut } = useAuth();
const initials = user?.user_metadata?.full_name
?.split(' ')
.map((n: string) => n[0])
.join('')
.toUpperCase() || user?.email?.[0]?.toUpperCase() || 'U';
return (
<header className="flex h-16 items-center justify-between border-b px-6">
{/* Mobile menu button */}
<Button variant="ghost" size="icon" className="lg:hidden">
<Menu className="h-5 w-5" />
<span className="sr-only">Открыть меню</span>
</Button>
{/* Spacer */}
<div className="flex-1" />
{/* Right side */}
<div className="flex items-center gap-4">
{/* Notifications */}
<Button variant="ghost" size="icon">
<Bell className="h-5 w-5" />
<span className="sr-only">Уведомления</span>
</Button>
{/* User menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-9 w-9 rounded-full">
<Avatar className="h-9 w-9">
<AvatarImage
src={user?.user_metadata?.avatar_url}
alt={user?.user_metadata?.full_name || 'User'}
/>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{user?.user_metadata?.full_name || 'Пользователь'}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user?.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/dashboard/settings" className="cursor-pointer">
<Settings className="mr-2 h-4 w-4" />
Настройки
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/dashboard/billing" className="cursor-pointer">
<CreditCard className="mr-2 h-4 w-4" />
Подписка
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive cursor-pointer"
onClick={() => signOut()}
>
<LogOut className="mr-2 h-4 w-4" />
Выйти
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}

View File

@ -0,0 +1,98 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
Sparkles,
LayoutDashboard,
BookOpen,
Settings,
CreditCard,
Plus,
Search,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
const navigation = [
{ name: 'Обзор', href: '/dashboard', icon: LayoutDashboard },
{ name: 'Мои курсы', href: '/dashboard', icon: BookOpen },
{ name: 'Поиск', href: '/dashboard/search', icon: Search },
];
const bottomNavigation = [
{ name: 'Настройки', href: '/dashboard/settings', icon: Settings },
{ name: 'Подписка', href: '/dashboard/billing', icon: CreditCard },
];
export function Sidebar() {
const pathname = usePathname();
return (
<aside className="hidden lg:flex lg:flex-col lg:w-64 lg:border-r lg:bg-muted/30">
{/* Logo */}
<div className="flex h-16 items-center gap-2 border-b px-6">
<Link href="/" className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
<Sparkles className="h-4 w-4 text-primary-foreground" />
</div>
<span className="text-lg font-bold">CourseCraft</span>
</Link>
</div>
{/* Create button */}
<div className="p-4">
<Button className="w-full" asChild>
<Link href="/dashboard/courses/new">
<Plus className="mr-2 h-4 w-4" />
Создать курс
</Link>
</Button>
</div>
{/* Main navigation */}
<nav className="flex-1 px-4 space-y-1">
{navigation.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
);
})}
</nav>
{/* Bottom navigation */}
<nav className="p-4 border-t space-y-1">
{bottomNavigation.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
);
})}
</nav>
</aside>
);
}

View File

@ -0,0 +1,394 @@
'use client';
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import mermaid from 'mermaid';
import {
Bold,
Italic,
Underline as UnderlineIcon,
List,
ListOrdered,
Heading1,
Heading2,
Heading3,
Quote,
Code,
Minus,
Link as LinkIcon,
Wand2,
ImageIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useCallback, useEffect, useState, useRef } from 'react';
interface CourseEditorProps {
content: Record<string, unknown>;
onChange: (content: Record<string, unknown>) => void;
}
mermaid.initialize({ startOnLoad: false, theme: 'neutral' });
export function CourseEditor({ content, onChange }: CourseEditorProps) {
const [showAiMenu, setShowAiMenu] = useState(false);
const [linkUrl, setLinkUrl] = useState('');
const [showLinkInput, setShowLinkInput] = useState(false);
const [imageUrl, setImageUrl] = useState('');
const [showImageInput, setShowImageInput] = useState(false);
const editorRef = useRef<HTMLDivElement>(null);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
codeBlock: {
HTMLAttributes: (node) =>
node.attrs.language === 'mermaid'
? { class: 'mermaid rounded-lg p-4 bg-muted min-h-[80px]', 'data-language': 'mermaid' }
: { class: 'rounded-lg bg-muted p-4 font-mono text-sm', 'data-language': node.attrs.language || '' },
},
}),
Underline,
Link.configure({
openOnClick: false,
HTMLAttributes: { class: 'text-primary underline underline-offset-2' },
}),
Image.configure({
inline: false,
allowBase64: true,
HTMLAttributes: { class: 'rounded-lg max-w-full h-auto' },
}),
Placeholder.configure({
placeholder: 'Начните писать. Поддерживаются: текст, списки, цитаты, блоки кода, Mermaid-диаграммы, картинки, ссылки.',
}),
],
content,
editorProps: {
attributes: {
class:
'prose prose-sm sm:prose lg:prose-lg dark:prose-invert max-w-none focus:outline-none min-h-full [&_.ProseMirror]:min-h-[60vh] [&_.ProseMirror_pre]:rounded-lg [&_.ProseMirror_pre]:bg-muted [&_.ProseMirror_pre]:p-4 [&_.ProseMirror_pre]:font-mono [&_.ProseMirror_pre]:text-sm [&_.ProseMirror_pre]:border',
},
},
onUpdate: ({ editor }) => {
onChange(editor.getJSON());
},
});
useEffect(() => {
if (editor && content) {
const currentContent = JSON.stringify(editor.getJSON());
const newContent = JSON.stringify(content);
if (currentContent !== newContent) {
editor.commands.setContent(content);
}
}
}, [content, editor]);
// Render Mermaid diagrams in code blocks with language "mermaid"
useEffect(() => {
if (!editorRef.current) return;
const mermaidNodes = editorRef.current.querySelectorAll('pre[data-language="mermaid"]');
if (mermaidNodes.length === 0) return;
mermaid.run({ nodes: Array.from(mermaidNodes), suppressErrors: true }).catch(() => {});
}, [content]);
const setLink = useCallback(() => {
if (!editor) return;
if (linkUrl) {
editor.chain().focus().extendMarkRange('link').setLink({ href: linkUrl }).run();
} else {
editor.chain().focus().unsetLink().run();
}
setLinkUrl('');
setShowLinkInput(false);
}, [editor, linkUrl]);
const setImage = useCallback(() => {
if (!editor || !imageUrl) return;
editor.chain().focus().setImage({ src: imageUrl }).run();
setImageUrl('');
setShowImageInput(false);
}, [editor, imageUrl]);
const handleAiRewrite = useCallback(() => {
if (!editor) return;
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to);
if (selectedText) {
console.log('Rewriting:', selectedText);
setShowAiMenu(false);
}
}, [editor]);
if (!editor) {
return null;
}
return (
<div ref={editorRef} className="relative border rounded-lg overflow-hidden flex-1 flex flex-col min-h-0">
{/* Fixed toolbar */}
<div className="flex flex-wrap items-center gap-1 border-b bg-muted/30 p-2 shrink-0">
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title="Жирный"
>
<Bold className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title="Курсив"
>
<Italic className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleUnderline().run()}
isActive={editor.isActive('underline')}
title="Подчёркнутый"
>
<UnderlineIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive('strike')}
title="Зачёркнутый"
>
<span className="text-sm font-bold line-through">S</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCode().run()}
isActive={editor.isActive('code')}
title="Код (инлайн)"
>
<Code className="h-4 w-4" />
</ToolbarButton>
<div className="w-px h-6 bg-border mx-1" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
isActive={editor.isActive('heading', { level: 1 })}
title="Заголовок 1"
>
<Heading1 className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive('heading', { level: 2 })}
title="Заголовок 2"
>
<Heading2 className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive('heading', { level: 3 })}
title="Заголовок 3"
>
<Heading3 className="h-4 w-4" />
</ToolbarButton>
<div className="w-px h-6 bg-border mx-1" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title="Маркированный список"
>
<List className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title="Нумерованный список"
>
<ListOrdered className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
title="Цитата"
>
<Quote className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
isActive={editor.isActive('codeBlock') && editor.getAttributes('codeBlock').language !== 'mermaid'}
title="Блок кода"
>
<Code className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCodeBlock({ language: 'mermaid' }).run()}
isActive={editor.isActive('codeBlock') && editor.getAttributes('codeBlock').language === 'mermaid'}
title="Mermaid-диаграмма"
>
<span className="text-xs font-bold">M</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Разделитель"
>
<Minus className="h-4 w-4" />
</ToolbarButton>
<div className="w-px h-6 bg-border mx-1" />
<div className="relative flex items-center gap-1">
<ToolbarButton
onClick={() => {
const prev = editor.getAttributes('link').href;
setLinkUrl(prev || '');
setShowLinkInput(!showLinkInput);
setShowImageInput(false);
}}
isActive={editor.isActive('link')}
title="Ссылка"
>
<LinkIcon className="h-4 w-4" />
</ToolbarButton>
{showLinkInput && (
<div className="absolute left-0 top-full mt-1 flex gap-1 bg-background border rounded-md p-2 shadow-lg z-10">
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://..."
className="w-48 px-2 py-1 text-sm border rounded"
onKeyDown={(e) => e.key === 'Enter' && setLink()}
/>
<Button type="button" size="sm" onClick={setLink}>
OK
</Button>
</div>
)}
</div>
<div className="relative flex items-center gap-1">
<ToolbarButton
onClick={() => {
setShowImageInput(!showImageInput);
setShowLinkInput(false);
}}
title="Картинка"
>
<ImageIcon className="h-4 w-4" />
</ToolbarButton>
{showImageInput && (
<div className="absolute left-0 top-full mt-1 flex gap-1 bg-background border rounded-md p-2 shadow-lg z-10">
<input
type="url"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="URL картинки"
className="w-48 px-2 py-1 text-sm border rounded"
onKeyDown={(e) => e.key === 'Enter' && setImage()}
/>
<Button type="button" size="sm" onClick={setImage}>
Вставить
</Button>
</div>
)}
</div>
<div className="w-px h-6 bg-border mx-1" />
<Button
variant="ghost"
size="sm"
className="h-8 gap-1 text-primary"
onClick={handleAiRewrite}
>
<Wand2 className="h-4 w-4" />
AI
</Button>
</div>
{/* Bubble menu for quick formatting when text selected */}
<BubbleMenu
editor={editor}
tippyOptions={{ duration: 100 }}
className="flex items-center gap-1 rounded-lg border bg-background p-1 shadow-lg"
>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title="Жирный"
>
<Bold className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title="Курсив"
>
<Italic className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCode().run()}
isActive={editor.isActive('code')}
title="Код"
>
<Code className="h-4 w-4" />
</ToolbarButton>
</BubbleMenu>
<div className="flex-1 min-h-0 p-4 flex flex-col">
<EditorContent editor={editor} className="flex-1 min-h-0 [&_.ProseMirror]:min-h-[60vh]" />
</div>
{showAiMenu && (
<div className="absolute top-0 right-0 w-64 rounded-lg border bg-background p-4 shadow-lg z-10">
<h3 className="font-medium mb-2">AI помощник</h3>
<div className="space-y-2">
<Button variant="outline" size="sm" className="w-full justify-start">
Улучшить текст
</Button>
<Button variant="outline" size="sm" className="w-full justify-start">
Сделать короче
</Button>
<Button variant="outline" size="sm" className="w-full justify-start">
Сделать длиннее
</Button>
<Button variant="outline" size="sm" className="w-full justify-start">
Упростить
</Button>
</div>
</div>
)}
</div>
);
}
interface ToolbarButtonProps {
onClick: () => void;
isActive?: boolean;
title: string;
children: React.ReactNode;
}
function ToolbarButton({ onClick, isActive, title, children }: ToolbarButtonProps) {
return (
<button
type="button"
title={title}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
// Defer so editor still has selection and focus is not stolen
setTimeout(() => onClick(), 0);
}}
className={cn(
'flex h-8 w-8 items-center justify-center rounded-md transition-colors',
isActive ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'
)}
>
{children}
</button>
);
}

View File

@ -0,0 +1,70 @@
'use client';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import mermaid from 'mermaid';
import { useEffect, useRef } from 'react';
mermaid.initialize({ startOnLoad: false, theme: 'neutral' });
const emptyDoc = { type: 'doc', content: [] };
interface LessonContentViewerProps {
content: Record<string, unknown> | null;
className?: string;
}
export function LessonContentViewer({ content, className }: LessonContentViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
codeBlock: {
HTMLAttributes: (node: { attrs: { language?: string } }) =>
node.attrs.language === 'mermaid'
? { class: 'mermaid rounded-lg p-4 bg-muted min-h-[80px]', 'data-language': 'mermaid' }
: { class: 'rounded-lg bg-muted p-4 font-mono text-sm', 'data-language': node.attrs.language || '' },
},
}),
Underline,
Link.configure({
openOnClick: true,
HTMLAttributes: { class: 'text-primary underline underline-offset-2' },
}),
Image.configure({ HTMLAttributes: { class: 'rounded-lg max-w-full h-auto' } }),
],
content: content ?? emptyDoc,
editable: false,
editorProps: {
attributes: {
class:
'prose prose-lg dark:prose-invert max-w-none focus:outline-none leading-relaxed [&_.ProseMirror]:outline-none [&_.ProseMirror]:text-foreground [&_.ProseMirror_p]:leading-7 [&_.ProseMirror_h1]:text-3xl [&_.ProseMirror_h2]:text-2xl [&_.ProseMirror_h3]:text-xl [&_.ProseMirror_pre]:rounded-lg [&_.ProseMirror_pre]:bg-muted [&_.ProseMirror_pre]:p-4 [&_.ProseMirror_pre]:font-mono [&_.ProseMirror_pre]:text-sm [&_.ProseMirror_pre]:border [&_.ProseMirror_blockquote]:border-primary [&_.ProseMirror_blockquote]:bg-muted/30',
},
},
});
useEffect(() => {
if (editor && content) {
editor.commands.setContent(content);
}
}, [content, editor]);
useEffect(() => {
if (!containerRef.current || !content) return;
const mermaidNodes = containerRef.current.querySelectorAll('pre[data-language="mermaid"]');
if (mermaidNodes.length === 0) return;
mermaid.run({ nodes: Array.from(mermaidNodes), suppressErrors: true }).catch(() => {});
}, [content]);
if (!editor) return null;
return (
<div ref={containerRef} className={className}>
<EditorContent editor={editor} />
</div>
);
}

View File

@ -0,0 +1,118 @@
'use client';
import { ChevronDown, ChevronRight, FileText, Plus } from 'lucide-react';
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface Lesson {
id: string;
title: string;
}
interface Chapter {
id: string;
title: string;
lessons: Lesson[];
}
interface Course {
id: string;
title: string;
chapters: Chapter[];
}
interface LessonSidebarProps {
course: Course;
activeLesson: string;
onSelectLesson: (lessonId: string) => void;
/** Скрыть кнопки «Добавить урок/главу» (режим просмотра) */
readOnly?: boolean;
}
export function LessonSidebar({
course,
activeLesson,
onSelectLesson,
readOnly = false,
}: LessonSidebarProps) {
const [expandedChapters, setExpandedChapters] = useState<string[]>(
course.chapters.map((ch) => ch.id)
);
const toggleChapter = (chapterId: string) => {
setExpandedChapters((prev) =>
prev.includes(chapterId)
? prev.filter((id) => id !== chapterId)
: [...prev, chapterId]
);
};
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="p-4 border-b">
<h2 className="font-semibold truncate">{course.title}</h2>
</div>
{/* Chapters list */}
<div className="flex-1 overflow-auto p-2">
{course.chapters.map((chapter) => {
const isExpanded = expandedChapters.includes(chapter.id);
return (
<div key={chapter.id} className="mb-2">
{/* Chapter header */}
<button
className="flex items-center gap-2 w-full rounded-md px-2 py-1.5 text-sm font-medium hover:bg-muted transition-colors"
onClick={() => toggleChapter(chapter.id)}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<span className="truncate">{chapter.title}</span>
</button>
{/* Lessons */}
{isExpanded && (
<div className="ml-4 mt-1 space-y-1">
{chapter.lessons.map((lesson) => (
<button
key={lesson.id}
className={cn(
'flex items-center gap-2 w-full rounded-md px-2 py-1.5 text-sm transition-colors text-left',
activeLesson === lesson.id
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted text-muted-foreground'
)}
onClick={() => onSelectLesson(lesson.id)}
>
<FileText className="h-4 w-4 shrink-0" />
<span className="truncate">{lesson.title}</span>
</button>
))}
{!readOnly && (
<button className="flex items-center gap-2 w-full rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-muted transition-colors">
<Plus className="h-4 w-4 shrink-0" />
<span>Добавить урок</span>
</button>
)}
</div>
)}
</div>
);
})}
</div>
{!readOnly && (
<div className="p-2 border-t">
<Button variant="ghost" size="sm" className="w-full justify-start">
<Plus className="mr-2 h-4 w-4" />
Добавить главу
</Button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,103 @@
'use client';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
const faqs = [
{
question: 'Как работает AI-генерация курсов?',
answer:
'Вы описываете тему курса простым текстом, например "Сделай курс по маркетингу". Наша нейросеть анализирует запрос, задаёт уточняющие вопросы, а затем генерирует полную структуру курса с главами, уроками и контентом. Весь процесс занимает несколько минут.',
},
{
question: 'Могу ли я редактировать сгенерированный курс?',
answer:
'Да, конечно! После генерации вы получаете полный доступ к редактированию. Можете изменять текст вручную или использовать AI для переписывания отдельных частей. Просто выделите нужный фрагмент и попросите нейросеть его улучшить.',
},
{
question: 'Какие AI модели используются?',
answer:
'Мы используем передовые языковые модели через OpenRouter. В зависимости от тарифа вам доступны разные модели: от базовых (Llama) до топовых (Claude 3.5 Sonnet). На Pro-тарифе вы можете выбрать модель самостоятельно.',
},
{
question: 'Кому принадлежат созданные курсы?',
answer:
'Все созданные курсы принадлежат вам полностью. Вы можете использовать их как угодно: публиковать, продавать, делиться. В будущем мы планируем маркетплейс, где вы сможете продавать свои курсы.',
},
{
question: 'Можно ли отменить подписку?',
answer:
'Да, вы можете отменить подписку в любое время в личном кабинете. После отмены вы сохраните доступ до конца оплаченного периода и перейдёте на бесплатный план.',
},
{
question: 'Есть ли ограничения на количество курсов?',
answer:
'Да, количество курсов в месяц зависит от тарифа: 2 на бесплатном, 5 на Премиум и 15 на Профессиональном плане. Счётчик обновляется каждый месяц.',
},
];
export function FAQ() {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<section id="faq" className="py-20 sm:py-32">
<div className="container">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
Частые вопросы
</h2>
<p className="mt-4 text-lg text-muted-foreground">
Не нашли ответ? Напишите нам на support@coursecraft.ai
</p>
</div>
<div className="mx-auto mt-16 max-w-3xl">
<div className="space-y-4">
{faqs.map((faq, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: index * 0.05 }}
>
<button
className={cn(
'flex w-full items-center justify-between rounded-lg border bg-background p-6 text-left transition-colors',
openIndex === index && 'border-primary'
)}
onClick={() => setOpenIndex(openIndex === index ? null : index)}
>
<span className="font-medium">{faq.question}</span>
<ChevronDown
className={cn(
'h-5 w-5 shrink-0 text-muted-foreground transition-transform',
openIndex === index && 'rotate-180'
)}
/>
</button>
<AnimatePresence>
{openIndex === index && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-6 py-4 text-muted-foreground">
{faq.answer}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,111 @@
'use client';
import { motion } from 'framer-motion';
import {
Wand2,
Layout,
Edit3,
Search,
Shield,
Zap,
} from 'lucide-react';
const features = [
{
name: 'AI-генерация курсов',
description:
'Просто опишите тему курса, и наша нейросеть создаст полную структуру с главами, уроками и контентом.',
icon: Wand2,
},
{
name: 'Умная структура',
description:
'Автоматическое разбиение на логичные главы и уроки с учётом сложности материала и целевой аудитории.',
icon: Layout,
},
{
name: 'Гибкое редактирование',
description:
'Редактируйте любую часть курса вручную или с помощью AI. Выделите текст и попросите нейросеть переписать.',
icon: Edit3,
},
{
name: 'Уточняющие вопросы',
description:
'AI задаст правильные вопросы, чтобы понять ваши потребности и создать максимально релевантный курс.',
icon: Search,
},
{
name: 'Приватность данных',
description:
'Ваши курсы принадлежат только вам. Полный контроль над контентом и возможность монетизации.',
icon: Shield,
},
{
name: 'Быстрая генерация',
description:
'Создание полноценного курса занимает считанные минуты. Следите за прогрессом в реальном времени.',
icon: Zap,
},
];
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
},
},
};
export function Features() {
return (
<section id="features" className="py-20 sm:py-32 bg-muted/30">
<div className="container">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
Всё, что нужно для создания курсов
</h2>
<p className="mt-4 text-lg text-muted-foreground">
CourseCraft объединяет мощь искусственного интеллекта с интуитивным
интерфейсом для создания профессиональных образовательных материалов.
</p>
</div>
<motion.div
className="mx-auto mt-16 grid max-w-5xl grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3"
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: '-100px' }}
>
{features.map((feature) => (
<motion.div
key={feature.name}
className="relative rounded-2xl border bg-background p-8 shadow-sm transition-shadow hover:shadow-md"
variants={itemVariants}
>
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<feature.icon className="h-6 w-6 text-primary" />
</div>
<h3 className="mt-4 text-lg font-semibold">{feature.name}</h3>
<p className="mt-2 text-muted-foreground">{feature.description}</p>
</motion.div>
))}
</motion.div>
</div>
</section>
);
}

View File

@ -0,0 +1,99 @@
import Link from 'next/link';
import { Sparkles } from 'lucide-react';
const navigation = {
product: [
{ name: 'Возможности', href: '#features' },
{ name: 'Тарифы', href: '#pricing' },
{ name: 'FAQ', href: '#faq' },
],
company: [
{ name: 'О нас', href: '/about' },
{ name: 'Блог', href: '/blog' },
{ name: 'Контакты', href: '/contact' },
],
legal: [
{ name: 'Политика конфиденциальности', href: '/privacy' },
{ name: 'Условия использования', href: '/terms' },
],
};
export function Footer() {
return (
<footer className="border-t bg-muted/30">
<div className="container py-12 md:py-16">
<div className="grid grid-cols-2 gap-8 md:grid-cols-4">
{/* Brand */}
<div className="col-span-2 md:col-span-1">
<Link href="/" className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary">
<Sparkles className="h-5 w-5 text-primary-foreground" />
</div>
<span className="text-xl font-bold">CourseCraft</span>
</Link>
<p className="mt-4 text-sm text-muted-foreground">
Создавайте профессиональные курсы за минуты с помощью искусственного интеллекта.
</p>
</div>
{/* Product */}
<div>
<h3 className="text-sm font-semibold">Продукт</h3>
<ul className="mt-4 space-y-3">
{navigation.product.map((item) => (
<li key={item.name}>
<Link
href={item.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{item.name}
</Link>
</li>
))}
</ul>
</div>
{/* Company */}
<div>
<h3 className="text-sm font-semibold">Компания</h3>
<ul className="mt-4 space-y-3">
{navigation.company.map((item) => (
<li key={item.name}>
<Link
href={item.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{item.name}
</Link>
</li>
))}
</ul>
</div>
{/* Legal */}
<div>
<h3 className="text-sm font-semibold">Документы</h3>
<ul className="mt-4 space-y-3">
{navigation.legal.map((item) => (
<li key={item.name}>
<Link
href={item.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{item.name}
</Link>
</li>
))}
</ul>
</div>
</div>
<div className="mt-12 border-t pt-8">
<p className="text-center text-sm text-muted-foreground">
&copy; {new Date().getFullYear()} CourseCraft. Все права защищены.
</p>
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,144 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { Menu, X, Sparkles, BookOpen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/auth-context';
const navigation = [
{ name: 'Возможности', href: '#features' },
{ name: 'Как это работает', href: '#how-it-works' },
{ name: 'Тарифы', href: '#pricing' },
{ name: 'FAQ', href: '#faq' },
];
export function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { user, loading } = useAuth();
const displayName =
user?.user_metadata?.full_name ||
user?.user_metadata?.name ||
user?.email?.split('@')[0] ||
'Пользователь';
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<nav className="container flex h-16 items-center justify-between">
<div className="flex items-center gap-2">
<Link href="/" className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary">
<Sparkles className="h-5 w-5 text-primary-foreground" />
</div>
<span className="text-xl font-bold">CourseCraft</span>
</Link>
</div>
{/* Desktop navigation */}
<div className="hidden md:flex md:items-center md:gap-8">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
{item.name}
</Link>
))}
</div>
<div className="hidden md:flex md:items-center md:gap-4">
{!loading &&
(user ? (
<Link
href="/dashboard"
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-foreground"
>
<Avatar className="h-8 w-8">
<AvatarImage src={user.user_metadata?.avatar_url} alt="" />
<AvatarFallback className="text-xs">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="max-w-[120px] truncate">{displayName}</span>
<BookOpen className="h-4 w-4 text-muted-foreground" />
</Link>
) : (
<>
<Button variant="ghost" asChild>
<Link href="/login">Войти</Link>
</Button>
<Button asChild>
<Link href="/register">Начать бесплатно</Link>
</Button>
</>
))}
</div>
{/* Mobile menu button */}
<button
type="button"
className="md:hidden -m-2.5 inline-flex items-center justify-center rounded-md p-2.5"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
<span className="sr-only">Открыть меню</span>
{mobileMenuOpen ? (
<X className="h-6 w-6" aria-hidden="true" />
) : (
<Menu className="h-6 w-6" aria-hidden="true" />
)}
</button>
</nav>
{/* Mobile menu */}
<div
className={cn(
'md:hidden',
mobileMenuOpen ? 'block' : 'hidden'
)}
>
<div className="space-y-1 px-4 pb-4 pt-2">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className="block rounded-md px-3 py-2 text-base font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => setMobileMenuOpen(false)}
>
{item.name}
</Link>
))}
<div className="flex flex-col gap-2 pt-4">
{user ? (
<Link
href="/dashboard"
className="flex items-center gap-2 rounded-md px-3 py-2 text-base font-medium hover:bg-accent"
onClick={() => setMobileMenuOpen(false)}
>
<Avatar className="h-8 w-8">
<AvatarImage src={user.user_metadata?.avatar_url} alt="" />
<AvatarFallback className="text-xs">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span>{displayName}</span>
<BookOpen className="h-4 w-4 ml-auto" />
</Link>
) : (
<>
<Button variant="outline" asChild className="w-full">
<Link href="/login">Войти</Link>
</Button>
<Button asChild className="w-full">
<Link href="/register">Начать бесплатно</Link>
</Button>
</>
)}
</div>
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,105 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { ArrowRight, Zap, BookOpen, Brain } from 'lucide-react';
import { Button } from '@/components/ui/button';
export function Hero() {
return (
<section className="relative overflow-hidden py-20 sm:py-32">
{/* Background gradient */}
<div className="absolute inset-0 -z-10">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-background to-primary/10" />
<div className="absolute top-0 right-0 -translate-y-1/4 translate-x-1/4 w-[600px] h-[600px] rounded-full bg-primary/10 blur-3xl" />
<div className="absolute bottom-0 left-0 translate-y-1/4 -translate-x-1/4 w-[600px] h-[600px] rounded-full bg-primary/5 blur-3xl" />
</div>
<div className="container">
<div className="mx-auto max-w-3xl text-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<div className="mb-8 inline-flex items-center gap-2 rounded-full border bg-background/50 px-4 py-1.5 text-sm backdrop-blur">
<Zap className="h-4 w-4 text-primary" />
<span>AI-powered course creation</span>
</div>
</motion.div>
<motion.h1
className="text-4xl font-bold tracking-tight sm:text-6xl"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
>
Создавайте курсы за{' '}
<span className="text-primary">минуты</span>,{' '}
<br className="hidden sm:inline" />
не за недели
</motion.h1>
<motion.p
className="mt-6 text-lg leading-8 text-muted-foreground"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
CourseCraft использует искусственный интеллект для создания
профессиональных образовательных курсов. Просто опишите тему,
и наша нейросеть создаст полноценный курс с структурой,
контентом и материалами.
</motion.p>
<motion.div
className="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<Button size="xl" asChild>
<Link href="/register">
Создать первый курс
<ArrowRight className="ml-2 h-5 w-5" />
</Link>
</Button>
<Button size="xl" variant="outline" asChild>
<Link href="#how-it-works">Как это работает</Link>
</Button>
</motion.div>
{/* Stats */}
<motion.div
className="mt-16 grid grid-cols-3 gap-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<div className="flex flex-col items-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<BookOpen className="h-6 w-6 text-primary" />
</div>
<div className="mt-3 text-2xl font-bold">2 мин</div>
<div className="text-sm text-muted-foreground">Среднее время создания</div>
</div>
<div className="flex flex-col items-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Brain className="h-6 w-6 text-primary" />
</div>
<div className="mt-3 text-2xl font-bold">AI</div>
<div className="text-sm text-muted-foreground">Умная генерация</div>
</div>
<div className="flex flex-col items-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Zap className="h-6 w-6 text-primary" />
</div>
<div className="mt-3 text-2xl font-bold">100%</div>
<div className="text-sm text-muted-foreground">Ваш контент</div>
</div>
</motion.div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,82 @@
'use client';
import { motion } from 'framer-motion';
import { MessageSquare, Settings2, Loader2, FileCheck } from 'lucide-react';
const steps = [
{
number: '01',
title: 'Опишите курс',
description: 'Напишите простой промт о том, какой курс вы хотите создать. Например: "Сделай курс по маркетингу для начинающих".',
icon: MessageSquare,
},
{
number: '02',
title: 'Уточните детали',
description: 'AI задаст несколько вопросов, чтобы лучше понять ваши потребности: целевую аудиторию, глубину материала, желаемую длительность.',
icon: Settings2,
},
{
number: '03',
title: 'Генерация',
description: 'Нейросеть создаст структуру курса и наполнит его контентом. Следите за прогрессом в реальном времени.',
icon: Loader2,
},
{
number: '04',
title: 'Редактирование',
description: 'Отредактируйте готовый курс: измените текст вручную или попросите AI переписать отдельные части.',
icon: FileCheck,
},
];
export function HowItWorks() {
return (
<section id="how-it-works" className="py-20 sm:py-32">
<div className="container">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
Как это работает
</h2>
<p className="mt-4 text-lg text-muted-foreground">
Создание курса с CourseCraft это просто. Всего 4 шага от идеи до готового материала.
</p>
</div>
<div className="mx-auto mt-16 max-w-4xl">
<div className="relative">
{/* Connection line */}
<div className="absolute left-8 top-0 bottom-0 w-px bg-border hidden md:block" />
<div className="space-y-12">
{steps.map((step, index) => (
<motion.div
key={step.number}
className="relative flex gap-8"
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
{/* Step number */}
<div className="relative z-10 flex h-16 w-16 shrink-0 items-center justify-center rounded-full border-2 border-primary bg-background">
<step.icon className="h-6 w-6 text-primary" />
</div>
{/* Content */}
<div className="flex-1 pt-3">
<div className="flex items-center gap-4">
<span className="text-sm font-medium text-primary">{step.number}</span>
<h3 className="text-xl font-semibold">{step.title}</h3>
</div>
<p className="mt-2 text-muted-foreground">{step.description}</p>
</div>
</motion.div>
))}
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,108 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { SUBSCRIPTION_PLANS, formatPlanPrice } from '@coursecraft/shared';
const locale = 'ru';
const period = 'месяц';
const plans = SUBSCRIPTION_PLANS.map((plan, index) => {
const { formatted } = formatPlanPrice(plan, locale);
const isPopular = index === 1;
return {
name: plan.nameRu,
description: plan.descriptionRu,
priceFormatted: formatted,
period,
features: plan.featuresRu,
cta: plan.tier === 'FREE' ? 'Начать бесплатно' : index === 1 ? 'Выбрать Премиум' : 'Выбрать Pro',
href: plan.tier === 'FREE' ? '/register' : `/register?plan=${plan.tier.toLowerCase()}`,
popular: isPopular,
};
});
export function Pricing() {
return (
<section id="pricing" className="py-20 sm:py-32 bg-muted/30">
<div className="container">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
Простые и понятные тарифы
</h2>
<p className="mt-4 text-lg text-muted-foreground">
Выберите план, который подходит именно вам. Начните бесплатно и обновитесь в любое время.
</p>
</div>
<div className="mx-auto mt-16 grid max-w-5xl grid-cols-1 gap-8 md:grid-cols-3">
{plans.map((plan, index) => (
<motion.div
key={plan.name}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<Card
className={cn(
'relative flex flex-col h-full',
plan.popular && 'border-primary shadow-lg'
)}
>
{plan.popular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="inline-flex items-center rounded-full bg-primary px-3 py-1 text-xs font-medium text-primary-foreground">
Популярный
</span>
</div>
)}
<CardHeader>
<CardTitle>{plan.name}</CardTitle>
<CardDescription>{plan.description}</CardDescription>
</CardHeader>
<CardContent className="flex-1">
<div className="min-h-[3.5rem] flex flex-col justify-end">
<div className="flex items-baseline gap-1">
<span className="text-4xl font-bold">
{plan.priceFormatted}
</span>
{plan.priceFormatted !== 'Бесплатно' && (
<span className="text-muted-foreground">/{plan.period}</span>
)}
</div>
</div>
<ul className="mt-8 space-y-3">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-3">
<Check className="h-5 w-5 text-primary shrink-0" />
<span className="text-sm">{feature}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button
className="w-full"
variant={plan.popular ? 'default' : 'outline'}
asChild
>
<Link href={plan.href}>{plan.cta}</Link>
</Button>
</CardFooter>
</Card>
</motion.div>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,9 @@
'use client';
import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { type ThemeProviderProps } from 'next-themes/dist/types';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@ -0,0 +1,126 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn('mt-2 sm:mt-0', buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@ -0,0 +1,50 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@ -0,0 +1,50 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'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',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
xl: 'h-12 rounded-lg px-10 text-base',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@ -0,0 +1,56 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
)
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@ -0,0 +1,187 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@ -0,0 +1,24 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };

View File

@ -0,0 +1,21 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@ -0,0 +1,25 @@
'use client';
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '@/lib/utils';
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn('relative h-4 w-full overflow-hidden rounded-full bg-secondary', className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@ -0,0 +1,124 @@
'use client';
import * as React from 'react';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn('text-sm font-semibold', className)} {...props} />
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@ -0,0 +1,33 @@
'use client';
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from '@/components/ui/toast';
import { useToast } from '@/components/ui/use-toast';
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@ -0,0 +1,188 @@
'use client';
import * as React from 'react';
import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType['ADD_TOAST'];
toast: ToasterToast;
}
| {
type: ActionType['UPDATE_TOAST'];
toast: Partial<ToasterToast>;
}
| {
type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast['id'];
}
| {
type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast['id'];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
};
case 'DISMISS_TOAST': {
const { toastId } = action;
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
};
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
};
}
export { useToast, toast };

View File

@ -0,0 +1,160 @@
'use client';
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { User, Session } from '@supabase/supabase-js';
import { getSupabase } from '@/lib/supabase';
import { useRouter } from 'next/navigation';
import { api, setApiToken } from '@/lib/api';
interface AuthContextType {
user: User | null;
session: Session | null;
loading: boolean;
signUp: (email: string, password: string, name: string) => Promise<{ error: Error | null }>;
signIn: (email: string, password: string) => Promise<{ error: Error | null }>;
signOut: () => Promise<void>;
getAccessToken: () => Promise<string | null>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
const supabase = getSupabase();
useEffect(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
// Only set loading false when there's no session; otherwise wait for token exchange
if (!session) {
setApiToken(null);
setLoading(false);
}
});
// Listen for auth changes
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
setUser(session?.user ?? null);
if (!session) {
setApiToken(null);
setLoading(false);
}
});
return () => subscription.unsubscribe();
}, [supabase.auth]);
const runExchange = useCallback(() => {
if (!session?.access_token) {
setApiToken(null);
return;
}
api
.exchangeToken(session.access_token)
.then(({ accessToken }) => {
setApiToken(accessToken);
setLoading(false);
})
.catch(() => {
setApiToken(null);
setLoading(false);
});
}, [session?.access_token]);
// Exchange Supabase token for backend JWT; keep loading true until done so API calls wait for JWT
useEffect(() => {
runExchange();
}, [runExchange]);
// Re-exchange on 401 (e.g. JWT expired) so next request gets a fresh token
useEffect(() => {
const handler = () => {
if (session?.access_token) runExchange();
};
window.addEventListener('auth:unauthorized', handler);
return () => window.removeEventListener('auth:unauthorized', handler);
}, [session?.access_token, runExchange]);
const signUp = useCallback(
async (email: string, password: string, name: string) => {
try {
const { error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
full_name: name,
},
},
});
if (error) throw error;
return { error: null };
} catch (error) {
return { error: error as Error };
}
},
[supabase.auth]
);
const signIn = useCallback(
async (email: string, password: string) => {
try {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw error;
return { error: null };
} catch (error) {
return { error: error as Error };
}
},
[supabase.auth]
);
const signOut = useCallback(async () => {
await supabase.auth.signOut();
router.push('/');
}, [supabase.auth, router]);
const getAccessToken = useCallback(async () => {
const { data } = await supabase.auth.getSession();
return data.session?.access_token ?? null;
}, [supabase.auth]);
return (
<AuthContext.Provider
value={{
user,
session,
loading,
signUp,
signIn,
signOut,
getAccessToken,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

240
apps/web/src/lib/api.ts Normal file
View File

@ -0,0 +1,240 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
const API_URL = `${API_BASE}/api`;
const STORAGE_KEY = 'coursecraft_api_token';
/** JWT from backend (set after exchangeToken). API expects JWT in Authorization header. */
let apiToken: string | null = null;
function getStoredToken(): string | null {
if (typeof window === 'undefined') return null;
try {
return sessionStorage.getItem(STORAGE_KEY);
} catch {
return null;
}
}
export function setApiToken(token: string | null) {
apiToken = token;
try {
if (typeof window !== 'undefined') {
if (token) sessionStorage.setItem(STORAGE_KEY, token);
else sessionStorage.removeItem(STORAGE_KEY);
}
} catch {}
}
/** Returns current JWT (memory or sessionStorage after reload). */
function getApiToken(): string | null {
if (apiToken) return apiToken;
const stored = getStoredToken();
if (stored) {
apiToken = stored;
return stored;
}
return null;
}
class ApiClient {
private getAuthHeaders(): HeadersInit {
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
const token = getApiToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const headers = this.getAuthHeaders();
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers: {
...headers,
...options.headers,
},
});
if (!response.ok) {
if (response.status === 401) {
setApiToken(null);
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
}
}
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
// Auth - Exchange Supabase token for internal JWT (no Authorization header)
async exchangeToken(supabaseToken: string) {
return this.request<{ accessToken: string; user: any }>('/auth/exchange', {
method: 'POST',
body: JSON.stringify({ supabaseToken }),
});
}
// User
async getProfile() {
return this.request<any>('/users/me');
}
async updateProfile(data: { name?: string; bio?: string }) {
return this.request<any>('/users/me', {
method: 'PATCH',
body: JSON.stringify(data),
});
}
async getSettings() {
return this.request<any>('/users/me/settings');
}
async updateSettings(data: { customAiModel?: string; language?: string; theme?: string }) {
return this.request<any>('/users/me/settings', {
method: 'PATCH',
body: JSON.stringify(data),
});
}
// Courses
async getCourses(params?: { status?: string; page?: number; limit?: number }) {
const searchParams = new URLSearchParams();
if (params?.status) searchParams.set('status', params.status);
if (params?.page) searchParams.set('page', String(params.page));
if (params?.limit) searchParams.set('limit', String(params.limit));
const query = searchParams.toString();
return this.request<{ data: any[]; meta: any }>(`/courses${query ? `?${query}` : ''}`);
}
async getCourse(id: string) {
return this.request<any>(`/courses/${id}`);
}
async createCourse(data: { title: string; description?: string }) {
return this.request<any>('/courses', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateCourse(id: string, data: any) {
return this.request<any>(`/courses/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
async deleteCourse(id: string) {
return this.request<void>(`/courses/${id}`, {
method: 'DELETE',
});
}
// Chapters
async createChapter(courseId: string, data: { title: string; description?: string }) {
return this.request<any>(`/courses/${courseId}/chapters`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateChapter(courseId: string, chapterId: string, data: any) {
return this.request<any>(`/courses/${courseId}/chapters/${chapterId}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
async deleteChapter(courseId: string, chapterId: string) {
return this.request<void>(`/courses/${courseId}/chapters/${chapterId}`, {
method: 'DELETE',
});
}
// Lessons (API: GET/PATCH /courses/:courseId/lessons/:lessonId)
async getLesson(courseId: string, lessonId: string) {
return this.request<any>(`/courses/${courseId}/lessons/${lessonId}`);
}
async updateLesson(courseId: string, lessonId: string, data: any) {
return this.request<any>(`/courses/${courseId}/lessons/${lessonId}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
// Generation
async startGeneration(prompt: string) {
return this.request<{ id: string; status: string; progress: number }>('/generation/start', {
method: 'POST',
body: JSON.stringify({ prompt }),
});
}
async getGenerationStatus(id: string) {
return this.request<{
id: string;
status: string;
progress: number;
currentStep: string | null;
questions: any[] | null;
generatedOutline: any | null;
errorMessage: string | null;
course: { id: string; slug: string } | null;
}>(`/generation/${id}/status`);
}
async answerQuestions(id: string, answers: Record<string, string | string[]>) {
return this.request<{ success: boolean }>(`/generation/${id}/answer`, {
method: 'POST',
body: JSON.stringify({ answers }),
});
}
async cancelGeneration(id: string) {
return this.request<{ success: boolean }>(`/generation/${id}/cancel`, {
method: 'POST',
});
}
// Subscription
async getSubscription() {
return this.request<any>('/payments/subscription');
}
async createCheckoutSession(priceId: string) {
return this.request<{ url: string }>('/payments/create-checkout-session', {
method: 'POST',
body: JSON.stringify({ priceId }),
});
}
async createPortalSession() {
return this.request<{ url: string }>('/payments/create-portal-session', {
method: 'POST',
});
}
// Search
async searchCourses(query: string, filters?: { category?: string; difficulty?: string }) {
const searchParams = new URLSearchParams({ q: query });
if (filters?.category) searchParams.set('category', filters.category);
if (filters?.difficulty) searchParams.set('difficulty', filters.difficulty);
return this.request<{ hits: any[]; totalHits: number }>(`/search?${searchParams}`);
}
}
export const api = new ApiClient();

View File

@ -0,0 +1,32 @@
import { createServerClient, type CookieOptions } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function createServerSupabaseClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
try {
cookieStore.set({ name, value, ...options });
} catch (error) {
// Ignore errors in Server Components
}
},
remove(name: string, options: CookieOptions) {
try {
cookieStore.set({ name, value: '', ...options });
} catch (error) {
// Ignore errors in Server Components
}
},
},
}
);
}

View File

@ -0,0 +1,18 @@
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
// Singleton instance for client-side usage
let supabaseInstance: ReturnType<typeof createClient> | null = null;
export function getSupabase() {
if (!supabaseInstance) {
supabaseInstance = createClient();
}
return supabaseInstance;
}

View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1,80 @@
import { createServerClient, type CookieOptions } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return request.cookies.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
request.cookies.set({
name,
value,
...options,
});
response = NextResponse.next({
request: {
headers: request.headers,
},
});
response.cookies.set({
name,
value,
...options,
});
},
remove(name: string, options: CookieOptions) {
request.cookies.set({
name,
value: '',
...options,
});
response = NextResponse.next({
request: {
headers: request.headers,
},
});
response.cookies.set({
name,
value: '',
...options,
});
},
},
}
);
const {
data: { session },
} = await supabase.auth.getSession();
// Protect dashboard routes
if (request.nextUrl.pathname.startsWith('/dashboard')) {
if (!session) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
// Redirect logged-in users away from auth pages
if (request.nextUrl.pathname.startsWith('/login') || request.nextUrl.pathname.startsWith('/register')) {
if (session) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
}
return response;
}
export const config = {
matcher: ['/dashboard/:path*', '/login', '/register'],
};