mirror of
https://github.com/nihonbuzz/nihonbuzz-academy.git
synced 2026-01-26 05:25:37 +07:00
feat: implement Notion-like LMS (Course Player, Exam, Certificate) with R2 integration
This commit is contained in:
@@ -27,12 +27,37 @@ class LessonForm
|
|||||||
Textarea::make('content')
|
Textarea::make('content')
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
TextInput::make('video_url')
|
TextInput::make('video_url')
|
||||||
->url(),
|
->url()
|
||||||
TextInput::make('content_pdf'),
|
->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')
|
TextInput::make('duration_seconds')
|
||||||
->required()
|
->required()
|
||||||
->numeric()
|
->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')
|
Toggle::make('is_free_preview')
|
||||||
->required(),
|
->required(),
|
||||||
TextInput::make('order_index')
|
TextInput::make('order_index')
|
||||||
@@ -40,7 +65,8 @@ class LessonForm
|
|||||||
->numeric()
|
->numeric()
|
||||||
->default(0),
|
->default(0),
|
||||||
Textarea::make('metadata')
|
Textarea::make('metadata')
|
||||||
->columnSpanFull(),
|
->columnSpanFull()
|
||||||
|
->rows(5),
|
||||||
Select::make('vocabularies')
|
Select::make('vocabularies')
|
||||||
->relationship('vocabularies', 'word')
|
->relationship('vocabularies', 'word')
|
||||||
->multiple()
|
->multiple()
|
||||||
|
|||||||
40
app/Http/Controllers/CertificateController.php
Normal file
40
app/Http/Controllers/CertificateController.php
Normal 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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,14 @@ class CoursePlayerController extends Controller
|
|||||||
? Lesson::where('slug', $lessonSlug)->with('vocabularies')->firstOrFail()
|
? Lesson::where('slug', $lessonSlug)->with('vocabularies')->firstOrFail()
|
||||||
: $allLessons->first()->load('vocabularies');
|
: $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
|
// Get user progress for this course
|
||||||
$completedLessonsIds = UserProgress::where('user_id', $user->id)
|
$completedLessonsIds = UserProgress::where('user_id', $user->id)
|
||||||
->whereIn('lesson_id', $allLessons->pluck('id'))
|
->whereIn('lesson_id', $allLessons->pluck('id'))
|
||||||
@@ -55,42 +63,52 @@ class CoursePlayerController extends Controller
|
|||||||
['started_at' => now(), 'last_heartbeat_at' => now()]
|
['started_at' => now(), 'last_heartbeat_at' => now()]
|
||||||
);
|
);
|
||||||
|
|
||||||
return Inertia::render('Courses/Player', [
|
return Inertia::render('Courses/Learn', [
|
||||||
'course' => [
|
'course' => [
|
||||||
'id' => $course->id,
|
'id' => $course->id,
|
||||||
'title' => $course->title,
|
'title' => $course->title,
|
||||||
'slug' => $course->slug,
|
'slug' => $course->slug,
|
||||||
'modules' => $course->modules->map(function ($module) use ($completedLessonsIds) {
|
'progress_percentage' => count($allLessons) > 0 ? round((count($completedLessonsIds) / count($allLessons)) * 100) : 0,
|
||||||
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),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
];
|
|
||||||
})
|
|
||||||
],
|
],
|
||||||
'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,
|
'id' => $currentLesson->id,
|
||||||
'title' => $currentLesson->title,
|
'title' => $currentLesson->title,
|
||||||
'slug' => $currentLesson->slug,
|
'slug' => $currentLesson->slug,
|
||||||
'type' => $currentLesson->type,
|
'type' => $currentLesson->type,
|
||||||
'content' => $currentLesson->content,
|
'content' => $currentLesson->content,
|
||||||
'video_url' => $currentLesson->video_url,
|
'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,
|
'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,
|
|
||||||
]
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
129
app/Http/Controllers/ExamController.php
Normal file
129
app/Http/Controllers/ExamController.php
Normal 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.'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,10 +7,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
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 = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
])
|
])
|
||||||
->authMiddleware([
|
->authMiddleware([
|
||||||
Authenticate::class,
|
Authenticate::class,
|
||||||
|
])
|
||||||
|
->plugins([
|
||||||
|
\Filament\SpatieLaravelMediaLibraryPlugin::make(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"filament/filament": "*",
|
"filament/filament": "*",
|
||||||
|
"filament/spatie-laravel-media-library-plugin": "*",
|
||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
|
|||||||
39
composer.lock
generated
39
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "41d744c25ecf5cfc01e7689053820d0a",
|
"content-hash": "c2de08919f6e9fa513cd5094c72c6cbd",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "anourvalar/eloquent-serialize",
|
"name": "anourvalar/eloquent-serialize",
|
||||||
@@ -1555,6 +1555,43 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-01-09T15:14:31+00:00"
|
"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",
|
"name": "filament/support",
|
||||||
"version": "v5.0.0",
|
"version": "v5.0.0",
|
||||||
|
|||||||
80
package-lock.json
generated
80
package-lock.json
generated
@@ -12,7 +12,9 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-progress": "^1.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-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.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": {
|
"node_modules/@radix-ui/react-roving-focus": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||||
|
|||||||
@@ -33,7 +33,9 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-progress": "^1.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-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
39
resources/js/Components/ui/radio-group.tsx
Normal file
39
resources/js/Components/ui/radio-group.tsx
Normal file
@@ -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<typeof RadioGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
|
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
))
|
||||||
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
30
resources/js/Components/ui/separator.tsx
Normal file
30
resources/js/Components/ui/separator.tsx
Normal file
@@ -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<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
301
resources/js/Layouts/CourseLayout.tsx
Normal file
301
resources/js/Layouts/CourseLayout.tsx
Normal file
@@ -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<string[]>([]);
|
||||||
|
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 <PlayCircle size={14} />;
|
||||||
|
case 'quiz': return <HelpCircle size={14} />;
|
||||||
|
case 'pdf': return <FileText size={14} />;
|
||||||
|
default: return <FileText size={14} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex font-sans text-foreground">
|
||||||
|
{/* Mobile Sheet Sidebar */}
|
||||||
|
<Sheet>
|
||||||
|
<div className="lg:hidden fixed top-0 left-0 right-0 h-14 border-b bg-background/95 backdrop-blur z-40 flex items-center px-4 justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="-ml-2">
|
||||||
|
<Menu size={20} />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<span className="font-semibold text-sm truncate max-w-[200px]">{course.title}</span>
|
||||||
|
</div>
|
||||||
|
<Link href={route('dashboard')}>
|
||||||
|
<Button variant="ghost" size="sm" className="text-xs">Keluar</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetContent side="left" className="p-0 w-[85vw] max-w-[320px]">
|
||||||
|
<SidebarContent
|
||||||
|
course={course}
|
||||||
|
modules={modules}
|
||||||
|
currentLesson={currentLesson}
|
||||||
|
openModules={openModules}
|
||||||
|
toggleModule={toggleModule}
|
||||||
|
getLessonIcon={getLessonIcon}
|
||||||
|
/>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
{/* Desktop Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"hidden lg:flex flex-col w-80 border-r bg-muted/30 fixed inset-y-0 left-0 z-30 transition-all duration-300",
|
||||||
|
!isSidebarOpen && "-ml-80"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SidebarContent
|
||||||
|
course={course}
|
||||||
|
modules={modules}
|
||||||
|
currentLesson={currentLesson}
|
||||||
|
openModules={openModules}
|
||||||
|
toggleModule={toggleModule}
|
||||||
|
getLessonIcon={getLessonIcon}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<main
|
||||||
|
className={cn(
|
||||||
|
"flex-1 flex flex-col min-h-screen transition-all duration-300 bg-background",
|
||||||
|
isSidebarOpen ? "lg:ml-80" : "lg:ml-0",
|
||||||
|
"pt-14 lg:pt-0" // Mobile padding
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Desktop Topbar */}
|
||||||
|
<header className="hidden lg:flex h-14 items-center justify-between px-6 border-b bg-background sticky top-0 z-20">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
title={isSidebarOpen ? "Close Sidebar" : "Open Sidebar"}
|
||||||
|
>
|
||||||
|
<Menu size={18} />
|
||||||
|
</Button>
|
||||||
|
<nav className="flex items-center text-sm text-muted-foreground">
|
||||||
|
<Link href={route('dashboard')} className="hover:text-foreground transition-colors">
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
<ChevronRight size={14} className="mx-2" />
|
||||||
|
<Link href={route('courses.index')} className="hover:text-foreground transition-colors">
|
||||||
|
Courses
|
||||||
|
</Link>
|
||||||
|
<ChevronRight size={14} className="mx-2" />
|
||||||
|
<span className="font-medium text-foreground truncate max-w-[300px]">
|
||||||
|
{currentLesson?.title || course.title}
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex flex-col items-end mr-2">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Progress</span>
|
||||||
|
<span className="text-xs font-bold">{course.progress_percentage}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={course.progress_percentage} className="w-32 h-1.5" />
|
||||||
|
</div>
|
||||||
|
<ModeToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content Slot */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<div className="max-w-4xl mx-auto p-6 lg:p-10 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContent({ course, modules, currentLesson, openModules, toggleModule, getLessonIcon }: any) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b bg-background/50 backdrop-blur-sm">
|
||||||
|
<Link
|
||||||
|
href={route('dashboard')}
|
||||||
|
className="flex items-center text-xs font-medium text-muted-foreground hover:text-primary mb-4 transition-colors group"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} className="mr-1 group-hover:-translate-x-1 transition-transform" />
|
||||||
|
Kembali ke Dashboard
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="h-10 w-10 rounded-lg bg-gradient-to-br from-nihonbuzz-red to-orange-500 flex items-center justify-center text-white font-bold text-sm shrink-0 shadow-lg shadow-orange-500/20">
|
||||||
|
{course.title.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<h2 className="font-bold leading-tight text-sm line-clamp-2">{course.title}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mt-3">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
className="w-full h-8 pl-8 pr-3 rounded-md border border-input bg-background/50 text-xs focus:outline-none focus:ring-1 focus:ring-primary transition-all placeholder:text-muted-foreground"
|
||||||
|
placeholder="Cari materi..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Curriculum List */}
|
||||||
|
<ScrollArea className="flex-1 px-3 py-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{modules.map((module: any, index: number) => (
|
||||||
|
<div key={module.id} className="space-y-1">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleModule(module.id)}
|
||||||
|
className="flex items-center w-full text-left gap-2 px-2 py-1.5 text-xs font-bold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors group"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
size={12}
|
||||||
|
className={cn(
|
||||||
|
"transition-transform bg-muted rounded-sm",
|
||||||
|
openModules.includes(module.id) && "rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="line-clamp-1 group-hover:underline decoration-border underline-offset-4">{module.title}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{openModules.includes(module.id) && (
|
||||||
|
<div className="space-y-0.5 ml-1 pl-2 border-l border-border/40">
|
||||||
|
{module.lessons.map((lesson: any) => {
|
||||||
|
const isActive = currentLesson?.slug === lesson.slug;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={lesson.id}
|
||||||
|
href={route('courses.learn', { course: course.slug, lesson: lesson.slug })}
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-2.5 px-3 py-2 rounded-md text-sm transition-all duration-200 group relative",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-primary font-medium"
|
||||||
|
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mt-0.5 shrink-0 transition-colors group-hover:text-foreground">
|
||||||
|
{lesson.is_completed ? (
|
||||||
|
<CheckCircle size={14} className="text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Circle size={14} className={cn("text-muted-foreground/30", isActive && "text-primary/30")} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<span className="line-clamp-2 leading-snug">{lesson.title}</span>
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/50 font-medium">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{getLessonIcon(lesson.type)}
|
||||||
|
<span className="capitalize">{lesson.type}</span>
|
||||||
|
</span>
|
||||||
|
{lesson.duration_seconds > 0 && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{Math.ceil(lesson.duration_seconds / 60)} min</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isActive && (
|
||||||
|
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-full bg-primary rounded-r-full" />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Footer User Info */}
|
||||||
|
<div className="p-4 border-t bg-background/50 text-[10px] text-center text-muted-foreground">
|
||||||
|
Built for <span className="font-bold text-foreground">Future Japan Enthusisasts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
resources/js/Pages/Certificates/Show.tsx
Normal file
94
resources/js/Pages/Certificates/Show.tsx
Normal file
@@ -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<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-neutral-100 dark:bg-neutral-950 flex flex-col items-center justify-center p-4">
|
||||||
|
<Head title={`Sertifikat: ${course.title}`} />
|
||||||
|
|
||||||
|
<div className="max-w-4xl w-full space-y-8">
|
||||||
|
{/* Actions Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 bg-white dark:bg-card p-4 rounded-xl shadow-sm border print:hidden">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href={route('dashboard')}>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Home size={16} className="mr-2" />
|
||||||
|
Dashboard
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted-foreground">|</span>
|
||||||
|
<p className="text-sm font-medium">Sertifikat Kelulusan</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button onClick={handlePrint} className="gap-2">
|
||||||
|
<Download size={16} />
|
||||||
|
Download PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Certificate Canvas */}
|
||||||
|
<div
|
||||||
|
ref={componentRef}
|
||||||
|
className="relative bg-white text-black w-full aspect-[1.414/1] shadow-2xl overflow-hidden print:shadow-none print:w-full print:h-screen print:absolute print:top-0 print:left-0"
|
||||||
|
style={{ fontFamily: "'Playfair Display', serif" }} // Ideally import a nice serif font
|
||||||
|
>
|
||||||
|
{/* Border/Frame */}
|
||||||
|
<div className="absolute inset-4 border-4 border-double border-neutral-200 pointer-events-none" />
|
||||||
|
<div className="absolute inset-6 border border-neutral-100 pointer-events-none" />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-center p-16 space-y-8">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<img src="/brand/logo-symbol.svg" alt="NihonBuzz" className="h-16 w-16 mx-auto mb-6 opacity-80" />
|
||||||
|
<p className="text-sm uppercase tracking-[0.3em] text-neutral-500 font-sans">Sertifikat Penyelesaian</p>
|
||||||
|
<h1 className="text-5xl font-bold tracking-tight text-neutral-900">NihonBuzz Academy</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="space-y-6 max-w-2xl">
|
||||||
|
<p className="text-lg text-neutral-600 italic">Diberikan dengan bangga kepada</p>
|
||||||
|
<h2 className="text-4xl font-black text-nihonbuzz-red underline decoration-neutral-200 underline-offset-8 decoration-1">
|
||||||
|
{student.name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-neutral-600 leading-relaxed">
|
||||||
|
Atas keberhasilan menyelesaikan kursus <br />
|
||||||
|
<span className="font-bold text-neutral-900 text-xl block mt-2">{course.title}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="w-full grid grid-cols-2 gap-20 pt-12 mt-12 border-t border-neutral-100 max-w-3xl">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<p className="font-bold text-lg">{date}</p>
|
||||||
|
<p className="text-xs uppercase tracking-widest text-neutral-400 border-t pt-2 w-32 mx-auto">Tanggal</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<img src="/signatures/director.png" className="h-10 mx-auto object-contain opacity-0" alt="Signature" /> {/* Placeholder */}
|
||||||
|
<p className="font-bold text-lg font-script">NihonBuzz Team</p>
|
||||||
|
<p className="text-xs uppercase tracking-widest text-neutral-400 border-t pt-2 w-32 mx-auto">Instruktur</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ID */}
|
||||||
|
<div className="absolute bottom-6 left-8 text-[10px] text-neutral-300 font-mono">
|
||||||
|
ID: {certificate_id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
resources/js/Pages/Courses/Exam.tsx
Normal file
112
resources/js/Pages/Courses/Exam.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
resources/js/Pages/Courses/Learn.tsx
Normal file
188
resources/js/Pages/Courses/Learn.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,6 +38,13 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::get('/srs', [App\Http\Controllers\SrsController::class, 'index'])->name('srs.index');
|
Route::get('/srs', [App\Http\Controllers\SrsController::class, 'index'])->name('srs.index');
|
||||||
Route::get('/srs/practice', [App\Http\Controllers\SrsController::class, 'practice'])->name('srs.practice');
|
Route::get('/srs/practice', [App\Http\Controllers\SrsController::class, 'practice'])->name('srs.practice');
|
||||||
Route::post('/srs/reviews', [App\Http\Controllers\SrsController::class, 'store'])->name('srs.store');
|
Route::post('/srs/reviews', [App\Http\Controllers\SrsController::class, 'store'])->name('srs.store');
|
||||||
|
|
||||||
|
// Exam Routes
|
||||||
|
Route::get('/courses/{course:slug}/exam/{lesson:slug}', [App\Http\Controllers\ExamController::class, 'show'])->name('exams.show');
|
||||||
|
Route::post('/courses/{course:slug}/exam/{lesson:slug}', [App\Http\Controllers\ExamController::class, 'store'])->name('exams.store');
|
||||||
|
|
||||||
|
// Certificate Routes
|
||||||
|
Route::get('/certificates/{course:slug}', [App\Http\Controllers\CertificateController::class, 'show'])->name('certificates.show');
|
||||||
});
|
});
|
||||||
|
|
||||||
require __DIR__.'/auth.php';
|
require __DIR__.'/auth.php';
|
||||||
|
|||||||
Reference in New Issue
Block a user