project init
This commit is contained in:
13
apps/web/src/app/(auth)/layout.tsx
Normal file
13
apps/web/src/app/(auth)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
apps/web/src/app/(auth)/login/page.tsx
Normal file
133
apps/web/src/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
179
apps/web/src/app/(auth)/register/page.tsx
Normal file
179
apps/web/src/app/(auth)/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
apps/web/src/app/(dashboard)/dashboard/billing/page.tsx
Normal file
139
apps/web/src/app/(dashboard)/dashboard/billing/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
235
apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx
Normal file
235
apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
512
apps/web/src/app/(dashboard)/dashboard/courses/new/page.tsx
Normal file
512
apps/web/src/app/(dashboard)/dashboard/courses/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
apps/web/src/app/(dashboard)/dashboard/page.tsx
Normal file
139
apps/web/src/app/(dashboard)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
apps/web/src/app/(dashboard)/dashboard/settings/page.tsx
Normal file
137
apps/web/src/app/(dashboard)/dashboard/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/web/src/app/(dashboard)/layout.tsx
Normal file
18
apps/web/src/app/(dashboard)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
apps/web/src/app/globals.css
Normal file
106
apps/web/src/app/globals.css
Normal 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);
|
||||
}
|
||||
45
apps/web/src/app/layout.tsx
Normal file
45
apps/web/src/app/layout.tsx
Normal 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
23
apps/web/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
apps/web/src/components/dashboard/course-card.tsx
Normal file
199
apps/web/src/components/dashboard/course-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
apps/web/src/components/dashboard/header.tsx
Normal file
95
apps/web/src/components/dashboard/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
apps/web/src/components/dashboard/sidebar.tsx
Normal file
98
apps/web/src/components/dashboard/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
394
apps/web/src/components/editor/course-editor.tsx
Normal file
394
apps/web/src/components/editor/course-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
apps/web/src/components/editor/lesson-content-viewer.tsx
Normal file
70
apps/web/src/components/editor/lesson-content-viewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
apps/web/src/components/editor/lesson-sidebar.tsx
Normal file
118
apps/web/src/components/editor/lesson-sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
apps/web/src/components/landing/faq.tsx
Normal file
103
apps/web/src/components/landing/faq.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
apps/web/src/components/landing/features.tsx
Normal file
111
apps/web/src/components/landing/features.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
apps/web/src/components/landing/footer.tsx
Normal file
99
apps/web/src/components/landing/footer.tsx
Normal 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">
|
||||
© {new Date().getFullYear()} CourseCraft. Все права защищены.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
144
apps/web/src/components/landing/header.tsx
Normal file
144
apps/web/src/components/landing/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
apps/web/src/components/landing/hero.tsx
Normal file
105
apps/web/src/components/landing/hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
apps/web/src/components/landing/how-it-works.tsx
Normal file
82
apps/web/src/components/landing/how-it-works.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
apps/web/src/components/landing/pricing.tsx
Normal file
108
apps/web/src/components/landing/pricing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
apps/web/src/components/providers/theme-provider.tsx
Normal file
9
apps/web/src/components/providers/theme-provider.tsx
Normal 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>;
|
||||
}
|
||||
126
apps/web/src/components/ui/alert-dialog.tsx
Normal file
126
apps/web/src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
};
|
||||
50
apps/web/src/components/ui/avatar.tsx
Normal file
50
apps/web/src/components/ui/avatar.tsx
Normal 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 };
|
||||
50
apps/web/src/components/ui/button.tsx
Normal file
50
apps/web/src/components/ui/button.tsx
Normal 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 };
|
||||
56
apps/web/src/components/ui/card.tsx
Normal file
56
apps/web/src/components/ui/card.tsx
Normal 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 };
|
||||
187
apps/web/src/components/ui/dropdown-menu.tsx
Normal file
187
apps/web/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
};
|
||||
24
apps/web/src/components/ui/input.tsx
Normal file
24
apps/web/src/components/ui/input.tsx
Normal 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 };
|
||||
21
apps/web/src/components/ui/label.tsx
Normal file
21
apps/web/src/components/ui/label.tsx
Normal 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 };
|
||||
25
apps/web/src/components/ui/progress.tsx
Normal file
25
apps/web/src/components/ui/progress.tsx
Normal 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 };
|
||||
124
apps/web/src/components/ui/toast.tsx
Normal file
124
apps/web/src/components/ui/toast.tsx
Normal 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,
|
||||
};
|
||||
33
apps/web/src/components/ui/toaster.tsx
Normal file
33
apps/web/src/components/ui/toaster.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
apps/web/src/components/ui/use-toast.ts
Normal file
188
apps/web/src/components/ui/use-toast.ts
Normal 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 };
|
||||
160
apps/web/src/contexts/auth-context.tsx
Normal file
160
apps/web/src/contexts/auth-context.tsx
Normal 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
240
apps/web/src/lib/api.ts
Normal 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();
|
||||
32
apps/web/src/lib/supabase-server.ts
Normal file
32
apps/web/src/lib/supabase-server.ts
Normal 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
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
18
apps/web/src/lib/supabase.ts
Normal file
18
apps/web/src/lib/supabase.ts
Normal 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;
|
||||
}
|
||||
6
apps/web/src/lib/utils.ts
Normal file
6
apps/web/src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
80
apps/web/src/middleware.ts
Normal file
80
apps/web/src/middleware.ts
Normal 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'],
|
||||
};
|
||||
Reference in New Issue
Block a user