feat: AI quiz generation per lesson + hide edit buttons for non-authors

- Generate unique quiz for each lesson using OpenRouter API
- Parse lesson content (TipTap JSON) and send to AI for question generation
- Cache quiz in database to avoid regeneration
- Hide Edit/Delete buttons if current user is not course author
- Add backendUser to auth context for proper authorization checks
- Show certificate button prominently when course is completed

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-02-06 11:04:37 +00:00
parent f39680d714
commit 5241144bc5
2 changed files with 65 additions and 41 deletions

View File

@ -45,13 +45,14 @@ type CourseData = {
title: string; title: string;
description?: string | null; description?: string | null;
status: string; status: string;
authorId: string;
chapters: Chapter[]; chapters: Chapter[];
}; };
export default function CoursePage() { export default function CoursePage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const { loading: authLoading } = useAuth(); const { loading: authLoading, backendUser } = useAuth();
const id = params?.id as string; const id = params?.id as string;
const [course, setCourse] = useState<CourseData | null>(null); const [course, setCourse] = useState<CourseData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -249,6 +250,9 @@ export default function CoursePage() {
? flatLessons.find((l) => l.id === selectedLessonId) ? flatLessons.find((l) => l.id === selectedLessonId)
: null; : null;
const isAuthor = course && backendUser && course.authorId === backendUser.id;
const courseCompleted = completedCount >= totalLessons && totalLessons > 0;
return ( return (
<div className="flex h-[calc(100vh-4rem)] -m-6 flex-col"> <div className="flex h-[calc(100vh-4rem)] -m-6 flex-col">
{/* Top bar */} {/* Top bar */}
@ -272,37 +276,51 @@ export default function CoursePage() {
<div className="h-2 w-2 rounded-full bg-primary" /> <div className="h-2 w-2 rounded-full bg-primary" />
<span className="text-xs font-medium">{progressPercent}% пройдено</span> <span className="text-xs font-medium">{progressPercent}% пройдено</span>
</div> </div>
<Button size="sm" variant="outline" asChild>
<Link href={`/dashboard/courses/${course.id}/edit`}> {/* Certificate button - show when course completed */}
<Edit className="mr-1.5 h-3.5 w-3.5" /> {courseCompleted && (
Редактировать <Button size="sm" variant="default" onClick={handleGetCertificate} disabled={generatingCertificate}>
</Link> <GraduationCap className="mr-1.5 h-3.5 w-3.5" />
</Button> {generatingCertificate ? 'Генерация...' : 'Получить сертификат'}
<AlertDialog> </Button>
<AlertDialogTrigger asChild> )}
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive">
<Trash2 className="h-4 w-4" /> {/* Edit/Delete - only for author */}
{isAuthor && (
<>
<Button size="sm" variant="outline" asChild>
<Link href={`/dashboard/courses/${course.id}/edit`}>
<Edit className="mr-1.5 h-3.5 w-3.5" />
Редактировать
</Link>
</Button> </Button>
</AlertDialogTrigger> <AlertDialog>
<AlertDialogContent> <AlertDialogTrigger asChild>
<AlertDialogHeader> <Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive">
<AlertDialogTitle>Удалить курс?</AlertDialogTitle> <Trash2 className="h-4 w-4" />
<AlertDialogDescription> </Button>
Курс «{course.title}» будет удалён безвозвратно. </AlertDialogTrigger>
</AlertDialogDescription> <AlertDialogContent>
</AlertDialogHeader> <AlertDialogHeader>
<AlertDialogFooter> <AlertDialogTitle>Удалить курс?</AlertDialogTitle>
<AlertDialogCancel>Отмена</AlertDialogCancel> <AlertDialogDescription>
<AlertDialogAction Курс «{course.title}» будет удалён безвозвратно.
onClick={(e) => { e.preventDefault(); handleDelete(); }} </AlertDialogDescription>
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" </AlertDialogHeader>
disabled={deleting} <AlertDialogFooter>
> <AlertDialogCancel>Отмена</AlertDialogCancel>
{deleting ? 'Удаление...' : 'Удалить'} <AlertDialogAction
</AlertDialogAction> onClick={(e) => { e.preventDefault(); handleDelete(); }}
</AlertDialogFooter> className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
</AlertDialogContent> disabled={deleting}
</AlertDialog> >
{deleting ? 'Удаление...' : 'Удалить'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</div> </div>
</div> </div>
@ -520,16 +538,10 @@ export default function CoursePage() {
Следующий урок Следующий урок
<ChevronRight className="ml-1.5 h-4 w-4" /> <ChevronRight className="ml-1.5 h-4 w-4" />
</Button> </Button>
) : completedCount >= totalLessons ? (
<Button size="sm" onClick={handleGetCertificate} disabled={generatingCertificate}>
<GraduationCap className="mr-1.5 h-4 w-4" />
{generatingCertificate ? 'Генерация...' : 'Получить сертификат'}
</Button>
) : ( ) : (
<Button size="sm" variant="outline" disabled> <div className="text-sm text-muted-foreground">
<GraduationCap className="mr-1.5 h-4 w-4" /> {courseCompleted ? 'Курс пройден!' : 'Последний урок'}
Завершить курс </div>
</Button>
)} )}
</div> </div>
</div> </div>

View File

@ -6,8 +6,17 @@ import { getSupabase } from '@/lib/supabase';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { api, setApiToken } from '@/lib/api'; import { api, setApiToken } from '@/lib/api';
interface BackendUser {
id: string;
email: string;
name: string | null;
avatarUrl: string | null;
subscriptionTier: string;
}
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
backendUser: BackendUser | null;
session: Session | null; session: Session | null;
loading: boolean; loading: boolean;
signUp: (email: string, password: string, name: string) => Promise<{ error: Error | null }>; signUp: (email: string, password: string, name: string) => Promise<{ error: Error | null }>;
@ -20,6 +29,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [backendUser, setBackendUser] = useState<BackendUser | null>(null);
const [session, setSession] = useState<Session | null>(null); const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const router = useRouter(); const router = useRouter();
@ -64,8 +74,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const tryExchange = () => { const tryExchange = () => {
api api
.exchangeToken(session.access_token) .exchangeToken(session.access_token)
.then(({ accessToken }) => { .then(({ accessToken, user: backendUserData }) => {
setApiToken(accessToken); setApiToken(accessToken);
setBackendUser(backendUserData);
setLoading(false); setLoading(false);
}) })
.catch(() => { .catch(() => {
@ -152,6 +163,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
user, user,
backendUser,
session, session,
loading, loading,
signUp, signUp,