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

@@ -27,12 +27,37 @@ class LessonForm
Textarea::make('content')
->columnSpanFull(),
TextInput::make('video_url')
->url(),
TextInput::make('content_pdf'),
->url()
->placeholder('https://youtube.com/watch?v=...')
->visible(fn (callable $get) => $get('type') === 'video'),
\Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('content_pdf')
->collection('content_pdf')
->disk('r2')
->visibility('public')
->acceptedFileTypes(['application/pdf'])
->label('Upload PDF Material')
->visible(fn (callable $get) => $get('type') === 'pdf'),
// Legacy URL input if needed, or remove. Keeping it hidden if empty?
// Let's keep it as an alternative or just replace it.
// Assuming we want to fully switch to upload:
// TextInput::make('content_pdf')->label('PDF URL (Legacy)'),
TextInput::make('duration_seconds')
->required()
->numeric()
->default(0),
->default(0)
->suffix('Seconds'),
\Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('attachments')
->collection('attachments')
->disk('r2')
->visibility('public')
->multiple()
->downloadable()
->label('Attachments (Downloadable Resources)'),
Toggle::make('is_free_preview')
->required(),
TextInput::make('order_index')
@@ -40,7 +65,8 @@ class LessonForm
->numeric()
->default(0),
Textarea::make('metadata')
->columnSpanFull(),
->columnSpanFull()
->rows(5),
Select::make('vocabularies')
->relationship('vocabularies', 'word')
->multiple()

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Models\Course;
use App\Models\UserProgress;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Barryvdh\DomPDF\Facade\Pdf; // Optional if we want PDF download, for now just view
class CertificateController extends Controller
{
public function show(Request $request, string $courseSlug)
{
$user = $request->user();
$course = Course::where('slug', $courseSlug)->withCount('lessons')->firstOrFail();
// Check if course is fully completed
// Count completed lessons vs total lessons
$completedCount = UserProgress::where('user_id', $user->id)
->whereIn('lesson_id', $course->lessons()->pluck('id'))
->whereNotNull('completed_at')
->count();
$totalLessons = $course->lessons_count ?? 0;
$progress = ($totalLessons > 0) ? ($completedCount / $totalLessons) * 100 : 0;
// Strict check: Must be 100% complete
if ($progress < 100) {
return redirect()->route('courses.index')->with('error', 'Selesaikan semua materi untuk klaim sertifikat.');
}
return Inertia::render('Certificates/Show', [
'course' => $course,
'student' => $user,
'date' => now()->translatedFormat('d F Y'),
'certificate_id' => 'NB-' . strtoupper(substr($course->slug, 0, 3)) . '-' . $user->id . '-' . date('ymd'),
]);
}
}

View File

@@ -42,6 +42,14 @@ class CoursePlayerController extends Controller
? Lesson::where('slug', $lessonSlug)->with('vocabularies')->firstOrFail()
: $allLessons->first()->load('vocabularies');
// Navigation Logic (Next/Prev)
$currentIndex = $allLessons->search(function ($item) use ($currentLesson) {
return $item->id === $currentLesson->id;
});
$prevLesson = $currentIndex > 0 ? $allLessons[$currentIndex - 1] : null;
$nextLesson = $currentIndex < $allLessons->count() - 1 ? $allLessons[$currentIndex + 1] : null;
// Get user progress for this course
$completedLessonsIds = UserProgress::where('user_id', $user->id)
->whereIn('lesson_id', $allLessons->pluck('id'))
@@ -55,42 +63,52 @@ class CoursePlayerController extends Controller
['started_at' => now(), 'last_heartbeat_at' => now()]
);
return Inertia::render('Courses/Player', [
return Inertia::render('Courses/Learn', [
'course' => [
'id' => $course->id,
'title' => $course->title,
'slug' => $course->slug,
'modules' => $course->modules->map(function ($module) use ($completedLessonsIds) {
return [
'id' => $module->id,
'title' => $module->title,
'lessons' => $module->lessons->map(function ($lesson) use ($completedLessonsIds) {
return [
'id' => $lesson->id,
'title' => $lesson->title,
'slug' => $lesson->slug,
'type' => $lesson->type,
'is_completed' => in_array($lesson->id, $completedLessonsIds),
];
})
];
})
'progress_percentage' => count($allLessons) > 0 ? round((count($completedLessonsIds) / count($allLessons)) * 100) : 0,
],
'currentLesson' => [
'modules' => $course->modules->map(function ($module) use ($completedLessonsIds) {
return [
'id' => $module->id,
'title' => $module->title,
'lessons' => $module->lessons->map(function ($lesson) use ($completedLessonsIds) {
return [
'id' => $lesson->id,
'title' => $lesson->title,
'slug' => $lesson->slug,
'type' => $lesson->type,
'duration_seconds' => $lesson->duration_seconds,
'is_completed' => in_array($lesson->id, $completedLessonsIds),
];
})
];
}),
'lesson' => [
'id' => $currentLesson->id,
'title' => $currentLesson->title,
'slug' => $currentLesson->slug,
'type' => $currentLesson->type,
'content' => $currentLesson->content,
'video_url' => $currentLesson->video_url,
'content_pdf' => $currentLesson->content_pdf,
// Prefer Spatie Media Library URL (R2), fallback to legacy column
'content_pdf' => $currentLesson->getFirstMediaUrl('content_pdf') ?: $currentLesson->content_pdf,
'duration_seconds' => $currentLesson->duration_seconds,
'is_completed' => in_array($currentLesson->id, $completedLessonsIds),
'vocabularies' => $currentLesson->vocabularies,
'next_lesson_slug' => $nextLesson?->slug,
'prev_lesson_slug' => $prevLesson?->slug,
'attachments' => $currentLesson->getMedia('attachments')->map(function ($media) {
return [
'id' => $media->uuid,
'name' => $media->file_name,
'url' => $media->getUrl(),
'size' => $media->human_readable_size,
];
}),
],
'progress' => [
'completed_count' => count($completedLessonsIds),
'total_count' => count($allLessons),
'percentage' => count($allLessons) > 0 ? round((count($completedLessonsIds) / count($allLessons)) * 100) : 0,
]
]);
}

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers;
use App\Models\Course;
use App\Models\Lesson;
use App\Models\UserProgress;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ExamController extends Controller
{
/**
* Show the exam interface.
*/
public function show(Request $request, string $courseSlug, string $lessonSlug): Response
{
$user = $request->user();
$course = Course::where('slug', $courseSlug)->firstOrFail();
$lesson = Lesson::where('slug', $lessonSlug)->where('type', 'quiz')->firstOrFail();
// Check if already completed
$progress = UserProgress::where('user_id', $user->id)
->where('lesson_id', $lesson->id)
->first();
$previousResut = null;
if ($progress && $progress->completed_at) {
$previousResut = $progress->metadata['score'] ?? 0;
}
// Parse questions from metadata
// Assuming structure: metadata->questions = [{ id, question, options[], correct_answer }]
// We do NOT send 'correct_answer' to frontend
$questions = collect($lesson->metadata['questions'] ?? [])->map(function ($q) {
return [
'id' => $q['id'],
'question' => $q['question'],
'options' => $q['options'],
];
});
// Use same layout data as CoursePlayer (simplified modules list)
// Ideally refactor this shared logic or use a Service
$modules = $course->modules()->with('lessons')->get()->map(function ($module) use ($user) {
return [
'id' => $module->id,
'title' => $module->title,
'lessons' => $module->lessons->map(function ($l) {
return [
'id' => $l->id,
'title' => $l->title,
'slug' => $l->slug,
'type' => $l->type,
'is_completed' => false // Simplified for now
];
})
];
});
return Inertia::render('Courses/Exam', [
'course' => [
'title' => $course->title,
'slug' => $course->slug,
],
'modules' => $modules,
'lesson' => [
'id' => $lesson->id,
'title' => $lesson->title,
'slug' => $lesson->slug,
'questions' => $questions,
],
'previousResult' => $previousResut,
]);
}
/**
* Handle exam submission.
*/
public function store(Request $request, string $courseSlug, string $lessonSlug)
{
$lesson = Lesson::where('slug', $lessonSlug)->firstOrFail();
$user = $request->user();
$answers = $request->input('answers', []); // [question_id => selected_option]
$questions = $lesson->metadata['questions'] ?? [];
$score = 0;
$total = count($questions);
$correctAnswers = 0;
foreach ($questions as $q) {
$userAnswer = $answers[$q['id']] ?? null;
if ($userAnswer === $q['correct_answer']) {
$correctAnswers++;
}
}
$minPassPercentage = 70; // Hardcoded requirement for certificate
$accuracy = ($total > 0) ? round(($correctAnswers / $total) * 100) : 0;
$passed = $accuracy >= $minPassPercentage;
// Save progress with score
UserProgress::updateOrCreate(
['user_id' => $user->id, 'lesson_id' => $lesson->id],
[
'completed_at' => $passed ? now() : null, // Only mark complete if passed? Or mark complete but with failing score? Let's say only if passed.
'metadata' => [
'score' => $accuracy,
'answers' => $answers, // Store user answers if needed for review
'passed' => $passed
]
]
);
if ($passed) {
$user->increment('xp_points', 100);
}
return back()->with('flash', [
'score' => $accuracy,
'passed' => $passed,
'message' => $passed ? 'Selamat! Anda lulus ujian.' : 'Maaf, nilai anda belum mencukupi. Silahkan coba lagi.'
]);
}
}

View File

@@ -7,10 +7,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class Lesson extends Model
class Lesson extends Model implements HasMedia
{
use HasFactory, HasUuids, SoftDeletes;
use HasFactory, HasUuids, SoftDeletes, InteractsWithMedia;
protected $guarded = [];

View File

@@ -52,6 +52,9 @@ class AdminPanelProvider extends PanelProvider
])
->authMiddleware([
Authenticate::class,
])
->plugins([
\Filament\SpatieLaravelMediaLibraryPlugin::make(),
]);
}
}