feat: restore landing style and add separate courses/admin UX
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user