feat: restore landing style and add separate courses/admin UX
This commit is contained in:
@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Post, Param, Body } from '@nestjs/common';
|
import { Controller, Get, Post, Param, Body, Query, Delete, HttpCode, HttpStatus } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { ModerationService } from './moderation.service';
|
import { ModerationService } from './moderation.service';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
@ -15,6 +15,15 @@ export class ModerationController {
|
|||||||
return this.moderationService.getPendingCourses(user.id);
|
return this.moderationService.getPendingCourses(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('courses')
|
||||||
|
async getCourses(
|
||||||
|
@CurrentUser() user: User,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('search') search?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
return this.moderationService.getCourses(user.id, { status, search });
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':courseId/approve')
|
@Post(':courseId/approve')
|
||||||
async approveCourse(@Param('courseId') courseId: string, @Body('note') note: string, @CurrentUser() user: User): Promise<any> {
|
async approveCourse(@Param('courseId') courseId: string, @Body('note') note: string, @CurrentUser() user: User): Promise<any> {
|
||||||
return this.moderationService.approveCourse(user.id, courseId, note);
|
return this.moderationService.approveCourse(user.id, courseId, note);
|
||||||
@ -34,4 +43,10 @@ export class ModerationController {
|
|||||||
async unhideReview(@Param('reviewId') reviewId: string, @CurrentUser() user: User): Promise<any> {
|
async unhideReview(@Param('reviewId') reviewId: string, @CurrentUser() user: User): Promise<any> {
|
||||||
return this.moderationService.unhideReview(user.id, reviewId);
|
return this.moderationService.unhideReview(user.id, reviewId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':courseId')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async deleteCourse(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<void> {
|
||||||
|
await this.moderationService.deleteCourse(user.id, courseId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Injectable, ForbiddenException } from '@nestjs/common';
|
import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../common/prisma/prisma.service';
|
import { PrismaService } from '../common/prisma/prisma.service';
|
||||||
import { CourseStatus, UserRole } from '@coursecraft/database';
|
import { CourseStatus, UserRole } from '@coursecraft/database';
|
||||||
|
|
||||||
@ -22,6 +22,48 @@ export class ModerationService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCourses(
|
||||||
|
userId: string,
|
||||||
|
options?: {
|
||||||
|
status?: string;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
): Promise<any[]> {
|
||||||
|
await this.assertStaff(userId);
|
||||||
|
|
||||||
|
const allowedStatuses = Object.values(CourseStatus);
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (options?.status && allowedStatuses.includes(options.status as CourseStatus)) {
|
||||||
|
where.status = options.status as CourseStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.search?.trim()) {
|
||||||
|
where.OR = [
|
||||||
|
{ title: { contains: options.search.trim(), mode: 'insensitive' } },
|
||||||
|
{ description: { contains: options.search.trim(), mode: 'insensitive' } },
|
||||||
|
{ author: { name: { contains: options.search.trim(), mode: 'insensitive' } } },
|
||||||
|
{ author: { email: { contains: options.search.trim(), mode: 'insensitive' } } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.course.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, email: true } },
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
chapters: true,
|
||||||
|
enrollments: true,
|
||||||
|
reviews: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }],
|
||||||
|
take: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async approveCourse(userId: string, courseId: string, note?: string): Promise<any> {
|
async approveCourse(userId: string, courseId: string, note?: string): Promise<any> {
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) {
|
if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) {
|
||||||
@ -98,6 +140,19 @@ export class ModerationService {
|
|||||||
return review;
|
return review;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteCourse(userId: string, courseId: string): Promise<void> {
|
||||||
|
await this.assertStaff(userId);
|
||||||
|
const existing = await this.prisma.course.findUnique({
|
||||||
|
where: { id: courseId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Course not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.course.delete({ where: { id: courseId } });
|
||||||
|
}
|
||||||
|
|
||||||
private async assertStaff(userId: string): Promise<void> {
|
private async assertStaff(userId: string): Promise<void> {
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) {
|
if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) {
|
||||||
|
|||||||
5
apps/web/src/app/(dashboard)/dashboard/admin/page.tsx
Normal file
5
apps/web/src/app/(dashboard)/dashboard/admin/page.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function DashboardAdminRedirectPage() {
|
||||||
|
redirect('/admin');
|
||||||
|
}
|
||||||
@ -1,147 +1,5 @@
|
|||||||
'use client';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
export default function DashboardAdminSupportRedirectPage() {
|
||||||
import { io, Socket } from 'socket.io-client';
|
redirect('/admin/support');
|
||||||
import { Send } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { getWsBaseUrl } from '@/lib/ws';
|
|
||||||
|
|
||||||
export default function AdminSupportPage() {
|
|
||||||
const [tickets, setTickets] = useState<any[]>([]);
|
|
||||||
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null);
|
|
||||||
const [messages, setMessages] = useState<any[]>([]);
|
|
||||||
const [status, setStatus] = useState('in_progress');
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
const socketRef = useRef<Socket | null>(null);
|
|
||||||
|
|
||||||
const selected = useMemo(
|
|
||||||
() => tickets.find((ticket) => ticket.id === selectedTicketId) || null,
|
|
||||||
[tickets, selectedTicketId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadTickets = async () => {
|
|
||||||
const data = await api.getAdminSupportTickets().catch(() => []);
|
|
||||||
setTickets(data);
|
|
||||||
if (!selectedTicketId && data.length > 0) {
|
|
||||||
setSelectedTicketId(data[0].id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadTickets();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedTicketId) return;
|
|
||||||
api
|
|
||||||
.getAdminSupportTicketMessages(selectedTicketId)
|
|
||||||
.then((data) => setMessages(data))
|
|
||||||
.catch(() => setMessages([]));
|
|
||||||
|
|
||||||
const token =
|
|
||||||
typeof window !== 'undefined' ? sessionStorage.getItem('coursecraft_api_token') || undefined : undefined;
|
|
||||||
const socket = io(`${getWsBaseUrl()}/ws/support`, {
|
|
||||||
transports: ['websocket'],
|
|
||||||
auth: { token },
|
|
||||||
});
|
|
||||||
socketRef.current = socket;
|
|
||||||
socket.emit('support:join', { ticketId: selectedTicketId });
|
|
||||||
socket.on('support:new-message', (msg: any) => {
|
|
||||||
setMessages((prev) => [...prev, msg]);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
socket.disconnect();
|
|
||||||
socketRef.current = null;
|
|
||||||
};
|
|
||||||
}, [selectedTicketId]);
|
|
||||||
|
|
||||||
const sendReply = async () => {
|
|
||||||
if (!selectedTicketId || !message.trim()) return;
|
|
||||||
await api.sendAdminSupportMessage(selectedTicketId, message.trim());
|
|
||||||
setMessage('');
|
|
||||||
const list = await api.getAdminSupportTicketMessages(selectedTicketId).catch(() => []);
|
|
||||||
setMessages(list);
|
|
||||||
await loadTickets();
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateStatus = async () => {
|
|
||||||
if (!selectedTicketId) return;
|
|
||||||
await api.updateAdminSupportTicketStatus(selectedTicketId, status);
|
|
||||||
await loadTickets();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Админ: тикеты поддержки</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
{tickets.map((ticket) => (
|
|
||||||
<button
|
|
||||||
key={ticket.id}
|
|
||||||
className={`w-full rounded-md border px-3 py-2 text-left text-sm ${
|
|
||||||
selectedTicketId === ticket.id ? 'border-primary bg-primary/5' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedTicketId(ticket.id)}
|
|
||||||
>
|
|
||||||
<p className="font-medium">{ticket.title}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{ticket.user?.name || ticket.user?.email} • {ticket.status}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="min-h-[520px]">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{selected ? `Тикет: ${selected.title}` : 'Выберите тикет'}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex h-[430px] flex-col">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<select
|
|
||||||
className="rounded-md border bg-background px-3 py-2 text-sm"
|
|
||||||
value={status}
|
|
||||||
onChange={(e) => setStatus(e.target.value)}
|
|
||||||
disabled={!selectedTicketId}
|
|
||||||
>
|
|
||||||
<option value="open">open</option>
|
|
||||||
<option value="in_progress">in_progress</option>
|
|
||||||
<option value="resolved">resolved</option>
|
|
||||||
<option value="closed">closed</option>
|
|
||||||
</select>
|
|
||||||
<Button variant="outline" onClick={updateStatus} disabled={!selectedTicketId}>
|
|
||||||
Обновить статус
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto space-y-2 pr-2">
|
|
||||||
{messages.map((msg) => (
|
|
||||||
<div key={msg.id} className="rounded-md border p-2 text-sm">
|
|
||||||
<p className="font-medium">
|
|
||||||
{msg.user?.name || 'Пользователь'} {msg.isStaff ? '(Поддержка)' : ''}
|
|
||||||
</p>
|
|
||||||
<p>{msg.content}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 flex gap-2">
|
|
||||||
<input
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
placeholder="Ответ поддержки"
|
|
||||||
className="flex-1 rounded-md border bg-background px-3 py-2 text-sm"
|
|
||||||
/>
|
|
||||||
<Button onClick={sendReply} disabled={!selectedTicketId}>
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,375 +1,5 @@
|
|||||||
'use client';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
export default function DashboardCatalogCourseRedirectPage({ params }: { params: { id: string } }) {
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
redirect(`/courses/${params.id}`);
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
BookOpen,
|
|
||||||
Clock,
|
|
||||||
Users,
|
|
||||||
Star,
|
|
||||||
Shield,
|
|
||||||
Check,
|
|
||||||
Loader2,
|
|
||||||
ChevronDown,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { useAuth } from '@/contexts/auth-context';
|
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
export default function PublicCoursePage() {
|
|
||||||
const params = useParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { user } = useAuth();
|
|
||||||
const id = params?.id as string;
|
|
||||||
|
|
||||||
const [course, setCourse] = useState<any>(null);
|
|
||||||
const [reviews, setReviews] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [enrolling, setEnrolling] = useState(false);
|
|
||||||
const [enrolled, setEnrolled] = useState(false);
|
|
||||||
const [expandedChapters, setExpandedChapters] = useState<string[]>([]);
|
|
||||||
const [submittingReview, setSubmittingReview] = useState(false);
|
|
||||||
const [reviewRating, setReviewRating] = useState(5);
|
|
||||||
const [reviewTitle, setReviewTitle] = useState('');
|
|
||||||
const [reviewContent, setReviewContent] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!id) return;
|
|
||||||
(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [courseData, reviewsData] = await Promise.all([
|
|
||||||
api.getPublicCourse(id),
|
|
||||||
api.getCourseReviews(id).catch(() => ({ data: [] })),
|
|
||||||
]);
|
|
||||||
setCourse(courseData);
|
|
||||||
setReviews(reviewsData.data || []);
|
|
||||||
|
|
||||||
// Check if already enrolled
|
|
||||||
if (user) {
|
|
||||||
const enrollments = await api.getMyEnrollments().catch(() => []);
|
|
||||||
setEnrolled(enrollments.some((e: any) => e.course.id === id));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setCourse(null);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [id, user]);
|
|
||||||
|
|
||||||
const handleEnroll = async () => {
|
|
||||||
if (!id || enrolling) return;
|
|
||||||
setEnrolling(true);
|
|
||||||
try {
|
|
||||||
if (course?.price) {
|
|
||||||
const session = await api.checkoutCourse(id);
|
|
||||||
if (session?.url) {
|
|
||||||
window.location.href = session.url;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await api.enrollInCourse(id);
|
|
||||||
toast({ title: 'Успех', description: 'Вы записались на курс' });
|
|
||||||
router.push(`/dashboard/courses/${id}`);
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
toast({ title: 'Ошибка', description: e.message, variant: 'destructive' });
|
|
||||||
} finally {
|
|
||||||
setEnrolling(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmitReview = async () => {
|
|
||||||
if (!id || !enrolled || submittingReview) return;
|
|
||||||
setSubmittingReview(true);
|
|
||||||
try {
|
|
||||||
await api.createReview(id, {
|
|
||||||
rating: reviewRating,
|
|
||||||
title: reviewTitle || undefined,
|
|
||||||
content: reviewContent || undefined,
|
|
||||||
});
|
|
||||||
const reviewsData = await api.getCourseReviews(id).catch(() => ({ data: [] }));
|
|
||||||
setReviews(reviewsData.data || []);
|
|
||||||
setReviewTitle('');
|
|
||||||
setReviewContent('');
|
|
||||||
toast({ title: 'Спасибо!', description: 'Ваш отзыв опубликован' });
|
|
||||||
} catch (e: any) {
|
|
||||||
toast({ title: 'Ошибка', description: e.message, variant: 'destructive' });
|
|
||||||
} finally {
|
|
||||||
setSubmittingReview(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!course) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-20">
|
|
||||||
<p className="text-muted-foreground">Курс не найден</p>
|
|
||||||
<Link href="/dashboard/catalog" className="text-primary hover:underline mt-2 inline-block">
|
|
||||||
Вернуться к каталогу
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalLessons = course.chapters.reduce((acc: number, ch: any) => acc + ch.lessons.length, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-5xl mx-auto space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<Button variant="ghost" size="sm" asChild>
|
|
||||||
<Link href="/dashboard/catalog">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
К каталогу
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Course header */}
|
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
|
||||||
<div className="md:col-span-2 space-y-4">
|
|
||||||
{/* Cover */}
|
|
||||||
<div className="aspect-video rounded-xl bg-gradient-to-br from-primary/20 to-primary/5 overflow-hidden">
|
|
||||||
{course.coverImage ? (
|
|
||||||
<img src={course.coverImage} alt={course.title} className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<BookOpen className="h-16 w-16 text-primary/30" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title & meta */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
{course.isVerified && (
|
|
||||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-600 text-white text-xs font-medium">
|
|
||||||
<Shield className="h-3 w-3" />
|
|
||||||
Проверен автором
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{course.difficulty && (
|
|
||||||
<span className="px-2 py-0.5 rounded-full bg-muted text-xs font-medium">
|
|
||||||
{course.difficulty === 'beginner' ? 'Начинающий' : course.difficulty === 'intermediate' ? 'Средний' : 'Продвинутый'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-bold">{course.title}</h1>
|
|
||||||
<p className="text-muted-foreground mt-2">{course.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="flex items-center gap-4 text-sm">
|
|
||||||
{course.averageRating && (
|
|
||||||
<div className="flex items-center gap-1 text-yellow-600 font-medium">
|
|
||||||
<Star className="h-4 w-4 fill-current" />
|
|
||||||
{course.averageRating.toFixed(1)} ({course._count.reviews} отзывов)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="flex items-center gap-1 text-muted-foreground">
|
|
||||||
<Users className="h-4 w-4" />
|
|
||||||
{course._count.enrollments} студентов
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">{totalLessons} уроков</span>
|
|
||||||
{course.estimatedHours && (
|
|
||||||
<span className="flex items-center gap-1 text-muted-foreground">
|
|
||||||
<Clock className="h-4 w-4" />
|
|
||||||
{course.estimatedHours}ч
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-px bg-border my-4" />
|
|
||||||
|
|
||||||
{/* Author */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center font-bold text-primary">
|
|
||||||
{course.author.name?.[0] || 'A'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">{course.author.name || 'Автор'}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Преподаватель</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar: Enroll / Price */}
|
|
||||||
<div className="md:col-span-1">
|
|
||||||
<Card className="sticky top-4">
|
|
||||||
<CardContent className="p-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-3xl font-bold">
|
|
||||||
{course.price ? `${course.price} ${course.currency}` : 'Бесплатно'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{enrolled ? (
|
|
||||||
<Button className="w-full" asChild>
|
|
||||||
<Link href={`/dashboard/courses/${id}`}>Продолжить обучение</Link>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button className="w-full" onClick={handleEnroll} disabled={enrolling}>
|
|
||||||
{enrolling ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
|
||||||
{course.price ? 'Купить курс' : 'Добавить в библиотеку'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
|
||||||
<span>{course.chapters.length} глав</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
|
||||||
<span>{totalLessons} видеоуроков</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
|
||||||
<span>Сертификат по окончании</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
|
||||||
<span>Пожизненный доступ</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Course content (chapters & lessons) */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Содержание курса</h2>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{course.chapters.map((chapter: any) => {
|
|
||||||
const expanded = expandedChapters.includes(chapter.id);
|
|
||||||
const toggleExpanded = () => {
|
|
||||||
setExpandedChapters(prev =>
|
|
||||||
prev.includes(chapter.id)
|
|
||||||
? prev.filter(id => id !== chapter.id)
|
|
||||||
: [...prev, chapter.id]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div key={chapter.id} className="border rounded-lg overflow-hidden">
|
|
||||||
<button
|
|
||||||
className="flex items-center justify-between w-full p-4 hover:bg-muted/50 transition-colors"
|
|
||||||
onClick={toggleExpanded}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<BookOpen className="h-4 w-4 text-primary" />
|
|
||||||
<span className="font-medium">{chapter.title}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<span>{chapter.lessons.length} уроков</span>
|
|
||||||
<ChevronDown className={cn('h-4 w-4 transition-transform', expanded && 'rotate-180')} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{expanded && (
|
|
||||||
<div className="border-t bg-muted/20 p-2">
|
|
||||||
{chapter.lessons.map((lesson: any) => (
|
|
||||||
<div key={lesson.id} className="flex items-center gap-2 px-3 py-2 text-sm">
|
|
||||||
<Check className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
<span>{lesson.title}</span>
|
|
||||||
{lesson.durationMinutes && (
|
|
||||||
<span className="ml-auto text-xs text-muted-foreground">{lesson.durationMinutes} мин</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Reviews */}
|
|
||||||
{reviews.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Отзывы студентов</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{reviews.map((review: any) => (
|
|
||||||
<div key={review.id} className="border-b last:border-b-0 pb-4 last:pb-0">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center font-bold text-primary text-sm">
|
|
||||||
{review.user.name?.[0] || 'U'}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-medium text-sm">{review.user.name || 'Пользователь'}</p>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<Star
|
|
||||||
key={i}
|
|
||||||
className={cn(
|
|
||||||
'h-3.5 w-3.5',
|
|
||||||
i < review.rating ? 'text-yellow-500 fill-current' : 'text-muted-foreground/30'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{review.title && <p className="font-medium text-sm mb-1">{review.title}</p>}
|
|
||||||
{review.content && <p className="text-sm text-muted-foreground">{review.content}</p>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{enrolled && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Оцените курс</h2>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<button key={i} onClick={() => setReviewRating(i + 1)} className="p-1">
|
|
||||||
<Star
|
|
||||||
className={cn(
|
|
||||||
'h-5 w-5',
|
|
||||||
i < reviewRating ? 'text-yellow-500 fill-current' : 'text-muted-foreground/30'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
value={reviewTitle}
|
|
||||||
onChange={(e) => setReviewTitle(e.target.value)}
|
|
||||||
placeholder="Заголовок отзыва"
|
|
||||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
value={reviewContent}
|
|
||||||
onChange={(e) => setReviewContent(e.target.value)}
|
|
||||||
placeholder="Что вам понравилось в курсе?"
|
|
||||||
className="w-full min-h-[120px] rounded-md border bg-background p-3 text-sm"
|
|
||||||
/>
|
|
||||||
<Button onClick={handleSubmitReview} disabled={submittingReview}>
|
|
||||||
{submittingReview ? 'Публикация...' : 'Оставить отзыв'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,185 +1,5 @@
|
|||||||
'use client';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
export default function DashboardCatalogRedirectPage() {
|
||||||
import Link from 'next/link';
|
redirect('/courses');
|
||||||
import { Search, Star, Users, BookOpen, Shield, Loader2 } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { useAuth } from '@/contexts/auth-context';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface CatalogCourse {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string | null;
|
|
||||||
coverImage: string | null;
|
|
||||||
difficulty: string | null;
|
|
||||||
price: number | null;
|
|
||||||
currency: string;
|
|
||||||
isVerified: boolean;
|
|
||||||
averageRating: number | null;
|
|
||||||
enrollmentCount: number;
|
|
||||||
reviewCount: number;
|
|
||||||
chaptersCount: number;
|
|
||||||
lessonsCount: number;
|
|
||||||
author: { id: string; name: string | null; avatarUrl: string | null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const difficultyLabels: Record<string, { label: string; color: string }> = {
|
|
||||||
beginner: { label: 'Начинающий', color: 'text-green-600 bg-green-50 dark:bg-green-900/30' },
|
|
||||||
intermediate: { label: 'Средний', color: 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/30' },
|
|
||||||
advanced: { label: 'Продвинутый', color: 'text-red-600 bg-red-50 dark:bg-red-900/30' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CatalogPage() {
|
|
||||||
const { loading: authLoading } = useAuth();
|
|
||||||
const [courses, setCourses] = useState<CatalogCourse[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [difficulty, setDifficulty] = useState('');
|
|
||||||
|
|
||||||
const loadCourses = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await api.getCatalog({
|
|
||||||
search: searchQuery || undefined,
|
|
||||||
difficulty: difficulty || undefined,
|
|
||||||
});
|
|
||||||
setCourses(result.data);
|
|
||||||
} catch {
|
|
||||||
// silent
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (authLoading) return;
|
|
||||||
loadCourses();
|
|
||||||
}, [authLoading, difficulty]);
|
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
loadCourses();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Каталог курсов</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">Изучайте курсы от других авторов</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search & filters */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
|
||||||
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Поиск курсов..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="w-full pl-10 pr-4 py-2 border rounded-lg bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" size="sm">Найти</Button>
|
|
||||||
</form>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{['', 'beginner', 'intermediate', 'advanced'].map((d) => (
|
|
||||||
<Button
|
|
||||||
key={d}
|
|
||||||
variant={difficulty === d ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setDifficulty(d)}
|
|
||||||
>
|
|
||||||
{d ? difficultyLabels[d]?.label : 'Все'}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Courses grid */}
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex justify-center py-20">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : courses.length === 0 ? (
|
|
||||||
<div className="text-center py-20">
|
|
||||||
<BookOpen className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
|
|
||||||
<p className="text-muted-foreground">Пока нет опубликованных курсов</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{courses.map((course) => (
|
|
||||||
<Link key={course.id} href={`/dashboard/catalog/${course.id}`}>
|
|
||||||
<Card className="group overflow-hidden transition-all hover:shadow-lg hover:-translate-y-0.5 cursor-pointer h-full">
|
|
||||||
{/* Cover image */}
|
|
||||||
<div className="aspect-video bg-gradient-to-br from-primary/20 to-primary/5 relative overflow-hidden">
|
|
||||||
{course.coverImage ? (
|
|
||||||
<img src={course.coverImage} alt="" className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<BookOpen className="h-10 w-10 text-primary/30" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{course.isVerified && (
|
|
||||||
<div className="absolute top-2 right-2 flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-600 text-white text-xs font-medium">
|
|
||||||
<Shield className="h-3 w-3" />
|
|
||||||
Проверен
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{course.difficulty && difficultyLabels[course.difficulty] && (
|
|
||||||
<div className={cn(
|
|
||||||
'absolute top-2 left-2 px-2 py-0.5 rounded-full text-xs font-medium',
|
|
||||||
difficultyLabels[course.difficulty].color
|
|
||||||
)}>
|
|
||||||
{difficultyLabels[course.difficulty].label}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<h3 className="font-semibold line-clamp-1 group-hover:text-primary transition-colors">
|
|
||||||
{course.title}
|
|
||||||
</h3>
|
|
||||||
{course.description && (
|
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">{course.description}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Author */}
|
|
||||||
<p className="text-xs text-muted-foreground mt-3">
|
|
||||||
{course.author.name || 'Автор'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="flex items-center gap-3 mt-3 text-xs text-muted-foreground">
|
|
||||||
{course.averageRating && (
|
|
||||||
<span className="flex items-center gap-0.5 text-yellow-600 font-medium">
|
|
||||||
<Star className="h-3.5 w-3.5 fill-current" />
|
|
||||||
{course.averageRating.toFixed(1)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="flex items-center gap-0.5">
|
|
||||||
<Users className="h-3.5 w-3.5" />
|
|
||||||
{course.enrollmentCount}
|
|
||||||
</span>
|
|
||||||
<span>{course.lessonsCount} уроков</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Price */}
|
|
||||||
<div className="mt-3 pt-3 border-t">
|
|
||||||
<span className="font-bold text-lg">
|
|
||||||
{course.price ? `${course.price} ${course.currency}` : 'Бесплатно'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { ChevronLeft, ChevronRight, Save, Wand2, Eye, Pencil, Upload, Shield } from 'lucide-react';
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
Layers3,
|
||||||
|
Lock,
|
||||||
|
Save,
|
||||||
|
Settings2,
|
||||||
|
Shield,
|
||||||
|
Upload,
|
||||||
|
Wallet,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { CourseEditor } from '@/components/editor/course-editor';
|
import { CourseEditor } from '@/components/editor/course-editor';
|
||||||
import { LessonContentViewer } from '@/components/editor/lesson-content-viewer';
|
import { LessonContentViewer } from '@/components/editor/lesson-content-viewer';
|
||||||
import { LessonSidebar } from '@/components/editor/lesson-sidebar';
|
import { LessonSidebar } from '@/components/editor/lesson-sidebar';
|
||||||
@ -24,10 +37,22 @@ type CourseData = {
|
|||||||
currency?: string;
|
currency?: string;
|
||||||
status: 'DRAFT' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED' | string;
|
status: 'DRAFT' | 'PENDING_REVIEW' | 'PUBLISHED' | 'REJECTED' | string;
|
||||||
moderationNote?: string | null;
|
moderationNote?: string | null;
|
||||||
|
difficulty?: string | null;
|
||||||
|
estimatedHours?: number | null;
|
||||||
|
tags?: string[];
|
||||||
chapters: Chapter[];
|
chapters: Chapter[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EditTab = 'general' | 'content' | 'pricing' | 'settings' | 'access';
|
||||||
|
|
||||||
const emptyDoc = { type: 'doc', content: [] };
|
const emptyDoc = { type: 'doc', content: [] };
|
||||||
|
const tabs: { key: EditTab; label: string; icon: any }[] = [
|
||||||
|
{ key: 'general', label: 'Общая информация', icon: FileText },
|
||||||
|
{ key: 'content', label: 'Контент', icon: Layers3 },
|
||||||
|
{ key: 'pricing', label: 'Цены', icon: Wallet },
|
||||||
|
{ key: 'settings', label: 'Настройки', icon: Settings2 },
|
||||||
|
{ key: 'access', label: 'Доступ', icon: Lock },
|
||||||
|
];
|
||||||
|
|
||||||
export default function CourseEditPage() {
|
export default function CourseEditPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@ -39,41 +64,52 @@ export default function CourseEditPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<EditTab>('general');
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [activeLesson, setActiveLesson] = useState<{ chapterId: string; lessonId: string } | null>(null);
|
const [activeLesson, setActiveLesson] = useState<{ chapterId: string; lessonId: string } | null>(null);
|
||||||
const [content, setContent] = useState<Record<string, unknown>>(emptyDoc);
|
const [content, setContent] = useState<Record<string, unknown>>(emptyDoc);
|
||||||
const [contentLoading, setContentLoading] = useState(false);
|
const [contentLoading, setContentLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [readOnly, setReadOnly] = useState(false);
|
const [savingLesson, setSavingLesson] = useState(false);
|
||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
const [verifying, setVerifying] = useState(false);
|
const [verifying, setVerifying] = useState(false);
|
||||||
const [savingMeta, setSavingMeta] = useState(false);
|
const [savingMeta, setSavingMeta] = useState(false);
|
||||||
|
const [readOnly, setReadOnly] = useState(false);
|
||||||
|
|
||||||
const [courseTitle, setCourseTitle] = useState('');
|
const [courseTitle, setCourseTitle] = useState('');
|
||||||
const [courseDescription, setCourseDescription] = useState('');
|
const [courseDescription, setCourseDescription] = useState('');
|
||||||
const [courseCover, setCourseCover] = useState('');
|
const [courseCover, setCourseCover] = useState('');
|
||||||
const [coursePrice, setCoursePrice] = useState('');
|
const [coursePrice, setCoursePrice] = useState('');
|
||||||
const [courseCurrency, setCourseCurrency] = useState('USD');
|
const [courseCurrency, setCourseCurrency] = useState('USD');
|
||||||
|
const [courseDifficulty, setCourseDifficulty] = useState('');
|
||||||
|
const [courseEstimatedHours, setCourseEstimatedHours] = useState('');
|
||||||
|
const [courseTags, setCourseTags] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!courseId || authLoading) return;
|
if (!courseId || authLoading) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await api.getCourse(courseId);
|
const data = await api.getCourse(courseId);
|
||||||
if (!cancelled) {
|
if (cancelled) return;
|
||||||
setCourse(data);
|
|
||||||
setCourseTitle(data.title || '');
|
setCourse(data);
|
||||||
setCourseDescription(data.description || '');
|
setCourseTitle(data.title || '');
|
||||||
setCourseCover(data.coverImage || '');
|
setCourseDescription(data.description || '');
|
||||||
setCoursePrice(data.price ? String(data.price) : '');
|
setCourseCover(data.coverImage || '');
|
||||||
setCourseCurrency(data.currency || 'USD');
|
setCoursePrice(data.price ? String(data.price) : '');
|
||||||
const firstChapter = data.chapters?.[0];
|
setCourseCurrency(data.currency || 'USD');
|
||||||
const firstLesson = firstChapter?.lessons?.[0];
|
setCourseDifficulty(data.difficulty || '');
|
||||||
if (firstChapter && firstLesson) {
|
setCourseEstimatedHours(data.estimatedHours ? String(data.estimatedHours) : '');
|
||||||
setActiveLesson({ chapterId: firstChapter.id, lessonId: firstLesson.id });
|
setCourseTags(Array.isArray(data.tags) ? data.tags.join(', ') : '');
|
||||||
}
|
|
||||||
|
const firstChapter = data.chapters?.[0];
|
||||||
|
const firstLesson = firstChapter?.lessons?.[0];
|
||||||
|
if (firstChapter && firstLesson) {
|
||||||
|
setActiveLesson({ chapterId: firstChapter.id, lessonId: firstLesson.id });
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (!cancelled) setError(e?.message || 'Не удалось загрузить курс');
|
if (!cancelled) setError(e?.message || 'Не удалось загрузить курс');
|
||||||
@ -81,17 +117,21 @@ export default function CourseEditPage() {
|
|||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [courseId, authLoading]);
|
}, [courseId, authLoading]);
|
||||||
|
|
||||||
// Load lesson content when active lesson changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!courseId || !activeLesson) {
|
if (!courseId || !activeLesson) {
|
||||||
setContent(emptyDoc);
|
setContent(emptyDoc);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setContentLoading(true);
|
setContentLoading(true);
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const lessonData = await api.getLesson(courseId, activeLesson.lessonId);
|
const lessonData = await api.getLesson(courseId, activeLesson.lessonId);
|
||||||
@ -110,30 +150,58 @@ export default function CourseEditPage() {
|
|||||||
if (!cancelled) setContentLoading(false);
|
if (!cancelled) setContentLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [courseId, activeLesson?.lessonId]);
|
}, [courseId, activeLesson?.lessonId]);
|
||||||
|
|
||||||
const handleSelectLesson = (lessonId: string) => {
|
const handleSelectLesson = (lessonId: string) => {
|
||||||
if (!course) return;
|
if (!course) return;
|
||||||
for (const ch of course.chapters) {
|
for (const chapter of course.chapters) {
|
||||||
const lesson = ch.lessons.find((l) => l.id === lessonId);
|
const lesson = chapter.lessons.find((item) => item.id === lessonId);
|
||||||
if (lesson) {
|
if (lesson) {
|
||||||
setActiveLesson({ chapterId: ch.id, lessonId: lesson.id });
|
setActiveLesson({ chapterId: chapter.id, lessonId: lesson.id });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSaveLesson = async () => {
|
||||||
if (!courseId || !activeLesson || saving) return;
|
if (!courseId || !activeLesson || savingLesson) return;
|
||||||
setSaving(true);
|
setSavingLesson(true);
|
||||||
try {
|
try {
|
||||||
await api.updateLesson(courseId, activeLesson.lessonId, { content });
|
await api.updateLesson(courseId, activeLesson.lessonId, { content });
|
||||||
toast({ title: 'Сохранено', description: 'Изменения успешно сохранены' });
|
toast({ title: 'Сохранено', description: 'Контент урока сохранён' });
|
||||||
} catch (e: any) {
|
} catch {
|
||||||
toast({ title: 'Ошибка', description: 'Не удалось сохранить', variant: 'destructive' });
|
toast({ title: 'Ошибка', description: 'Не удалось сохранить контент', variant: 'destructive' });
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSavingLesson(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveMeta = async () => {
|
||||||
|
if (!courseId || savingMeta) return;
|
||||||
|
setSavingMeta(true);
|
||||||
|
try {
|
||||||
|
await api.updateCourse(courseId, {
|
||||||
|
title: courseTitle,
|
||||||
|
description: courseDescription || undefined,
|
||||||
|
coverImage: courseCover || undefined,
|
||||||
|
price: coursePrice ? Number(coursePrice) : 0,
|
||||||
|
currency: courseCurrency,
|
||||||
|
difficulty: courseDifficulty || undefined,
|
||||||
|
estimatedHours: courseEstimatedHours ? Number(courseEstimatedHours) : undefined,
|
||||||
|
tags: courseTags
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
});
|
||||||
|
toast({ title: 'Сохранено', description: 'Параметры курса обновлены' });
|
||||||
|
} catch (e: any) {
|
||||||
|
toast({ title: 'Ошибка', description: e.message, variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
|
setSavingMeta(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -151,25 +219,6 @@ export default function CourseEditPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveMeta = async () => {
|
|
||||||
if (!courseId || savingMeta) return;
|
|
||||||
setSavingMeta(true);
|
|
||||||
try {
|
|
||||||
await api.updateCourse(courseId, {
|
|
||||||
title: courseTitle,
|
|
||||||
description: courseDescription || undefined,
|
|
||||||
coverImage: courseCover || undefined,
|
|
||||||
price: coursePrice ? Number(coursePrice) : 0,
|
|
||||||
currency: courseCurrency,
|
|
||||||
});
|
|
||||||
toast({ title: 'Сохранено', description: 'Настройки курса обновлены' });
|
|
||||||
} catch (e: any) {
|
|
||||||
toast({ title: 'Ошибка', description: e.message, variant: 'destructive' });
|
|
||||||
} finally {
|
|
||||||
setSavingMeta(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleVerify = async () => {
|
const handleToggleVerify = async () => {
|
||||||
if (!courseId || verifying) return;
|
if (!courseId || verifying) return;
|
||||||
setVerifying(true);
|
setVerifying(true);
|
||||||
@ -186,7 +235,7 @@ export default function CourseEditPage() {
|
|||||||
|
|
||||||
if (authLoading || loading) {
|
if (authLoading || loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
|
<div className="flex min-h-[400px] items-center justify-center">
|
||||||
<p className="text-muted-foreground">Загрузка курса...</p>
|
<p className="text-muted-foreground">Загрузка курса...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -194,118 +243,151 @@ export default function CourseEditPage() {
|
|||||||
|
|
||||||
if (error || !course) {
|
if (error || !course) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
|
<div className="flex min-h-[400px] items-center justify-center">
|
||||||
<p className="text-destructive">{error || 'Курс не найден'}</p>
|
<p className="text-destructive">{error || 'Курс не найден'}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const flatLessons = course.chapters.flatMap((ch) =>
|
const flatLessons = course.chapters.flatMap((chapter) => chapter.lessons.map((lesson) => ({ ...lesson, chapterId: chapter.id })));
|
||||||
ch.lessons.map((l) => ({ ...l, chapterId: ch.id }))
|
const activeLessonMeta = activeLesson ? flatLessons.find((lesson) => lesson.id === activeLesson.lessonId) : null;
|
||||||
);
|
|
||||||
const activeLessonMeta = activeLesson
|
|
||||||
? flatLessons.find((l) => l.id === activeLesson.lessonId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-[calc(100vh-4rem)] -m-6">
|
<div className="space-y-4">
|
||||||
<div
|
<Card>
|
||||||
className={cn(
|
<CardHeader>
|
||||||
'border-r bg-muted/30 transition-all duration-300',
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
sidebarOpen ? 'w-72' : 'w-0'
|
<div>
|
||||||
)}
|
<CardTitle className="text-2xl">Редактор курса</CardTitle>
|
||||||
>
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
{sidebarOpen && (
|
{course.title} • Статус: {course.status}
|
||||||
<LessonSidebar
|
{course.moderationNote ? ` • Заметка модерации: ${course.moderationNote}` : ''}
|
||||||
course={course}
|
</p>
|
||||||
activeLesson={activeLesson?.lessonId ?? ''}
|
</div>
|
||||||
onSelectLesson={handleSelectLesson}
|
<Button variant="outline" asChild>
|
||||||
/>
|
<Link href={`/dashboard/courses/${courseId}`}>
|
||||||
)}
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
Просмотр курса
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Button
|
||||||
|
key={tab.key}
|
||||||
|
variant={activeTab === tab.key ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
>
|
||||||
|
<tab.icon className="mr-2 h-4 w-4" />
|
||||||
|
{tab.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{activeTab === 'general' && (
|
||||||
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"
|
<Card>
|
||||||
style={{ left: sidebarOpen ? '288px' : '0px' }}
|
<CardHeader>
|
||||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
<CardTitle>Общая информация</CardTitle>
|
||||||
>
|
</CardHeader>
|
||||||
{sidebarOpen ? (
|
<CardContent className="space-y-3">
|
||||||
<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">
|
|
||||||
<div>
|
|
||||||
<h2 className="font-medium truncate">{activeLessonMeta?.title ?? 'Выберите урок'}</h2>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Статус курса: {course.status}
|
|
||||||
{course.moderationNote ? ` • Заметка: ${course.moderationNote}` : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
<Button size="sm" variant="outline" onClick={handleToggleVerify} disabled={verifying}>
|
|
||||||
<Shield className="mr-2 h-4 w-4" />
|
|
||||||
{verifying ? 'Обработка...' : 'Верификация'}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="default" onClick={handlePublish} disabled={publishing}>
|
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
|
||||||
{publishing ? 'Отправка...' : 'Отправить на модерацию'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-b px-4 py-3 bg-muted/20">
|
|
||||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-5">
|
|
||||||
<input
|
<input
|
||||||
value={courseTitle}
|
value={courseTitle}
|
||||||
onChange={(e) => setCourseTitle(e.target.value)}
|
onChange={(e) => setCourseTitle(e.target.value)}
|
||||||
className="rounded-md border bg-background px-3 py-2 text-sm lg:col-span-2"
|
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
placeholder="Название курса"
|
placeholder="Название курса"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
value={courseCover}
|
value={courseCover}
|
||||||
onChange={(e) => setCourseCover(e.target.value)}
|
onChange={(e) => setCourseCover(e.target.value)}
|
||||||
className="rounded-md border bg-background px-3 py-2 text-sm"
|
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
placeholder="URL превью"
|
placeholder="URL обложки"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<textarea
|
||||||
|
value={courseDescription}
|
||||||
|
onChange={(e) => setCourseDescription(e.target.value)}
|
||||||
|
className="w-full rounded-md border bg-background p-3 text-sm"
|
||||||
|
rows={4}
|
||||||
|
placeholder="Описание курса"
|
||||||
|
/>
|
||||||
|
<Button onClick={handleSaveMeta} disabled={savingMeta}>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{savingMeta ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'content' && (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="flex h-[70vh]">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'border-r bg-muted/30 transition-all duration-300',
|
||||||
|
sidebarOpen ? 'w-72' : 'w-14'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-10 items-center justify-between border-b px-2">
|
||||||
|
<span className={cn('text-xs text-muted-foreground', !sidebarOpen && 'hidden')}>Уроки</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setSidebarOpen((prev) => !prev)}>
|
||||||
|
{sidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{sidebarOpen ? (
|
||||||
|
<LessonSidebar
|
||||||
|
course={course}
|
||||||
|
activeLesson={activeLesson?.lessonId ?? ''}
|
||||||
|
onSelectLesson={handleSelectLesson}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium truncate">{activeLessonMeta?.title ?? 'Выберите урок'}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Редактирование контента урока</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setReadOnly((prev) => !prev)}>
|
||||||
|
{readOnly ? 'Редактировать' : 'Режим просмотра'}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSaveLesson} disabled={savingLesson || !activeLesson || readOnly}>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{savingLesson ? 'Сохранение...' : 'Сохранить урок'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
{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>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'pricing' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Цены</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex gap-2 max-w-md">
|
||||||
<input
|
<input
|
||||||
value={coursePrice}
|
value={coursePrice}
|
||||||
onChange={(e) => setCoursePrice(e.target.value)}
|
onChange={(e) => setCoursePrice(e.target.value)}
|
||||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
placeholder="Цена"
|
placeholder="Цена (пусто или 0 = бесплатно)"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={courseCurrency}
|
value={courseCurrency}
|
||||||
@ -318,30 +400,76 @@ export default function CourseEditPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleSaveMeta} disabled={savingMeta}>
|
<Button onClick={handleSaveMeta} disabled={savingMeta}>
|
||||||
{savingMeta ? 'Сохранение...' : 'Сохранить карточку'}
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{savingMeta ? 'Сохранение...' : 'Сохранить'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</CardContent>
|
||||||
<textarea
|
</Card>
|
||||||
value={courseDescription}
|
)}
|
||||||
onChange={(e) => setCourseDescription(e.target.value)}
|
|
||||||
className="mt-2 w-full rounded-md border bg-background p-3 text-sm"
|
|
||||||
rows={2}
|
|
||||||
placeholder="Описание курса"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col min-h-0 overflow-auto p-6">
|
{activeTab === 'settings' && (
|
||||||
<div className="w-full max-w-3xl mx-auto flex-1 min-h-0 flex flex-col">
|
<Card>
|
||||||
{contentLoading ? (
|
<CardHeader>
|
||||||
<p className="text-muted-foreground">Загрузка контента...</p>
|
<CardTitle>Настройки</CardTitle>
|
||||||
) : readOnly ? (
|
</CardHeader>
|
||||||
<LessonContentViewer content={content} className="prose-reader min-h-[400px]" />
|
<CardContent className="space-y-3">
|
||||||
) : (
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<CourseEditor content={content} onChange={setContent} />
|
<select
|
||||||
)}
|
value={courseDifficulty}
|
||||||
</div>
|
onChange={(e) => setCourseDifficulty(e.target.value)}
|
||||||
</div>
|
className="rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
</div>
|
>
|
||||||
|
<option value="">Уровень сложности</option>
|
||||||
|
<option value="beginner">Начинающий</option>
|
||||||
|
<option value="intermediate">Средний</option>
|
||||||
|
<option value="advanced">Продвинутый</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
value={courseEstimatedHours}
|
||||||
|
onChange={(e) => setCourseEstimatedHours(e.target.value)}
|
||||||
|
className="rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
|
placeholder="Оценка длительности (часы)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={courseTags}
|
||||||
|
onChange={(e) => setCourseTags(e.target.value)}
|
||||||
|
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
|
placeholder="Теги через запятую"
|
||||||
|
/>
|
||||||
|
<Button onClick={handleSaveMeta} disabled={savingMeta}>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{savingMeta ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'access' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Доступ и публикация</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-3 text-sm">
|
||||||
|
<p><span className="font-medium">Статус:</span> {course.status}</p>
|
||||||
|
{course.moderationNote ? (
|
||||||
|
<p className="mt-1 text-muted-foreground">Комментарий модерации: {course.moderationNote}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" onClick={handleToggleVerify} disabled={verifying}>
|
||||||
|
<Shield className="mr-2 h-4 w-4" />
|
||||||
|
{verifying ? 'Обработка...' : 'Верификация автора'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handlePublish} disabled={publishing}>
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
{publishing ? 'Отправка...' : 'Отправить на модерацию'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,12 +3,13 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { io, Socket } from 'socket.io-client';
|
import { io, Socket } from 'socket.io-client';
|
||||||
import { Link as LinkIcon, Send } from 'lucide-react';
|
import { CheckCircle2, Link as LinkIcon, Send, Users } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { getWsBaseUrl } from '@/lib/ws';
|
import { getWsBaseUrl } from '@/lib/ws';
|
||||||
import { useAuth } from '@/contexts/auth-context';
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export default function CourseGroupPage() {
|
export default function CourseGroupPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@ -22,8 +23,10 @@ export default function CourseGroupPage() {
|
|||||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||||
const [submissions, setSubmissions] = useState<any[]>([]);
|
const [submissions, setSubmissions] = useState<any[]>([]);
|
||||||
const [reviewState, setReviewState] = useState<Record<string, { score: number; feedback: string }>>({});
|
const [reviewState, setReviewState] = useState<Record<string, { score: number; feedback: string }>>({});
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
const socketRef = useRef<Socket | null>(null);
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
const endRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const isAuthor = useMemo(() => {
|
const isAuthor = useMemo(() => {
|
||||||
if (!backendUser || !group?.courseId) return false;
|
if (!backendUser || !group?.courseId) return false;
|
||||||
@ -71,12 +74,18 @@ export default function CourseGroupPage() {
|
|||||||
};
|
};
|
||||||
}, [group?.id]);
|
}, [group?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
endRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
const sendMessage = async () => {
|
const sendMessage = async () => {
|
||||||
if (!group?.id || !message.trim()) return;
|
if (!group?.id || !message.trim()) return;
|
||||||
|
setSending(true);
|
||||||
await api.sendGroupMessage(group.id, message.trim());
|
await api.sendGroupMessage(group.id, message.trim());
|
||||||
setMessage('');
|
setMessage('');
|
||||||
const latest = await api.getGroupMessages(group.id).catch(() => []);
|
const latest = await api.getGroupMessages(group.id).catch(() => []);
|
||||||
setMessages(latest);
|
setMessages(latest);
|
||||||
|
setSending(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createInvite = async () => {
|
const createInvite = async () => {
|
||||||
@ -97,84 +106,103 @@ export default function CourseGroupPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Группа курса</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Комьюнити курса</h1>
|
||||||
<p className="text-muted-foreground">Чат участников и проверка домашних заданий</p>
|
<p className="text-sm text-muted-foreground">Общение студентов, приглашения и проверка домашних заданий</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
|
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
|
||||||
<Card className="min-h-[460px]">
|
<Card className="border-border/60">
|
||||||
<CardHeader>
|
<CardHeader className="border-b bg-muted/20">
|
||||||
<CardTitle>{group?.name || 'Основная группа'}</CardTitle>
|
<CardTitle className="flex items-center justify-between text-base">
|
||||||
|
<span>{group?.name || 'Основная группа'}</span>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{messages.length} сообщений</span>
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex h-[400px] flex-col">
|
<CardContent className="flex h-[460px] flex-col p-4">
|
||||||
<div className="flex-1 overflow-auto space-y-2 pr-2">
|
<div className="flex-1 space-y-2 overflow-auto pr-2">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => {
|
||||||
<div key={msg.id} className="rounded-md border p-2 text-sm">
|
const own = msg.user?.id === backendUser?.id;
|
||||||
<p className="font-medium">{msg.user?.name || 'Участник'}</p>
|
return (
|
||||||
<p>{msg.content}</p>
|
<div key={msg.id} className={cn('flex', own ? 'justify-end' : 'justify-start')}>
|
||||||
</div>
|
<div
|
||||||
))}
|
className={cn(
|
||||||
|
'max-w-[78%] rounded-2xl px-3 py-2 text-sm',
|
||||||
|
own ? 'rounded-br-md bg-primary text-primary-foreground' : 'rounded-bl-md border bg-card'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="mb-1 text-xs opacity-80">{msg.user?.name || 'Участник'}</p>
|
||||||
|
<p>{msg.content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div ref={endRef} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-2">
|
|
||||||
|
<div className="mt-3 flex gap-2 border-t pt-3">
|
||||||
<input
|
<input
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
|
||||||
placeholder="Сообщение в чат"
|
placeholder="Сообщение в чат"
|
||||||
className="flex-1 rounded-md border bg-background px-3 py-2 text-sm"
|
className="h-10 flex-1 rounded-xl border bg-background px-3 text-sm"
|
||||||
/>
|
/>
|
||||||
<Button onClick={sendMessage}>
|
<Button onClick={sendMessage} disabled={sending || !message.trim()}>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card className="border-border/60">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Участники ({members.length})</CardTitle>
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
Участники ({members.length})
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
{members.map((member) => (
|
{members.map((member) => (
|
||||||
<div key={member.id} className="rounded-md border p-2 text-sm">
|
<div key={member.id} className="rounded-xl border p-2 text-sm">
|
||||||
<p className="font-medium">{member.user?.name || member.user?.email}</p>
|
<p className="font-medium">{member.user?.name || member.user?.email}</p>
|
||||||
<p className="text-xs text-muted-foreground">{member.role}</p>
|
<p className="text-xs text-muted-foreground">{member.role}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{isAuthor && (
|
{isAuthor ? (
|
||||||
<div className="pt-2 border-t space-y-2">
|
<div className="space-y-2 border-t pt-3">
|
||||||
<Button variant="outline" onClick={createInvite} className="w-full">
|
<Button variant="outline" onClick={createInvite} className="w-full">
|
||||||
<LinkIcon className="mr-2 h-4 w-4" />
|
<LinkIcon className="mr-2 h-4 w-4" />
|
||||||
Создать invite-ссылку
|
Invite-ссылка
|
||||||
</Button>
|
</Button>
|
||||||
{inviteLink ? <p className="text-xs break-all text-muted-foreground">{inviteLink}</p> : null}
|
{inviteLink ? <p className="break-all text-xs text-muted-foreground">{inviteLink}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isAuthor && (
|
{isAuthor ? (
|
||||||
<Card>
|
<Card className="border-border/60">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Проверка домашних заданий</CardTitle>
|
<CardTitle>Проверка домашних заданий</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{submissions.map((submission) => (
|
{submissions.map((submission) => (
|
||||||
<div key={submission.id} className="rounded-md border p-3 space-y-2">
|
<div key={submission.id} className="space-y-2 rounded-xl border p-3">
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
{submission.user?.name || submission.user?.email} • {submission.homework?.lesson?.title}
|
{submission.user?.name || submission.user?.email} • {submission.homework?.lesson?.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm whitespace-pre-wrap">{submission.content}</p>
|
<p className="whitespace-pre-wrap text-sm">{submission.content}</p>
|
||||||
{submission.aiScore ? (
|
{submission.aiScore ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
AI-предоценка: {submission.aiScore}/5 {submission.aiFeedback ? `• ${submission.aiFeedback}` : ''}
|
AI-предоценка: {submission.aiScore}/5 {submission.aiFeedback ? `• ${submission.aiFeedback}` : ''}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<select
|
<select
|
||||||
className="rounded-md border bg-background px-2 py-1 text-sm"
|
className="h-9 rounded-lg border bg-background px-2 text-sm"
|
||||||
value={reviewState[submission.id]?.score ?? submission.teacherScore ?? 5}
|
value={reviewState[submission.id]?.score ?? submission.teacherScore ?? 5}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setReviewState((prev) => ({
|
setReviewState((prev) => ({
|
||||||
@ -193,7 +221,7 @@ export default function CourseGroupPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
className="flex-1 rounded-md border bg-background px-3 py-1 text-sm"
|
className="h-9 flex-1 rounded-lg border bg-background px-3 text-sm"
|
||||||
value={reviewState[submission.id]?.feedback ?? submission.teacherFeedback ?? ''}
|
value={reviewState[submission.id]?.feedback ?? submission.teacherFeedback ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setReviewState((prev) => ({
|
setReviewState((prev) => ({
|
||||||
@ -207,6 +235,7 @@ export default function CourseGroupPage() {
|
|||||||
placeholder="Комментарий преподавателя"
|
placeholder="Комментарий преподавателя"
|
||||||
/>
|
/>
|
||||||
<Button size="sm" onClick={() => submitReview(submission.id)}>
|
<Button size="sm" onClick={() => submitReview(submission.id)}>
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
Сохранить
|
Сохранить
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -215,7 +244,7 @@ export default function CourseGroupPage() {
|
|||||||
{submissions.length === 0 ? <p className="text-sm text-muted-foreground">Пока нет отправленных ДЗ</p> : null}
|
{submissions.length === 0 ? <p className="text-sm text-muted-foreground">Пока нет отправленных ДЗ</p> : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,19 +3,24 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { io, Socket } from 'socket.io-client';
|
import { io, Socket } from 'socket.io-client';
|
||||||
import { Send } from 'lucide-react';
|
import { Send, Users } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { getWsBaseUrl } from '@/lib/ws';
|
import { getWsBaseUrl } from '@/lib/ws';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
|
|
||||||
export default function InviteGroupPage() {
|
export default function InviteGroupPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const groupId = params?.groupId as string;
|
const groupId = params?.groupId as string;
|
||||||
|
const { backendUser } = useAuth();
|
||||||
|
|
||||||
const [messages, setMessages] = useState<any[]>([]);
|
const [messages, setMessages] = useState<any[]>([]);
|
||||||
const [members, setMembers] = useState<any[]>([]);
|
const [members, setMembers] = useState<any[]>([]);
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const socketRef = useRef<Socket | null>(null);
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
const endRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!groupId) return;
|
if (!groupId) return;
|
||||||
@ -47,6 +52,10 @@ export default function InviteGroupPage() {
|
|||||||
};
|
};
|
||||||
}, [groupId]);
|
}, [groupId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
endRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
if (!groupId || !message.trim()) return;
|
if (!groupId || !message.trim()) return;
|
||||||
await api.sendGroupMessage(groupId, message.trim());
|
await api.sendGroupMessage(groupId, message.trim());
|
||||||
@ -54,47 +63,70 @@ export default function InviteGroupPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
|
<div className="space-y-4">
|
||||||
<Card className="min-h-[460px]">
|
<div>
|
||||||
<CardHeader>
|
<h1 className="text-2xl font-bold tracking-tight">Группа курса</h1>
|
||||||
<CardTitle>Групповой чат курса</CardTitle>
|
<p className="text-sm text-muted-foreground">Вы подключены по invite-ссылке</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="flex h-[400px] flex-col">
|
|
||||||
<div className="flex-1 overflow-auto space-y-2 pr-2">
|
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
|
||||||
{messages.map((msg) => (
|
<Card className="border-border/60">
|
||||||
<div key={msg.id} className="rounded-md border p-2 text-sm">
|
<CardHeader className="border-b bg-muted/20">
|
||||||
<p className="font-medium">{msg.user?.name || 'Участник'}</p>
|
<CardTitle>Чат участников</CardTitle>
|
||||||
<p>{msg.content}</p>
|
</CardHeader>
|
||||||
|
<CardContent className="flex h-[460px] flex-col p-4">
|
||||||
|
<div className="flex-1 space-y-2 overflow-auto pr-2">
|
||||||
|
{messages.map((msg) => {
|
||||||
|
const own = msg.user?.id === backendUser?.id;
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className={cn('flex', own ? 'justify-end' : 'justify-start')}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'max-w-[78%] rounded-2xl px-3 py-2 text-sm',
|
||||||
|
own ? 'rounded-br-md bg-primary text-primary-foreground' : 'rounded-bl-md border bg-card'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="mb-1 text-xs opacity-80">{msg.user?.name || 'Участник'}</p>
|
||||||
|
<p>{msg.content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div ref={endRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex gap-2 border-t pt-3">
|
||||||
|
<input
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && send()}
|
||||||
|
className="h-10 flex-1 rounded-lg border bg-background px-3 text-sm"
|
||||||
|
placeholder="Сообщение"
|
||||||
|
/>
|
||||||
|
<Button onClick={send}>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border/60">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
Участники ({members.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{members.map((member) => (
|
||||||
|
<div key={member.id} className="rounded-xl border p-2 text-sm">
|
||||||
|
<p className="font-medium">{member.user?.name || member.user?.email}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{member.role}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</CardContent>
|
||||||
<div className="mt-3 flex gap-2">
|
</Card>
|
||||||
<input
|
</div>
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
className="flex-1 rounded-md border bg-background px-3 py-2 text-sm"
|
|
||||||
placeholder="Сообщение"
|
|
||||||
/>
|
|
||||||
<Button onClick={send}>
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Участники ({members.length})</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
{members.map((member) => (
|
|
||||||
<div key={member.id} className="rounded-md border p-2 text-sm">
|
|
||||||
<p className="font-medium">{member.user?.name || member.user?.email}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{member.role}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,7 +64,7 @@ export default function LearningPage() {
|
|||||||
<GraduationCap className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
|
<GraduationCap className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
|
||||||
<p className="text-lg font-medium">Пока нет записей</p>
|
<p className="text-lg font-medium">Пока нет записей</p>
|
||||||
<p className="text-muted-foreground mb-4">Найдите курсы в каталоге</p>
|
<p className="text-muted-foreground mb-4">Найдите курсы в каталоге</p>
|
||||||
<Link href="/dashboard/catalog" className="text-primary hover:underline">
|
<Link href="/courses" className="text-primary hover:underline">
|
||||||
Открыть каталог
|
Открыть каталог
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Plus, BookOpen, Clock, TrendingUp, Loader2 } from 'lucide-react';
|
import { BookOpen, Clock3, FileText, Loader2, Plus, Send, Sparkles } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { CourseCard } from '@/components/dashboard/course-card';
|
import { CourseCard } from '@/components/dashboard/course-card';
|
||||||
@ -10,41 +10,32 @@ import { api } from '@/lib/api';
|
|||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { useAuth } from '@/contexts/auth-context';
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
|
|
||||||
interface Course {
|
type Course = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED' | 'GENERATING';
|
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED' | 'GENERATING' | 'PENDING_REVIEW' | 'REJECTED';
|
||||||
chaptersCount: number;
|
chaptersCount: number;
|
||||||
lessonsCount: number;
|
lessonsCount: number;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { loading: authLoading, user } = useAuth();
|
const { loading: authLoading, user } = useAuth();
|
||||||
const [courses, setCourses] = useState<Course[]>([]);
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [stats, setStats] = useState({
|
|
||||||
total: 0,
|
|
||||||
drafts: 0,
|
|
||||||
published: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadCourses = async () => {
|
const loadCourses = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.getCourses();
|
const result = await api.getCourses({ limit: 100 });
|
||||||
setCourses(result.data);
|
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) {
|
} catch (error: any) {
|
||||||
if (error.message !== 'Unauthorized') {
|
if (error.message !== 'Unauthorized') {
|
||||||
toast({
|
toast({
|
||||||
title: 'Ошибка загрузки',
|
title: 'Ошибка загрузки',
|
||||||
description: 'Не удалось загрузить курсы',
|
description: 'Не удалось загрузить ваши курсы',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -60,17 +51,23 @@ export default function DashboardPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadCourses();
|
loadCourses();
|
||||||
}, [toast, authLoading, user]);
|
}, [authLoading, user]);
|
||||||
|
|
||||||
const statsCards = [
|
const stats = useMemo(() => {
|
||||||
{ name: 'Всего курсов', value: stats.total.toString(), icon: BookOpen },
|
const drafts = courses.filter((course) => course.status === 'DRAFT' || course.status === 'REJECTED');
|
||||||
{ name: 'Черновики', value: stats.drafts.toString(), icon: Clock },
|
const published = courses.filter((course) => course.status === 'PUBLISHED');
|
||||||
{ name: 'Опубликовано', value: stats.published.toString(), icon: TrendingUp },
|
const pending = courses.filter((course) => course.status === 'PENDING_REVIEW');
|
||||||
];
|
return {
|
||||||
|
drafts,
|
||||||
|
published,
|
||||||
|
pending,
|
||||||
|
total: courses.length,
|
||||||
|
};
|
||||||
|
}, [courses]);
|
||||||
|
|
||||||
if (authLoading || loading) {
|
if (authLoading || loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<div className="flex min-h-[400px] items-center justify-center">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -78,62 +75,119 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Header */}
|
<section className="rounded-3xl border border-border/50 bg-gradient-to-br from-primary/10 via-background to-amber-100/40 p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Мои курсы</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Кабинет автора</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
Управляйте своими курсами и создавайте новые
|
Здесь только ваша авторская зона: черновики, курсы в проверке и опубликованные материалы.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild>
|
<div className="flex gap-2">
|
||||||
<Link href="/dashboard/courses/new">
|
<Button variant="outline" asChild>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Link href="/">
|
||||||
Создать курс
|
<Sparkles className="mr-2 h-4 w-4" />
|
||||||
</Link>
|
Открыть лендинг
|
||||||
</Button>
|
</Link>
|
||||||
</div>
|
</Button>
|
||||||
|
|
||||||
{/* 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>
|
<Button asChild>
|
||||||
<Link href="/dashboard/courses/new">
|
<Link href="/dashboard/courses/new">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Создать курс
|
Новый курс
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card className="border-border/60">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Черновики</CardTitle>
|
||||||
|
<CardDescription>Можно редактировать и дорабатывать</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center justify-between">
|
||||||
|
<span className="text-2xl font-bold">{stats.drafts.length}</span>
|
||||||
|
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
|
<Card className="border-border/60">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">На модерации</CardTitle>
|
||||||
|
<CardDescription>Ожидают решения модератора</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center justify-between">
|
||||||
|
<span className="text-2xl font-bold">{stats.pending.length}</span>
|
||||||
|
<Send className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border/60">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Опубликованные</CardTitle>
|
||||||
|
<CardDescription>Доступны пользователям в каталоге</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center justify-between">
|
||||||
|
<span className="text-2xl font-bold">{stats.published.length}</span>
|
||||||
|
<BookOpen className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock3 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-xl font-semibold">Черновики и отклонённые</h2>
|
||||||
|
</div>
|
||||||
|
{stats.drafts.length ? (
|
||||||
|
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{stats.drafts.map((course) => (
|
||||||
|
<CourseCard key={course.id} course={course} onDeleted={loadCourses} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="p-10 text-center text-sm text-muted-foreground">
|
||||||
|
Нет черновиков. Создайте курс, чтобы начать работу.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">На проверке</h2>
|
||||||
|
{stats.pending.length ? (
|
||||||
|
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{stats.pending.map((course) => (
|
||||||
|
<CourseCard key={course.id} course={course} onDeleted={loadCourses} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="p-10 text-center text-sm text-muted-foreground">
|
||||||
|
Сейчас нет курсов в модерации.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">Опубликованные</h2>
|
||||||
|
{stats.published.length ? (
|
||||||
|
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{stats.published.map((course) => (
|
||||||
|
<CourseCard key={course.id} course={course} onDeleted={loadCourses} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="p-10 text-center text-sm text-muted-foreground">
|
||||||
|
Пока нет опубликованных курсов.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Sidebar } from '@/components/dashboard/sidebar';
|
import { Sidebar } from '@/components/dashboard/sidebar';
|
||||||
import { DashboardHeader } from '@/components/dashboard/header';
|
import { DashboardHeader } from '@/components/dashboard/header';
|
||||||
|
import { SupportWidget } from '@/components/dashboard/support-widget';
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
@ -13,6 +14,7 @@ export default function DashboardLayout({
|
|||||||
<DashboardHeader />
|
<DashboardHeader />
|
||||||
<main className="flex-1 p-6">{children}</main>
|
<main className="flex-1 p-6">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
|
<SupportWidget />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
78
apps/web/src/app/admin/layout.tsx
Normal file
78
apps/web/src/app/admin/layout.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { Loader2, ShieldAlert, ShieldCheck } from 'lucide-react';
|
||||||
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: 'Курсы', href: '/admin' },
|
||||||
|
{ name: 'Тикеты поддержки', href: '/admin/support' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { loading, backendUser } = useAuth();
|
||||||
|
const isStaff = backendUser?.role === 'ADMIN' || backendUser?.role === 'MODERATOR';
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStaff) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-6">
|
||||||
|
<div className="w-full max-w-md rounded-xl border bg-card p-6 text-center">
|
||||||
|
<ShieldAlert className="mx-auto mb-3 h-8 w-8 text-rose-500" />
|
||||||
|
<p className="text-base font-semibold">Нет доступа к Админ Панели</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Требуется роль администратора или модератора.</p>
|
||||||
|
<Link href="/dashboard" className="mt-4 inline-block text-sm text-primary hover:underline">
|
||||||
|
Вернуться в личный кабинет
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-muted/20">
|
||||||
|
<header className="border-b bg-background">
|
||||||
|
<div className="container flex h-16 items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">Админ Панель</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Модерация и поддержка</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
{navigation.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium',
|
||||||
|
pathname === item.href ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<Link href="/dashboard" className="text-sm text-primary hover:underline">
|
||||||
|
Личный кабинет
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="container py-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
260
apps/web/src/app/admin/page.tsx
Normal file
260
apps/web/src/app/admin/page.tsx
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { CheckCircle2, Loader2, MessageCircle, Search, Trash2, XCircle } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type ModerationCourse = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
author?: { name?: string | null; email?: string };
|
||||||
|
moderationNote?: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
_count?: { chapters?: number; enrollments?: number; reviews?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusFilters = [
|
||||||
|
{ value: '', label: 'Все статусы' },
|
||||||
|
{ value: 'PENDING_REVIEW', label: 'На проверке' },
|
||||||
|
{ value: 'PUBLISHED', label: 'Опубликованные' },
|
||||||
|
{ value: 'REJECTED', label: 'Отклонённые' },
|
||||||
|
{ value: 'DRAFT', label: 'Черновики' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const badgeMap: Record<string, string> = {
|
||||||
|
PENDING_REVIEW: 'bg-amber-100 text-amber-900',
|
||||||
|
PUBLISHED: 'bg-green-100 text-green-900',
|
||||||
|
REJECTED: 'bg-rose-100 text-rose-900',
|
||||||
|
DRAFT: 'bg-slate-100 text-slate-900',
|
||||||
|
ARCHIVED: 'bg-slate-200 text-slate-900',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [courses, setCourses] = useState<ModerationCourse[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
const [noteDraft, setNoteDraft] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actingId, setActingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadCourses = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.getModerationCourses({ status: status || undefined, search: search || undefined });
|
||||||
|
setCourses(data || []);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({ title: 'Ошибка', description: error.message || 'Не удалось загрузить курсы', variant: 'destructive' });
|
||||||
|
setCourses([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCourses();
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
return {
|
||||||
|
total: courses.length,
|
||||||
|
pending: courses.filter((course) => course.status === 'PENDING_REVIEW').length,
|
||||||
|
published: courses.filter((course) => course.status === 'PUBLISHED').length,
|
||||||
|
};
|
||||||
|
}, [courses]);
|
||||||
|
|
||||||
|
const approve = async (courseId: string) => {
|
||||||
|
setActingId(courseId);
|
||||||
|
try {
|
||||||
|
await api.approveModerationCourse(courseId, noteDraft[courseId] || undefined);
|
||||||
|
toast({ title: 'Курс опубликован', description: 'Курс прошёл модерацию' });
|
||||||
|
await loadCourses();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({ title: 'Ошибка', description: error.message, variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
|
setActingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reject = async (courseId: string) => {
|
||||||
|
setActingId(courseId);
|
||||||
|
try {
|
||||||
|
await api.rejectModerationCourse(courseId, noteDraft[courseId] || 'Нужны доработки');
|
||||||
|
toast({ title: 'Курс отклонён', description: 'Автор увидит причину в курсе' });
|
||||||
|
await loadCourses();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({ title: 'Ошибка', description: error.message, variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
|
setActingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCourse = async (courseId: string) => {
|
||||||
|
setActingId(courseId);
|
||||||
|
try {
|
||||||
|
await api.deleteModerationCourse(courseId);
|
||||||
|
toast({ title: 'Курс удалён', description: 'Курс удалён администратором' });
|
||||||
|
await loadCourses();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({ title: 'Ошибка', description: error.message, variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
|
setActingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="rounded-2xl border bg-background p-6">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Модерация курсов</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Проверка курсов, публикация, отклонение и удаление.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/admin/support">
|
||||||
|
<MessageCircle className="mr-2 h-4 w-4" />
|
||||||
|
Тикеты поддержки
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Всего в выдаче</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-2xl font-bold">{stats.total}</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Ожидают модерации</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-2xl font-bold">{stats.pending}</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Опубликовано</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-2xl font-bold">{stats.published}</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-3 rounded-2xl border bg-card p-4 md:flex-row">
|
||||||
|
<label className="relative flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Поиск по курсам и авторам"
|
||||||
|
className="h-10 w-full rounded-xl border bg-background pl-10 pr-3 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
|
className="h-10 rounded-xl border bg-background px-3 text-sm"
|
||||||
|
>
|
||||||
|
{statusFilters.map((item) => (
|
||||||
|
<option key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<Button onClick={loadCourses} disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
Применить
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
{courses.map((course) => (
|
||||||
|
<Card key={course.id} className="border-border/60">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">{course.title}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{course.author?.name || 'Без имени'} ({course.author?.email || '—'})
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'rounded-full px-2.5 py-1 text-xs font-semibold',
|
||||||
|
badgeMap[course.status] || 'bg-muted text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{course.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
|
||||||
|
<p>Глав: {course._count?.chapters || 0}</p>
|
||||||
|
<p>Студентов: {course._count?.enrollments || 0}</p>
|
||||||
|
<p>Отзывов: {course._count?.reviews || 0}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={noteDraft[course.id] || ''}
|
||||||
|
onChange={(e) => setNoteDraft((prev) => ({ ...prev, [course.id]: e.target.value }))}
|
||||||
|
placeholder="Комментарий модерации"
|
||||||
|
className="h-10 w-full rounded-lg border bg-background px-3 text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{course.status === 'PENDING_REVIEW' ? (
|
||||||
|
<>
|
||||||
|
<Button size="sm" onClick={() => approve(course.id)} disabled={actingId === course.id}>
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
|
Опубликовать
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => reject(course.id)}
|
||||||
|
disabled={actingId === course.id}
|
||||||
|
>
|
||||||
|
<XCircle className="mr-2 h-4 w-4" />
|
||||||
|
Отклонить
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => removeCourse(course.id)}
|
||||||
|
disabled={actingId === course.id}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Удалить курс
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!loading && courses.length === 0 ? (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="p-10 text-center text-sm text-muted-foreground">
|
||||||
|
Курсы по заданным фильтрам не найдены.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
apps/web/src/app/admin/support/page.tsx
Normal file
188
apps/web/src/app/admin/support/page.tsx
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
import { ChevronLeft, Loader2, Send } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { getWsBaseUrl } from '@/lib/ws';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export default function AdminSupportPage() {
|
||||||
|
const [tickets, setTickets] = useState<any[]>([]);
|
||||||
|
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null);
|
||||||
|
const [messages, setMessages] = useState<any[]>([]);
|
||||||
|
const [status, setStatus] = useState('in_progress');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
|
||||||
|
const selected = useMemo(
|
||||||
|
() => tickets.find((ticket) => ticket.id === selectedTicketId) || null,
|
||||||
|
[tickets, selectedTicketId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadTickets = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await api.getAdminSupportTickets().catch(() => []);
|
||||||
|
setTickets(data);
|
||||||
|
if (!selectedTicketId && data.length > 0) {
|
||||||
|
setSelectedTicketId(data[0].id);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTickets();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedTicketId) return;
|
||||||
|
api
|
||||||
|
.getAdminSupportTicketMessages(selectedTicketId)
|
||||||
|
.then((data) => setMessages(data))
|
||||||
|
.catch(() => setMessages([]));
|
||||||
|
|
||||||
|
const token =
|
||||||
|
typeof window !== 'undefined' ? sessionStorage.getItem('coursecraft_api_token') || undefined : undefined;
|
||||||
|
const socket = io(`${getWsBaseUrl()}/ws/support`, {
|
||||||
|
transports: ['websocket'],
|
||||||
|
auth: { token },
|
||||||
|
});
|
||||||
|
socketRef.current = socket;
|
||||||
|
socket.emit('support:join', { ticketId: selectedTicketId });
|
||||||
|
socket.on('support:new-message', (msg: any) => {
|
||||||
|
setMessages((prev) => [...prev, msg]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
socket.disconnect();
|
||||||
|
socketRef.current = null;
|
||||||
|
};
|
||||||
|
}, [selectedTicketId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected) return;
|
||||||
|
setStatus(selected.status || 'in_progress');
|
||||||
|
}, [selected?.id]);
|
||||||
|
|
||||||
|
const sendReply = async () => {
|
||||||
|
if (!selectedTicketId || !message.trim()) return;
|
||||||
|
setSending(true);
|
||||||
|
await api.sendAdminSupportMessage(selectedTicketId, message.trim());
|
||||||
|
setMessage('');
|
||||||
|
const list = await api.getAdminSupportTicketMessages(selectedTicketId).catch(() => []);
|
||||||
|
setMessages(list);
|
||||||
|
await loadTickets();
|
||||||
|
setSending(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStatus = async () => {
|
||||||
|
if (!selectedTicketId) return;
|
||||||
|
setSending(true);
|
||||||
|
await api.updateAdminSupportTicketStatus(selectedTicketId, status);
|
||||||
|
await loadTickets();
|
||||||
|
setSending(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[400px] items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Тикеты поддержки</h1>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/admin">
|
||||||
|
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||||
|
К модерации
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Очередь тикетов</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{tickets.map((ticket) => (
|
||||||
|
<button
|
||||||
|
key={ticket.id}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-xl border px-3 py-2 text-left text-sm transition',
|
||||||
|
selectedTicketId === ticket.id ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedTicketId(ticket.id)}
|
||||||
|
>
|
||||||
|
<p className="font-medium">{ticket.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{ticket.user?.name || ticket.user?.email} • {ticket.status}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="min-h-[560px]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{selected ? `Тикет: ${selected.title}` : 'Выберите тикет'}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex h-[470px] flex-col">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-lg border bg-background px-3 text-sm"
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
|
disabled={!selectedTicketId}
|
||||||
|
>
|
||||||
|
<option value="open">open</option>
|
||||||
|
<option value="in_progress">in_progress</option>
|
||||||
|
<option value="resolved">resolved</option>
|
||||||
|
<option value="closed">closed</option>
|
||||||
|
</select>
|
||||||
|
<Button variant="outline" onClick={updateStatus} disabled={!selectedTicketId || sending}>
|
||||||
|
Обновить статус
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-2 overflow-auto pr-2">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div key={msg.id} className={cn('flex', msg.isStaff ? 'justify-end' : 'justify-start')}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'max-w-[75%] rounded-2xl px-3 py-2 text-sm',
|
||||||
|
msg.isStaff ? 'bg-primary text-primary-foreground' : 'bg-muted'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="mb-1 text-xs opacity-80">{msg.user?.name || 'Пользователь'}</p>
|
||||||
|
<p>{msg.content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder="Ответ поддержки"
|
||||||
|
className="h-10 flex-1 rounded-lg border bg-background px-3 text-sm"
|
||||||
|
/>
|
||||||
|
<Button onClick={sendReply} disabled={!selectedTicketId || sending}>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
268
apps/web/src/app/courses/[id]/page.tsx
Normal file
268
apps/web/src/app/courses/[id]/page.tsx
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { ArrowLeft, BookOpen, Check, ChevronDown, Clock, Loader2, Shield, Star, Users } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Header } from '@/components/landing/header';
|
||||||
|
import { Footer } from '@/components/landing/footer';
|
||||||
|
|
||||||
|
export default function PublicCoursePage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const id = params?.id as string;
|
||||||
|
|
||||||
|
const [course, setCourse] = useState<any>(null);
|
||||||
|
const [reviews, setReviews] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [enrolling, setEnrolling] = useState(false);
|
||||||
|
const [enrolled, setEnrolled] = useState(false);
|
||||||
|
const [expandedChapters, setExpandedChapters] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [courseData, reviewsData] = await Promise.all([
|
||||||
|
api.getPublicCourse(id),
|
||||||
|
api.getCourseReviews(id).catch(() => ({ data: [] })),
|
||||||
|
]);
|
||||||
|
setCourse(courseData);
|
||||||
|
setReviews(reviewsData.data || []);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const enrollments = await api.getMyEnrollments().catch(() => []);
|
||||||
|
setEnrolled(enrollments.some((item: any) => item.course.id === id));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setCourse(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [id, user]);
|
||||||
|
|
||||||
|
const handleEnroll = async () => {
|
||||||
|
if (!id || enrolling) return;
|
||||||
|
if (!user) {
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnrolling(true);
|
||||||
|
try {
|
||||||
|
if (course?.price) {
|
||||||
|
const session = await api.checkoutCourse(id);
|
||||||
|
if (session?.url) {
|
||||||
|
window.location.href = session.url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await api.enrollInCourse(id);
|
||||||
|
toast({ title: 'Готово', description: 'Вы записались на курс' });
|
||||||
|
router.push(`/dashboard/courses/${id}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({ title: 'Ошибка', description: error.message, variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
|
setEnrolling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<Header />
|
||||||
|
<div className="flex-1 flex min-h-[420px] items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!course) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<Header />
|
||||||
|
<div className="container py-20 text-center flex-1">
|
||||||
|
<p className="text-muted-foreground">Курс не найден</p>
|
||||||
|
<Link href="/courses" className="mt-3 inline-block text-sm text-primary hover:underline">
|
||||||
|
Вернуться в Курсы
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalLessons = course.chapters.reduce((acc: number, chapter: any) => acc + chapter.lessons.length, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<Header />
|
||||||
|
<main className="container max-w-6xl space-y-6 py-8 flex-1">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href="/courses">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Назад к курсам
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
|
<div className="space-y-5 md:col-span-2">
|
||||||
|
<div className="aspect-video overflow-hidden rounded-2xl bg-gradient-to-br from-amber-100 via-background to-cyan-100">
|
||||||
|
{course.coverImage ? (
|
||||||
|
<img src={course.coverImage} alt={course.title} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<BookOpen className="h-12 w-12 text-primary/40" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
|
{course.isVerified ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-600 px-2 py-0.5 text-xs font-medium text-white">
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
Проверен автором
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{course.difficulty ? (
|
||||||
|
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium">
|
||||||
|
{course.difficulty === 'beginner'
|
||||||
|
? 'Начинающий'
|
||||||
|
: course.difficulty === 'intermediate'
|
||||||
|
? 'Средний'
|
||||||
|
: 'Продвинутый'}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">{course.title}</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">{course.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
{course._count.enrollments} студентов
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Star className="h-4 w-4" />
|
||||||
|
{course.averageRating ? `${course.averageRating.toFixed(1)} (${course._count.reviews})` : 'Без рейтинга'}
|
||||||
|
</span>
|
||||||
|
<span>{totalLessons} уроков</span>
|
||||||
|
{course.estimatedHours ? (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
{course.estimatedHours}ч
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Содержание курса</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{course.chapters.map((chapter: any) => {
|
||||||
|
const expanded = expandedChapters.includes(chapter.id);
|
||||||
|
return (
|
||||||
|
<div key={chapter.id} className="overflow-hidden rounded-xl border">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedChapters((prev) =>
|
||||||
|
prev.includes(chapter.id) ? prev.filter((id: string) => id !== chapter.id) : [...prev, chapter.id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<p className="font-medium">{chapter.title}</p>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{chapter.lessons.length} уроков</span>
|
||||||
|
<ChevronDown className={cn('h-4 w-4 transition', expanded && 'rotate-180')} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{expanded ? (
|
||||||
|
<div className="border-t bg-muted/20 p-2">
|
||||||
|
{chapter.lessons.map((lesson: any) => (
|
||||||
|
<div key={lesson.id} className="flex items-center gap-2 px-2 py-2 text-sm">
|
||||||
|
<Check className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span>{lesson.title}</span>
|
||||||
|
{lesson.durationMinutes ? (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">{lesson.durationMinutes} мин</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="h-fit md:sticky md:top-6">
|
||||||
|
<CardContent className="space-y-4 p-5">
|
||||||
|
<p className="text-3xl font-bold">
|
||||||
|
{course.price ? `${course.price} ${course.currency}` : 'Бесплатно'}
|
||||||
|
</p>
|
||||||
|
{enrolled ? (
|
||||||
|
<Button className="w-full" asChild>
|
||||||
|
<Link href={`/dashboard/courses/${id}`}>Перейти к обучению</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button className="w-full" onClick={handleEnroll} disabled={enrolling}>
|
||||||
|
{enrolling ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
{course.price ? 'Купить курс' : 'Записаться'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Check className="h-4 w-4 text-emerald-600" />
|
||||||
|
<span>{course.chapters.length} глав</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Check className="h-4 w-4 text-emerald-600" />
|
||||||
|
<span>{totalLessons} уроков</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Check className="h-4 w-4 text-emerald-600" />
|
||||||
|
<span>Сертификат по окончании</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reviews.length ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-4 p-5">
|
||||||
|
<h2 className="text-lg font-semibold">Отзывы</h2>
|
||||||
|
{reviews.map((review: any) => (
|
||||||
|
<div key={review.id} className="border-b pb-3 last:border-b-0">
|
||||||
|
<p className="text-sm font-medium">{review.user.name || 'Пользователь'}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Оценка: {review.rating}/5</p>
|
||||||
|
{review.title ? <p className="mt-1 text-sm font-medium">{review.title}</p> : null}
|
||||||
|
{review.content ? <p className="mt-1 text-sm text-muted-foreground">{review.content}</p> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
apps/web/src/app/courses/page.tsx
Normal file
182
apps/web/src/app/courses/page.tsx
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Search, Star, Users, BookOpen, Shield, Loader2 } from 'lucide-react';
|
||||||
|
import { Header } from '@/components/landing/header';
|
||||||
|
import { Footer } from '@/components/landing/footer';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface CatalogCourse {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
coverImage: string | null;
|
||||||
|
difficulty: string | null;
|
||||||
|
price: number | null;
|
||||||
|
currency: string;
|
||||||
|
isVerified: boolean;
|
||||||
|
averageRating: number | null;
|
||||||
|
enrollmentCount: number;
|
||||||
|
reviewCount: number;
|
||||||
|
chaptersCount: number;
|
||||||
|
lessonsCount: number;
|
||||||
|
author: { id: string; name: string | null; avatarUrl: string | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const difficultyLabels: Record<string, { label: string; color: string }> = {
|
||||||
|
beginner: { label: 'Начинающий', color: 'text-green-600 bg-green-50 dark:bg-green-900/30' },
|
||||||
|
intermediate: { label: 'Средний', color: 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/30' },
|
||||||
|
advanced: { label: 'Продвинутый', color: 'text-red-600 bg-red-50 dark:bg-red-900/30' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CoursesPage() {
|
||||||
|
const [courses, setCourses] = useState<CatalogCourse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [difficulty, setDifficulty] = useState('');
|
||||||
|
|
||||||
|
const loadCourses = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await api.getCatalog({
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
difficulty: difficulty || undefined,
|
||||||
|
});
|
||||||
|
setCourses(result.data);
|
||||||
|
} catch {
|
||||||
|
setCourses([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCourses();
|
||||||
|
}, [difficulty]);
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
loadCourses();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 container py-10 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Курсы</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">Каталог доступных курсов для обучения</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск курсов..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border rounded-lg bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm">Найти</Button>
|
||||||
|
</form>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{['', 'beginner', 'intermediate', 'advanced'].map((d) => (
|
||||||
|
<Button
|
||||||
|
key={d}
|
||||||
|
variant={difficulty === d ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDifficulty(d)}
|
||||||
|
>
|
||||||
|
{d ? difficultyLabels[d]?.label : 'Все'}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-20">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : courses.length === 0 ? (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<BookOpen className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
|
||||||
|
<p className="text-muted-foreground">Пока нет опубликованных курсов</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{courses.map((course) => (
|
||||||
|
<Link key={course.id} href={`/courses/${course.id}`}>
|
||||||
|
<Card className="group overflow-hidden transition-all hover:shadow-lg hover:-translate-y-0.5 cursor-pointer h-full">
|
||||||
|
<div className="aspect-video bg-gradient-to-br from-primary/20 to-primary/5 relative overflow-hidden">
|
||||||
|
{course.coverImage ? (
|
||||||
|
<img src={course.coverImage} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<BookOpen className="h-10 w-10 text-primary/30" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{course.isVerified && (
|
||||||
|
<div className="absolute top-2 right-2 flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-600 text-white text-xs font-medium">
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
Проверен
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{course.difficulty && difficultyLabels[course.difficulty] && (
|
||||||
|
<div className={cn(
|
||||||
|
'absolute top-2 left-2 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
difficultyLabels[course.difficulty].color
|
||||||
|
)}>
|
||||||
|
{difficultyLabels[course.difficulty].label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<h3 className="font-semibold line-clamp-1 group-hover:text-primary transition-colors">
|
||||||
|
{course.title}
|
||||||
|
</h3>
|
||||||
|
{course.description && (
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">{course.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground mt-3">
|
||||||
|
{course.author.name || 'Автор'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mt-3 text-xs text-muted-foreground">
|
||||||
|
{course.averageRating && (
|
||||||
|
<span className="flex items-center gap-0.5 text-yellow-600 font-medium">
|
||||||
|
<Star className="h-3.5 w-3.5 fill-current" />
|
||||||
|
{course.averageRating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
{course.enrollmentCount}
|
||||||
|
</span>
|
||||||
|
<span>{course.lessonsCount} уроков</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-t">
|
||||||
|
<span className="font-bold text-lg">
|
||||||
|
{course.price ? `${course.price} ${course.currency}` : 'Бесплатно'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
apps/web/src/app/staff/login/page.tsx
Normal file
128
apps/web/src/app/staff/login/page.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FormEvent, useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Loader2, Lock, Mail, ShieldCheck } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
|
||||||
|
export default function StaffLoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { signIn, backendUser, loading } = useAuth();
|
||||||
|
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [awaitingRole, setAwaitingRole] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!awaitingRole || loading) return;
|
||||||
|
|
||||||
|
if (backendUser?.role === 'ADMIN' || backendUser?.role === 'MODERATOR') {
|
||||||
|
router.push('/admin');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Нет доступа',
|
||||||
|
description: 'Этот вход доступен только администраторам и модераторам',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
router.push('/dashboard');
|
||||||
|
}, [awaitingRole, loading, backendUser, router, toast]);
|
||||||
|
|
||||||
|
const onSubmit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
const { error } = await signIn(email, password);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Ошибка входа',
|
||||||
|
description: error.message || 'Проверьте логин и пароль',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAwaitingRole(true);
|
||||||
|
setSubmitting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-background to-amber-50/40">
|
||||||
|
<div className="container flex min-h-screen items-center justify-center py-8">
|
||||||
|
<Card className="w-full max-w-md border-border/60 shadow-xl">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<ShieldCheck className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Вход для staff</CardTitle>
|
||||||
|
<CardDescription>Отдельная авторизация для админов и модераторов</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="staff-email">Email</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="staff-email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
placeholder="admin@company.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="staff-password">Пароль</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="staff-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="flex flex-col gap-3">
|
||||||
|
<Button type="submit" className="w-full" disabled={submitting || awaitingRole}>
|
||||||
|
{submitting || awaitingRole ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Вход...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Войти в staff'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
Обычный вход для пользователей: <Link href="/login" className="text-primary hover:underline">/login</Link>
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Send, MessageCircle } from 'lucide-react';
|
import { MessageCircle, Send } from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@ -25,15 +25,17 @@ export function CourseChat({ groupId, userId, onSendMessage, messages }: CourseC
|
|||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const groupedMessages = useMemo(() => messages, [messages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [messages]);
|
}, [groupedMessages]);
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!newMessage.trim() || sending) return;
|
if (!newMessage.trim() || sending) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
await onSendMessage(newMessage);
|
await onSendMessage(newMessage.trim());
|
||||||
setNewMessage('');
|
setNewMessage('');
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
@ -41,44 +43,60 @@ export function CourseChat({ groupId, userId, onSendMessage, messages }: CourseC
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col h-[500px]">
|
<Card className="flex h-[560px] flex-col overflow-hidden border-border/60">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="border-b bg-muted/25 pb-3">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center justify-between text-base">
|
||||||
<MessageCircle className="h-4 w-4" />
|
<span className="flex items-center gap-2">
|
||||||
Чат курса
|
<MessageCircle className="h-4 w-4" />
|
||||||
|
Чат курса
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Группа {groupId.slice(0, 6)}</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 flex flex-col min-h-0 p-4">
|
|
||||||
<div className="flex-1 overflow-auto space-y-3 mb-3">
|
<CardContent className="flex min-h-0 flex-1 flex-col p-4">
|
||||||
{messages.map((msg) => {
|
<div className="mb-3 flex-1 space-y-3 overflow-auto pr-2">
|
||||||
|
{groupedMessages.map((msg) => {
|
||||||
const isOwn = msg.user.id === userId;
|
const isOwn = msg.user.id === userId;
|
||||||
return (
|
return (
|
||||||
<div key={msg.id} className={cn('flex gap-2', isOwn && 'flex-row-reverse')}>
|
<div key={msg.id} className={cn('flex', isOwn ? 'justify-end' : 'justify-start')}>
|
||||||
<div className="h-8 w-8 shrink-0 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold text-primary">
|
<div className={cn('max-w-[80%] space-y-1', isOwn ? 'items-end' : 'items-start')}>
|
||||||
{msg.user.name?.[0] || 'U'}
|
<p className="text-xs text-muted-foreground">{msg.user.name || 'Участник'}</p>
|
||||||
</div>
|
<div
|
||||||
<div className={cn('flex flex-col gap-1 max-w-[70%]', isOwn && 'items-end')}>
|
className={cn(
|
||||||
<span className="text-xs text-muted-foreground">{msg.user.name || 'Аноним'}</span>
|
'rounded-2xl px-3 py-2 text-sm shadow-sm',
|
||||||
<div className={cn('rounded-lg px-3 py-2 text-sm', isOwn ? 'bg-primary text-primary-foreground' : 'bg-muted')}>
|
isOwn
|
||||||
|
? 'rounded-br-md bg-primary text-primary-foreground'
|
||||||
|
: 'rounded-bl-md border bg-card'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{msg.content}
|
{msg.content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{groupedMessages.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Сообщений пока нет. Начните обсуждение.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
|
||||||
|
<div className="flex gap-2 border-t pt-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newMessage}
|
value={newMessage}
|
||||||
onChange={(e) => setNewMessage(e.target.value)}
|
onChange={(e) => setNewMessage(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
|
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
|
||||||
placeholder="Написать сообщение..."
|
placeholder="Написать сообщение..."
|
||||||
className="flex-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
className="h-10 flex-1 rounded-xl border bg-background px-3 text-sm"
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
/>
|
/>
|
||||||
<Button size="sm" onClick={handleSend} disabled={sending || !newMessage.trim()}>
|
<Button size="sm" className="h-10 rounded-xl px-4" onClick={handleSend} disabled={sending || !newMessage.trim()}>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Bell, Menu, LogOut, Settings, CreditCard } from 'lucide-react';
|
import { Bell, Menu, LogOut, Settings, CreditCard, ShieldCheck } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import {
|
import {
|
||||||
@ -15,7 +15,8 @@ import {
|
|||||||
import { useAuth } from '@/contexts/auth-context';
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
|
|
||||||
export function DashboardHeader() {
|
export function DashboardHeader() {
|
||||||
const { user, signOut } = useAuth();
|
const { user, signOut, backendUser } = useAuth();
|
||||||
|
const isStaff = backendUser?.role === 'ADMIN' || backendUser?.role === 'MODERATOR';
|
||||||
|
|
||||||
const initials = user?.user_metadata?.full_name
|
const initials = user?.user_metadata?.full_name
|
||||||
?.split(' ')
|
?.split(' ')
|
||||||
@ -24,7 +25,7 @@ export function DashboardHeader() {
|
|||||||
.toUpperCase() || user?.email?.[0]?.toUpperCase() || 'U';
|
.toUpperCase() || user?.email?.[0]?.toUpperCase() || 'U';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-16 items-center justify-between border-b px-6">
|
<header className="flex h-16 items-center justify-between border-b border-border/60 bg-background/80 px-6 backdrop-blur">
|
||||||
{/* Mobile menu button */}
|
{/* Mobile menu button */}
|
||||||
<Button variant="ghost" size="icon" className="lg:hidden">
|
<Button variant="ghost" size="icon" className="lg:hidden">
|
||||||
<Menu className="h-5 w-5" />
|
<Menu className="h-5 w-5" />
|
||||||
@ -36,6 +37,14 @@ export function DashboardHeader() {
|
|||||||
|
|
||||||
{/* Right side */}
|
{/* Right side */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
{isStaff ? (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link href="/admin">
|
||||||
|
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||||
|
Админ Панель
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
<Bell className="h-5 w-5" />
|
<Bell className="h-5 w-5" />
|
||||||
|
|||||||
@ -5,27 +5,26 @@ import { usePathname } from 'next/navigation';
|
|||||||
import {
|
import {
|
||||||
Sparkles,
|
Sparkles,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
BookOpen,
|
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
Compass,
|
|
||||||
Settings,
|
Settings,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Plus,
|
Plus,
|
||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
|
Globe2,
|
||||||
|
ShieldCheck,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useAuth } from '@/contexts/auth-context';
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'Обзор', href: '/dashboard', icon: LayoutDashboard, exact: true },
|
{ name: 'Мои курсы', href: '/dashboard', icon: LayoutDashboard, exact: true },
|
||||||
{ name: 'Мои курсы', href: '/dashboard', icon: BookOpen, exact: true },
|
|
||||||
{ name: 'Курсы', href: '/dashboard/catalog', icon: Compass },
|
|
||||||
{ name: 'Мои обучения', href: '/dashboard/learning', icon: GraduationCap },
|
{ name: 'Мои обучения', href: '/dashboard/learning', icon: GraduationCap },
|
||||||
{ name: 'Поддержка', href: '/dashboard/support', icon: LifeBuoy },
|
{ name: 'Курсы', href: '/courses', icon: Globe2, external: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
const bottomNavigation = [
|
const bottomNavigation = [
|
||||||
|
{ name: 'Поддержка', href: '/dashboard/support', icon: LifeBuoy },
|
||||||
{ name: 'Настройки', href: '/dashboard/settings', icon: Settings },
|
{ name: 'Настройки', href: '/dashboard/settings', icon: Settings },
|
||||||
{ name: 'Подписка', href: '/dashboard/billing', icon: CreditCard },
|
{ name: 'Подписка', href: '/dashboard/billing', icon: CreditCard },
|
||||||
];
|
];
|
||||||
@ -36,7 +35,7 @@ export function Sidebar() {
|
|||||||
const isStaff = backendUser?.role === 'ADMIN' || backendUser?.role === 'MODERATOR';
|
const isStaff = backendUser?.role === 'ADMIN' || backendUser?.role === 'MODERATOR';
|
||||||
|
|
||||||
const effectiveBottomNavigation = isStaff
|
const effectiveBottomNavigation = isStaff
|
||||||
? [{ name: 'Админ поддержка', href: '/dashboard/admin/support', icon: LifeBuoy }, ...bottomNavigation]
|
? [{ name: 'Админ Панель', href: '/admin', icon: ShieldCheck }, ...bottomNavigation]
|
||||||
: bottomNavigation;
|
: bottomNavigation;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
273
apps/web/src/components/dashboard/support-widget.tsx
Normal file
273
apps/web/src/components/dashboard/support-widget.tsx
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
import { ChevronLeft, LifeBuoy, MessageCircle, Minus, Plus, Send } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { getWsBaseUrl } from '@/lib/ws';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type Ticket = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TicketMessage = {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
isStaff: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
user?: { id?: string; name?: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SupportWidget() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||||
|
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null);
|
||||||
|
const [messages, setMessages] = useState<TicketMessage[]>([]);
|
||||||
|
const [newMessage, setNewMessage] = useState('');
|
||||||
|
const [ticketTitle, setTicketTitle] = useState('');
|
||||||
|
const [ticketText, setTicketText] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
|
||||||
|
const selectedTicket = useMemo(
|
||||||
|
() => tickets.find((ticket) => ticket.id === selectedTicketId) || null,
|
||||||
|
[tickets, selectedTicketId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadTickets = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.getMySupportTickets().catch(() => []);
|
||||||
|
setTickets(data || []);
|
||||||
|
if (!selectedTicketId && data?.length) {
|
||||||
|
setSelectedTicketId(data[0].id);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
loadTickets();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !selectedTicketId) return;
|
||||||
|
|
||||||
|
api
|
||||||
|
.getSupportTicketMessages(selectedTicketId)
|
||||||
|
.then((data) => setMessages(data || []))
|
||||||
|
.catch(() => setMessages([]));
|
||||||
|
|
||||||
|
const token =
|
||||||
|
typeof window !== 'undefined' ? sessionStorage.getItem('coursecraft_api_token') || undefined : undefined;
|
||||||
|
const socket = io(`${getWsBaseUrl()}/ws/support`, {
|
||||||
|
transports: ['websocket'],
|
||||||
|
auth: { token },
|
||||||
|
});
|
||||||
|
socketRef.current = socket;
|
||||||
|
socket.emit('support:join', { ticketId: selectedTicketId });
|
||||||
|
socket.on('support:new-message', (msg: TicketMessage) => {
|
||||||
|
setMessages((prev) => [...prev, msg]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.disconnect();
|
||||||
|
socketRef.current = null;
|
||||||
|
};
|
||||||
|
}, [open, selectedTicketId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages, selectedTicketId]);
|
||||||
|
|
||||||
|
const createTicket = async () => {
|
||||||
|
if (!ticketTitle.trim()) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await api.createSupportTicket({
|
||||||
|
title: ticketTitle.trim(),
|
||||||
|
initialMessage: ticketText.trim() || undefined,
|
||||||
|
});
|
||||||
|
setTicketTitle('');
|
||||||
|
setTicketText('');
|
||||||
|
setCreating(false);
|
||||||
|
await loadTickets();
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMessage = async () => {
|
||||||
|
if (!selectedTicketId || !newMessage.trim()) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await api.sendSupportMessage(selectedTicketId, newMessage.trim());
|
||||||
|
setNewMessage('');
|
||||||
|
const latest = await api.getSupportTicketMessages(selectedTicketId).catch(() => []);
|
||||||
|
setMessages(latest || []);
|
||||||
|
await loadTickets();
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-40">
|
||||||
|
{open ? (
|
||||||
|
<div className="w-[360px] rounded-2xl border border-border/70 bg-background/95 shadow-2xl backdrop-blur-xl">
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="rounded-lg bg-primary/15 p-1.5 text-primary">
|
||||||
|
<LifeBuoy className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">Поддержка</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Тикеты и ответы</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={() => setOpen(false)}>
|
||||||
|
<Minus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selectedTicketId || creating ? (
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium">Новый тикет</p>
|
||||||
|
{!creating ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setCreating(true)}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
Создать
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
value={ticketTitle}
|
||||||
|
onChange={(e) => setTicketTitle(e.target.value)}
|
||||||
|
placeholder="Тема тикета"
|
||||||
|
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={ticketText}
|
||||||
|
onChange={(e) => setTicketText(e.target.value)}
|
||||||
|
placeholder="Опишите вопрос"
|
||||||
|
className="min-h-[110px] w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<Button size="sm" className="w-full" disabled={sending} onClick={createTicket}>
|
||||||
|
Отправить тикет
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Мои тикеты
|
||||||
|
</p>
|
||||||
|
<div className="max-h-[260px] space-y-2 overflow-auto pr-1">
|
||||||
|
{loading ? <p className="text-xs text-muted-foreground">Загрузка...</p> : null}
|
||||||
|
{tickets.map((ticket) => (
|
||||||
|
<button
|
||||||
|
key={ticket.id}
|
||||||
|
onClick={() => setSelectedTicketId(ticket.id)}
|
||||||
|
className="w-full rounded-xl border px-3 py-2 text-left transition hover:bg-muted/60"
|
||||||
|
>
|
||||||
|
<p className="line-clamp-1 text-sm font-medium">{ticket.title}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Статус: {ticket.status}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!loading && tickets.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">Пока нет тикетов</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[500px] flex-col">
|
||||||
|
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => setSelectedTicketId(null)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{selectedTicket?.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Статус: {selectedTicket?.status}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-2 overflow-auto p-3">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={cn('flex', msg.isStaff ? 'justify-start' : 'justify-end')}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'max-w-[80%] rounded-2xl px-3 py-2 text-sm',
|
||||||
|
msg.isStaff ? 'bg-muted text-foreground' : 'bg-primary text-primary-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p>{msg.content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
|
||||||
|
<MessageCircle className="mr-2 h-4 w-4" />
|
||||||
|
Сообщений пока нет
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t p-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={newMessage}
|
||||||
|
onChange={(e) => setNewMessage(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
|
||||||
|
placeholder="Ваш ответ"
|
||||||
|
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<Button size="icon" className="h-9 w-9" onClick={sendMessage} disabled={sending}>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
className="h-12 w-12 rounded-full shadow-lg"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
aria-label="Открыть поддержку"
|
||||||
|
>
|
||||||
|
<LifeBuoy className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -3,9 +3,10 @@ import { Sparkles } from 'lucide-react';
|
|||||||
|
|
||||||
const navigation = {
|
const navigation = {
|
||||||
product: [
|
product: [
|
||||||
{ name: 'Возможности', href: '#features' },
|
{ name: 'Курсы', href: '/courses' },
|
||||||
{ name: 'Тарифы', href: '#pricing' },
|
{ name: 'Возможности', href: '/#features' },
|
||||||
{ name: 'FAQ', href: '#faq' },
|
{ name: 'Тарифы', href: '/#pricing' },
|
||||||
|
{ name: 'FAQ', href: '/#faq' },
|
||||||
],
|
],
|
||||||
company: [
|
company: [
|
||||||
{ name: 'О нас', href: '/about' },
|
{ name: 'О нас', href: '/about' },
|
||||||
|
|||||||
@ -9,10 +9,11 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useAuth } from '@/contexts/auth-context';
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'Возможности', href: '#features' },
|
{ name: 'Курсы', href: '/courses' },
|
||||||
{ name: 'Как это работает', href: '#how-it-works' },
|
{ name: 'Возможности', href: '/#features' },
|
||||||
{ name: 'Тарифы', href: '#pricing' },
|
{ name: 'Как это работает', href: '/#how-it-works' },
|
||||||
{ name: 'FAQ', href: '#faq' },
|
{ name: 'Тарифы', href: '/#pricing' },
|
||||||
|
{ name: 'FAQ', href: '/#faq' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
|
|||||||
@ -419,6 +419,38 @@ class ApiClient {
|
|||||||
return this.request<any>(`/moderation/reviews/${reviewId}/unhide`, { method: 'POST' });
|
return this.request<any>(`/moderation/reviews/${reviewId}/unhide`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getModerationCourses(params?: { status?: string; search?: string }) {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.status) searchParams.set('status', params.status);
|
||||||
|
if (params?.search) searchParams.set('search', params.search);
|
||||||
|
const query = searchParams.toString();
|
||||||
|
return this.request<any[]>(`/moderation/courses${query ? `?${query}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPendingModerationCourses() {
|
||||||
|
return this.request<any[]>('/moderation/pending');
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveModerationCourse(courseId: string, note?: string) {
|
||||||
|
return this.request<any>(`/moderation/${courseId}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ note }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectModerationCourse(courseId: string, reason: string) {
|
||||||
|
return this.request<any>(`/moderation/${courseId}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ reason }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteModerationCourse(courseId: string) {
|
||||||
|
return this.request<void>(`/moderation/${courseId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
async searchCourses(query: string, filters?: { category?: string; difficulty?: string }) {
|
async searchCourses(query: string, filters?: { category?: string; difficulty?: string }) {
|
||||||
const searchParams = new URLSearchParams({ q: query });
|
const searchParams = new URLSearchParams({ q: query });
|
||||||
|
|||||||
@ -62,8 +62,8 @@ export async function middleware(request: NextRequest) {
|
|||||||
data: { session },
|
data: { session },
|
||||||
} = await supabase.auth.getSession();
|
} = await supabase.auth.getSession();
|
||||||
|
|
||||||
// Protect dashboard routes
|
// Protect dashboard and admin routes
|
||||||
if (request.nextUrl.pathname.startsWith('/dashboard')) {
|
if (request.nextUrl.pathname.startsWith('/dashboard') || request.nextUrl.pathname.startsWith('/admin')) {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.redirect(new URL('/login', request.url));
|
return NextResponse.redirect(new URL('/login', request.url));
|
||||||
}
|
}
|
||||||
@ -80,5 +80,5 @@ export async function middleware(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ['/dashboard/:path*', '/login', '/register'],
|
matcher: ['/dashboard/:path*', '/admin/:path*', '/login', '/register'],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user