project init

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

View File

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