diff --git a/app/Filament/Admin/Resources/Lessons/Schemas/LessonForm.php b/app/Filament/Admin/Resources/Lessons/Schemas/LessonForm.php index 8b75b91..a353307 100644 --- a/app/Filament/Admin/Resources/Lessons/Schemas/LessonForm.php +++ b/app/Filament/Admin/Resources/Lessons/Schemas/LessonForm.php @@ -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() diff --git a/app/Http/Controllers/CertificateController.php b/app/Http/Controllers/CertificateController.php new file mode 100644 index 0000000..51de75d --- /dev/null +++ b/app/Http/Controllers/CertificateController.php @@ -0,0 +1,40 @@ +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'), + ]); + } +} diff --git a/app/Http/Controllers/CoursePlayerController.php b/app/Http/Controllers/CoursePlayerController.php index 6a71490..697c752 100644 --- a/app/Http/Controllers/CoursePlayerController.php +++ b/app/Http/Controllers/CoursePlayerController.php @@ -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, - ] ]); } diff --git a/app/Http/Controllers/ExamController.php b/app/Http/Controllers/ExamController.php new file mode 100644 index 0000000..7cad7fd --- /dev/null +++ b/app/Http/Controllers/ExamController.php @@ -0,0 +1,129 @@ +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.' + ]); + } +} diff --git a/app/Models/Lesson.php b/app/Models/Lesson.php index 5bd3b86..b3d0fd8 100644 --- a/app/Models/Lesson.php +++ b/app/Models/Lesson.php @@ -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 = []; diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 40fa800..4c20955 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -52,6 +52,9 @@ class AdminPanelProvider extends PanelProvider ]) ->authMiddleware([ Authenticate::class, + ]) + ->plugins([ + \Filament\SpatieLaravelMediaLibraryPlugin::make(), ]); } } diff --git a/composer.json b/composer.json index 6412329..ff2b64e 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "require": { "php": "^8.2", "filament/filament": "*", + "filament/spatie-laravel-media-library-plugin": "*", "inertiajs/inertia-laravel": "^2.0", "laravel/framework": "^12.0", "laravel/sanctum": "^4.0", diff --git a/composer.lock b/composer.lock index dba0293..46b7837 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "41d744c25ecf5cfc01e7689053820d0a", + "content-hash": "c2de08919f6e9fa513cd5094c72c6cbd", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -1555,6 +1555,43 @@ }, "time": "2026-01-09T15:14:31+00:00" }, + { + "name": "filament/spatie-laravel-media-library-plugin", + "version": "v5.0.0", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/spatie-laravel-media-library-plugin.git", + "reference": "fb7e7d93c9073acfa51fe623e251cf6ef209cd9b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/spatie-laravel-media-library-plugin/zipball/fb7e7d93c9073acfa51fe623e251cf6ef209cd9b", + "reference": "fb7e7d93c9073acfa51fe623e251cf6ef209cd9b", + "shasum": "" + }, + "require": { + "filament/support": "self.version", + "php": "^8.2", + "spatie/laravel-medialibrary": "^11.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Filament\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Filament support for `spatie/laravel-medialibrary`.", + "homepage": "https://github.com/filamentphp/filament", + "support": { + "issues": "https://github.com/filamentphp/filament/issues", + "source": "https://github.com/filamentphp/filament" + }, + "time": "2025-12-09T10:04:08+00:00" + }, { "name": "filament/support", "version": "v5.0.0", diff --git a/package-lock.json b/package-lock.json index 1721c2b..4f9e332 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,9 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -1655,6 +1657,38 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", @@ -1717,6 +1751,52 @@ } } }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", diff --git a/package.json b/package.json index e09d4de..aedc3f7 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,9 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/resources/js/Components/ui/radio-group.tsx b/resources/js/Components/ui/radio-group.tsx new file mode 100644 index 0000000..14d9318 --- /dev/null +++ b/resources/js/Components/ui/radio-group.tsx @@ -0,0 +1,39 @@ + +import * as React from "react" +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const RadioGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + +export { RadioGroup, RadioGroupItem } diff --git a/resources/js/Components/ui/separator.tsx b/resources/js/Components/ui/separator.tsx new file mode 100644 index 0000000..658f0cf --- /dev/null +++ b/resources/js/Components/ui/separator.tsx @@ -0,0 +1,30 @@ + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/resources/js/Layouts/CourseLayout.tsx b/resources/js/Layouts/CourseLayout.tsx new file mode 100644 index 0000000..0a88c9a --- /dev/null +++ b/resources/js/Layouts/CourseLayout.tsx @@ -0,0 +1,301 @@ + +import React, { useState, useEffect } from 'react'; +import { Link, Head, usePage } from '@inertiajs/react'; +import { + ChevronLeft, + Menu, + Search, + CheckCircle, + Circle, + ChevronDown, + ChevronRight, + PlayCircle, + FileText, + HelpCircle +} from 'lucide-react'; +import { Button } from '@/Components/ui/button'; +import { ScrollArea } from '@/Components/ui/scroll-area'; +import { Sheet, SheetContent, SheetTrigger } from '@/Components/ui/sheet'; +import { Progress } from '@/Components/ui/progress'; +import { cn } from '@/lib/utils'; +import { ModeToggle } from '@/Components/ModeToggle'; + +// Interfaces for Props +interface Lesson { + id: string; + title: string; + slug: string; + type: 'video' | 'text' | 'quiz' | 'pdf'; + is_completed: boolean; + duration_seconds: number; +} + +interface Module { + id: string; + title: string; + lessons: Lesson[]; +} + +interface Course { + id: string; + title: string; + slug: string; + progress_percentage: number; +} + +interface CourseLayoutProps { + children: React.ReactNode; + course: Course; + modules: Module[]; + currentLesson?: Lesson; +} + +export default function CourseLayout({ + children, + course, + modules = [], + currentLesson +}: CourseLayoutProps) { + const [isSidebarOpen, setIsSidebarOpen] = useState(true); + const [openModules, setOpenModules] = useState([]); + const { url } = usePage(); + + // Default open all modules or just the active one + useEffect(() => { + if (modules.length > 0) { + // Find module containing current lesson + const activeModule = modules.find(m => m.lessons.some(l => l.slug === currentLesson?.slug)); + if (activeModule) { + setOpenModules(prev => [...new Set([...prev, activeModule.id])]); + } else { + // If no active lesson (e.g. course home), open first module + setOpenModules([modules[0].id]); + } + } + }, [modules, currentLesson]); + + const toggleModule = (moduleId: string) => { + setOpenModules(prev => + prev.includes(moduleId) + ? prev.filter(id => id !== moduleId) + : [...prev, moduleId] + ); + }; + + const getLessonIcon = (type: string) => { + switch (type) { + case 'video': return ; + case 'quiz': return ; + case 'pdf': return ; + default: return ; + } + }; + + return ( +
+ {/* Mobile Sheet Sidebar */} + +
+
+ + + + {course.title} +
+ + + +
+ + + + +
+ + {/* Desktop Sidebar */} + + + {/* Main Content Area */} +
+ {/* Desktop Topbar */} +
+
+ + +
+
+
+
+ Progress + {course.progress_percentage}% +
+ +
+ +
+
+ + {/* Content Slot */} +
+
+ {children} +
+
+
+
+ ); +} + +function SidebarContent({ course, modules, currentLesson, openModules, toggleModule, getLessonIcon }: any) { + return ( +
+ {/* Header */} +
+ + + Kembali ke Dashboard + +
+
+ {course.title.charAt(0)} +
+

{course.title}

+
+ +
+ + +
+
+ + {/* Curriculum List */} + +
+ {modules.map((module: any, index: number) => ( +
+ + + {openModules.includes(module.id) && ( +
+ {module.lessons.map((lesson: any) => { + const isActive = currentLesson?.slug === lesson.slug; + return ( + +
+ {lesson.is_completed ? ( + + ) : ( + + )} +
+
+ {lesson.title} +
+ + {getLessonIcon(lesson.type)} + {lesson.type} + + {lesson.duration_seconds > 0 && ( + <> + + {Math.ceil(lesson.duration_seconds / 60)} min + + )} +
+
+ {isActive && ( +
+ )} + + ); + })} +
+ )} +
+ ))} +
+ + + {/* Footer User Info */} +
+ Built for Future Japan Enthusisasts +
+
+ ); +} diff --git a/resources/js/Pages/Certificates/Show.tsx b/resources/js/Pages/Certificates/Show.tsx new file mode 100644 index 0000000..50a7bff --- /dev/null +++ b/resources/js/Pages/Certificates/Show.tsx @@ -0,0 +1,94 @@ + +import React, { useRef } from 'react'; +import { Head, Link } from '@inertiajs/react'; +import { Button } from '@/Components/ui/button'; +import { Download, Share2, Home } from 'lucide-react'; +// import { useReactToPrint } from 'react-to-print'; + +export default function ShowCertificate({ course, student, date, certificate_id }: any) { + const componentRef = useRef(null); + + const handlePrint = () => { + window.print(); + }; + + return ( +
+ + +
+ {/* Actions Bar */} +
+
+ + + + | +

Sertifikat Kelulusan

+
+
+ +
+
+ + {/* Certificate Canvas */} +
+ {/* Border/Frame */} +
+
+ + {/* Content */} +
+ + {/* Header */} +
+ NihonBuzz +

Sertifikat Penyelesaian

+

NihonBuzz Academy

+
+ + {/* Body */} +
+

Diberikan dengan bangga kepada

+

+ {student.name} +

+

+ Atas keberhasilan menyelesaikan kursus
+ {course.title} +

+
+ + {/* Footer */} +
+
+

{date}

+

Tanggal

+
+
+ Signature {/* Placeholder */} +

NihonBuzz Team

+

Instruktur

+
+
+ + {/* ID */} +
+ ID: {certificate_id} +
+
+
+
+
+ ); +} diff --git a/resources/js/Pages/Courses/Exam.tsx b/resources/js/Pages/Courses/Exam.tsx new file mode 100644 index 0000000..84e2f6d --- /dev/null +++ b/resources/js/Pages/Courses/Exam.tsx @@ -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>({}); + 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 ( + + + +
+
+

{lesson.title}

+

Jawab semua pertanyaan untuk menyelesaikan kursus ini.

+
+ + {result && ( +
+ {result.passed ? ( +
+ +
+ ) : ( +
+ +
+ )} + +
+

{result.passed ? "Lulus!" : "Belum Lulus"}

+

Skor Anda: {result.score}%

+
+ + {result.passed ? ( + + ) : ( + + )} +
+ )} + + {!result?.passed && ( +
+ {lesson.questions.map((q: any, i: number) => ( + + + + {i + 1}. + {q.question} + + + + handleSelect(q.id, val)}> + {q.options.map((opt: string, idx: number) => ( +
+ + +
+ ))} +
+
+
+ ))} + +
+ +
+
+ )} +
+
+ ); +} diff --git a/resources/js/Pages/Courses/Learn.tsx b/resources/js/Pages/Courses/Learn.tsx new file mode 100644 index 0000000..2ad31ee --- /dev/null +++ b/resources/js/Pages/Courses/Learn.tsx @@ -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 ( + + + + {/* Header Content */} +
+
+ {lesson.type} + + {Math.ceil(lesson.duration_seconds / 60)} min read +
+

+ {lesson.title} +

+
+ + {/* Main Content Render */} +
+ + {lesson.type === 'video' && lesson.video_url && ( +
+