feat: implement Notion-like LMS (Course Player, Exam, Certificate) with R2 integration

This commit is contained in:
2026-01-24 11:11:19 +07:00
parent 594f3727f5
commit 27fc78e811
17 changed files with 1139 additions and 30 deletions

View File

@@ -0,0 +1,112 @@
import React, { useState } from 'react';
import { Head, Link, useForm } from '@inertiajs/react';
import { CheckCircle, AlertCircle, RefreshCcw } from 'lucide-react';
import CourseLayout from '@/Layouts/CourseLayout';
import { Button } from '@/Components/ui/button';
import { RadioGroup, RadioGroupItem } from "@/Components/ui/radio-group";
import { Label } from "@/Components/ui/label";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/Components/ui/card";
import { cn } from '@/lib/utils';
export default function Exam({ course, modules, lesson, previousResult, flash }: any) {
const [answers, setAnswers] = useState<Record<string, string>>({});
const { post, processing, wasSuccessful } = useForm();
// We can use flash messages or page props to show result state
const result = flash?.score !== undefined ? flash : (previousResult ? { score: previousResult, passed: previousResult >= 70 } : null);
const handleSelect = (questionId: string, value: string) => {
setAnswers(prev => ({ ...prev, [questionId]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
post(route('exams.store', { course: course.slug, lesson: lesson.slug, answers }), {
preserveScroll: true,
});
};
return (
<CourseLayout course={course} modules={modules} currentLesson={lesson}>
<Head title={`Ujian: ${lesson.title}`} />
<div className="max-w-2xl mx-auto space-y-8 pb-20">
<div className="text-center space-y-2">
<h1 className="text-3xl font-black tracking-tight">{lesson.title}</h1>
<p className="text-muted-foreground">Jawab semua pertanyaan untuk menyelesaikan kursus ini.</p>
</div>
{result && (
<div className={cn(
"p-6 rounded-2xl border flex flex-col items-center text-center gap-4 animate-in zoom-in-50 duration-500",
result.passed ? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-900" : "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-900"
)}>
{result.passed ? (
<div className="h-16 w-16 bg-green-100 text-green-600 rounded-full flex items-center justify-center dark:bg-green-800 dark:text-green-200">
<CheckCircle size={32} />
</div>
) : (
<div className="h-16 w-16 bg-red-100 text-red-600 rounded-full flex items-center justify-center dark:bg-red-800 dark:text-red-200">
<AlertCircle size={32} />
</div>
)}
<div>
<h2 className="text-xl font-bold">{result.passed ? "Lulus!" : "Belum Lulus"}</h2>
<p className="font-medium text-muted-foreground">Skor Anda: <span className="text-foreground font-black text-lg">{result.score}%</span></p>
</div>
{result.passed ? (
<Button size="lg" className="rounded-full font-bold" disabled>
Sertifikat Terbit
</Button>
) : (
<Button size="lg" variant="outline" onClick={() => window.location.reload()} className="rounded-full gap-2">
<RefreshCcw size={16} />
Coba Lagi
</Button>
)}
</div>
)}
{!result?.passed && (
<form onSubmit={handleSubmit} className="space-y-8">
{lesson.questions.map((q: any, i: number) => (
<Card key={q.id} className="border-border/50 shadow-sm">
<CardHeader>
<CardTitle className="text-base font-bold leading-relaxed">
<span className="text-muted-foreground mr-2">{i + 1}.</span>
{q.question}
</CardTitle>
</CardHeader>
<CardContent>
<RadioGroup value={answers[q.id]} onValueChange={(val: string) => handleSelect(q.id, val)}>
{q.options.map((opt: string, idx: number) => (
<div key={idx} className="flex items-center space-x-2 border rounded-xl p-3 hover:bg-muted/50 transition-colors cursor-pointer">
<RadioGroupItem value={opt} id={`q${q.id}-opt${idx}`} />
<Label htmlFor={`q${q.id}-opt${idx}`} className="flex-1 cursor-pointer font-medium">{opt}</Label>
</div>
))}
</RadioGroup>
</CardContent>
</Card>
))}
<div className="flex justify-end pt-4">
<Button
type="submit"
size="lg"
className="w-full sm:w-auto rounded-full font-bold px-8"
disabled={processing}
>
{processing ? 'Memeriksa...' : 'Kirim Jawaban'}
</Button>
</div>
</form>
)}
</div>
</CourseLayout>
);
}

View File

@@ -0,0 +1,188 @@
import React, { useState } from 'react';
import { Head, Link } from '@inertiajs/react';
import {
ChevronLeft,
ChevronRight,
CheckCircle,
FileText,
Download,
Maximize2
} from 'lucide-react';
import CourseLayout from '@/Layouts/CourseLayout';
import { Button } from '@/Components/ui/button';
import { Separator } from '@/Components/ui/separator';
import { Badge } from '@/Components/ui/badge';
// Interfaces (Should ideally be shared/exported)
interface Lesson {
id: string;
title: string;
slug: string;
type: 'video' | 'text' | 'quiz' | 'pdf';
content?: string;
video_url?: string;
content_pdf?: string; // URL to PDF
is_completed: boolean;
duration_seconds: number;
next_lesson_slug?: string;
prev_lesson_slug?: string;
attachments?: Array<{ id: string, name: string, url: string, size: string }>;
}
interface PageProps {
course: any;
modules: any[];
lesson: Lesson;
}
export default function Learn({ course, modules, lesson }: PageProps) {
const [isCompleted, setIsCompleted] = useState(lesson.is_completed);
const handleComplete = () => {
// Optimistic UI update
setIsCompleted(true);
// In real app: router.post(route('lessons.complete', lesson.id))
};
return (
<CourseLayout course={course} modules={modules} currentLesson={lesson}>
<Head title={`${lesson.title} - ${course.title}`} />
{/* Header Content */}
<div className="mb-8 space-y-4">
<div className="flex items-center gap-2 text-muted-foreground text-sm uppercase tracking-wider font-bold">
<span className="bg-primary/10 text-primary px-2 py-0.5 rounded text-[10px]">{lesson.type}</span>
<span></span>
<span>{Math.ceil(lesson.duration_seconds / 60)} min read</span>
</div>
<h1 className="text-3xl md:text-4xl font-sans font-black tracking-tight text-foreground leading-tight">
{lesson.title}
</h1>
</div>
{/* Main Content Render */}
<div className="space-y-8">
{lesson.type === 'video' && lesson.video_url && (
<div className="relative aspect-video rounded-2xl overflow-hidden shadow-2xl bg-black border border-border/50 group">
<iframe
src={lesson.video_url}
className="w-full h-full"
allowFullScreen
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
title={lesson.title}
/>
</div>
)}
{lesson.type === 'pdf' && lesson.content_pdf && (
<div className="rounded-2xl border border-border/50 overflow-hidden bg-card h-[80vh] flex flex-col shadow-sm">
<div className="bg-muted/30 border-b p-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<FileText size={16} />
<span className="truncate max-w-[200px]">{lesson.title}.pdf</span>
</div>
<Button size="sm" variant="ghost" asChild>
<a href={lesson.content_pdf} target="_blank" rel="noopener noreferrer">
<Maximize2 size={16} className="mr-2" />
Buka Full
</a>
</Button>
</div>
<iframe
src={`${lesson.content_pdf}#toolbar=0`}
className="w-full h-full bg-white"
title="PDF Viewer"
/>
</div>
)}
{/* Text Content (Notion-style blocks) */}
{lesson.content && (
<article className="prose prose-lg dark:prose-invert prose-headings:font-bold prose-headings:tracking-tight prose-p:leading-relaxed prose-img:rounded-xl max-w-none">
<div dangerouslySetInnerHTML={{ __html: lesson.content }} />
</article>
)}
{/* Attachments Section */}
{lesson.attachments && lesson.attachments.length > 0 && (
<div className="mt-12 pt-8 border-t">
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
<Download size={20} className="text-primary" />
Materi Pendukung
</h3>
<div className="grid sm:grid-cols-2 gap-4">
{lesson.attachments.map((file) => (
<a
key={file.id}
href={file.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-4 p-4 rounded-xl border bg-card hover:bg-muted/50 transition-all group"
>
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center group-hover:bg-primary/10 group-hover:text-primary transition-colors">
<FileText size={20} />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{file.name}</p>
<p className="text-xs text-muted-foreground">{file.size}</p>
</div>
<Download size={16} className="text-muted-foreground group-hover:text-foreground" />
</a>
))}
</div>
</div>
)}
</div>
{/* Footer / Navigation */}
<div className="mt-16 pt-8 border-t flex flex-col sm:flex-row items-center justify-between gap-6 pb-20">
<div className="w-full sm:w-auto">
{lesson.prev_lesson_slug ? (
<Button variant="ghost" asChild className="w-full sm:w-auto justify-start pl-0 hover:bg-transparent hover:text-primary">
<Link href={route('courses.learn', { course: course.slug, lesson: lesson.prev_lesson_slug })}>
<ChevronLeft className="mr-2 h-4 w-4" />
<div className="flex flex-col items-start gap-0.5">
<span className="text-[10px] text-muted-foreground uppercase tracking-wider font-bold">Sebelumnya</span>
<span className="font-bold line-clamp-1 max-w-[150px]">Pelajaran Sebelumnya</span>
</div>
</Link>
</Button>
) : <div />}
</div>
<div className="flex flex-col sm:flex-row items-center gap-4 w-full sm:w-auto">
{!isCompleted ? (
<Button
size="lg"
className="w-full sm:w-auto rounded-full font-bold shadow-lg shadow-primary/20 hover:shadow-primary/40 transition-all"
onClick={handleComplete}
>
<CheckCircle className="mr-2 h-5 w-5" />
Tandai Selesai
</Button>
) : (
<Button
variant="secondary"
size="lg"
className="w-full sm:w-auto rounded-full font-bold bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-900/50 cursor-default"
>
<CheckCircle className="mr-2 h-5 w-5" />
Sudah Selesai
</Button>
)}
{lesson.next_lesson_slug && (
<Button asChild className="w-full sm:w-auto rounded-full gap-2 font-bold px-6">
<Link href={route('courses.learn', { course: course.slug, lesson: lesson.next_lesson_slug })}>
Berikutnya
<ChevronRight className="h-4 w-4" />
</Link>
</Button>
)}
</div>
</div>
</CourseLayout>
);
}