From 7aa4eb89dfac0b475a8b9e85239983737f0d9bec Mon Sep 17 00:00:00 2001 From: nihonbuzz Date: Fri, 23 Jan 2026 18:15:51 +0700 Subject: [PATCH] feat: implement Phase 7 (Course Player v2, Furigana, XP System, Integrated Vocab) --- .../Resources/Lessons/Schemas/LessonForm.php | 6 + .../Controllers/CoursePlayerController.php | 17 +- app/Models/Lesson.php | 5 + app/Models/Vocabulary.php | 10 + ..._111012_create_lesson_vocabulary_table.php | 30 ++ database/seeders/TestDataSeeder.php | 38 ++- resources/js/Pages/Courses/Player.tsx | 305 ++++++++++++++---- resources/js/lib/furigana.tsx | 48 +++ 8 files changed, 386 insertions(+), 73 deletions(-) create mode 100644 database/migrations/2026_01_23_111012_create_lesson_vocabulary_table.php create mode 100644 resources/js/lib/furigana.tsx diff --git a/app/Filament/Admin/Resources/Lessons/Schemas/LessonForm.php b/app/Filament/Admin/Resources/Lessons/Schemas/LessonForm.php index e5983fc..8b75b91 100644 --- a/app/Filament/Admin/Resources/Lessons/Schemas/LessonForm.php +++ b/app/Filament/Admin/Resources/Lessons/Schemas/LessonForm.php @@ -41,6 +41,12 @@ class LessonForm ->default(0), Textarea::make('metadata') ->columnSpanFull(), + Select::make('vocabularies') + ->relationship('vocabularies', 'word') + ->multiple() + ->searchable() + ->preload() + ->label('Focus Vocabularies'), ]); } } diff --git a/app/Http/Controllers/CoursePlayerController.php b/app/Http/Controllers/CoursePlayerController.php index 374a814..92dd2a8 100644 --- a/app/Http/Controllers/CoursePlayerController.php +++ b/app/Http/Controllers/CoursePlayerController.php @@ -37,10 +37,10 @@ class CoursePlayerController extends Controller // Get all lessons for navigation and progress check $allLessons = $course->modules->flatMap->lessons; - // Find current lesson + // Find current lesson with vocabularies $currentLesson = $lessonSlug - ? Lesson::where('slug', $lessonSlug)->firstOrFail() - : $allLessons->first(); + ? Lesson::where('slug', $lessonSlug)->with('vocabularies')->firstOrFail() + : $allLessons->first()->load('vocabularies'); // Get user progress for this course $completedLessonsIds = UserProgress::where('user_id', $user->id) @@ -77,6 +77,7 @@ class CoursePlayerController extends Controller 'content' => $currentLesson->content, 'video_url' => $currentLesson->video_url, 'content_pdf' => $currentLesson->content_pdf, + 'vocabularies' => $currentLesson->vocabularies, ], 'progress' => [ 'completed_count' => count($completedLessonsIds), @@ -93,13 +94,19 @@ class CoursePlayerController extends Controller { $user = $request->user(); + $alreadyCompleted = UserProgress::where('user_id', $user->id) + ->where('lesson_id', $lesson->id) + ->exists(); + UserProgress::updateOrCreate( ['user_id' => $user->id, 'lesson_id' => $lesson->id], ['completed_at' => now()] ); - // Optional: Add XP points logic here later + if (!$alreadyCompleted) { + $user->increment('xp_points', 50); + } - return back()->with('success', 'Materi selesai!'); + return back()->with('success', 'Materi selesai! +50 XP'); } } diff --git a/app/Models/Lesson.php b/app/Models/Lesson.php index c03c198..5bd3b86 100644 --- a/app/Models/Lesson.php +++ b/app/Models/Lesson.php @@ -23,4 +23,9 @@ class Lesson extends Model { return $this->belongsTo(Module::class); } + + public function vocabularies() + { + return $this->belongsToMany(Vocabulary::class, 'lesson_vocabulary'); + } } diff --git a/app/Models/Vocabulary.php b/app/Models/Vocabulary.php index dfe3efd..057615a 100644 --- a/app/Models/Vocabulary.php +++ b/app/Models/Vocabulary.php @@ -24,4 +24,14 @@ class Vocabulary extends Model { return $this->hasMany(SrsReview::class); } + + public function level() + { + return $this->belongsTo(Level::class); + } + + public function lessons() + { + return $this->belongsToMany(Lesson::class, 'lesson_vocabulary'); + } } diff --git a/database/migrations/2026_01_23_111012_create_lesson_vocabulary_table.php b/database/migrations/2026_01_23_111012_create_lesson_vocabulary_table.php new file mode 100644 index 0000000..90694f9 --- /dev/null +++ b/database/migrations/2026_01_23_111012_create_lesson_vocabulary_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignUuid('lesson_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('vocabulary_id')->constrained()->cascadeOnDelete(); + $table->unique(['lesson_id', 'vocabulary_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('lesson_vocabulary'); + } +}; diff --git a/database/seeders/TestDataSeeder.php b/database/seeders/TestDataSeeder.php index a608643..f3ceca4 100644 --- a/database/seeders/TestDataSeeder.php +++ b/database/seeders/TestDataSeeder.php @@ -114,7 +114,43 @@ class TestDataSeeder extends Seeder ); } - // 5. Update User Stats + // 5. Vocabularies + $vocab1 = \App\Models\Vocabulary::updateOrCreate( + ['word' => '{私|わたし}'], + [ + 'reading' => 'わたし', + 'romaji' => 'watashi', + 'meaning_id' => 'Saya', + 'meaning_en' => 'I / Me', + 'type' => 'noun', + 'level_id' => $n5->id, + ] + ); + + $vocab2 = \App\Models\Vocabulary::updateOrCreate( + ['word' => '{先生|せんせい}'], + [ + 'reading' => 'せんせい', + 'romaji' => 'sensei', + 'meaning_id' => 'Guru', + 'meaning_en' => 'Teacher', + 'type' => 'noun', + 'level_id' => $n5->id, + ] + ); + + // Attach to lessons + $lesson1 = Lesson::where('slug', 'welcome-n5')->first(); + if ($lesson1) { + $lesson1->vocabularies()->syncWithoutDetaching([$vocab1->id, $vocab2->id]); + } + + $lesson2 = Lesson::where('slug', 'vowels-kana')->first(); + if ($lesson2) { + $lesson2->vocabularies()->syncWithoutDetaching([$vocab1->id]); + } + + // 6. Update User Stats $admin->update([ 'xp_points' => 1250, 'current_streak' => 5, diff --git a/resources/js/Pages/Courses/Player.tsx b/resources/js/Pages/Courses/Player.tsx index cf88976..b73af61 100644 --- a/resources/js/Pages/Courses/Player.tsx +++ b/resources/js/Pages/Courses/Player.tsx @@ -1,10 +1,42 @@ import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; import { Head, Link, router } from '@inertiajs/react'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Plyr } from 'plyr-react'; -import { CheckCircle2, ChevronLeft, Circle, FileText, Play, Video } from 'lucide-react'; +import 'plyr-react/plyr.css'; +import { + CheckCircle2, + ChevronLeft, + Circle, + FileText, + Play, + Video, + Menu, + Volume2, + BookOpen, + ArrowRight, + Trophy +} from 'lucide-react'; import { Button } from '@/Components/ui/button'; import { Progress } from '@/Components/ui/progress'; +import { FuriganaText } from '@/lib/furigana'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger +} from "@/Components/ui/sheet"; + +interface VocabularyData { + id: string; + word: string; + reading: string; + romaji: string; + meaning_id: string; + meaning_en: string; + type: string; + audio_url: string | null; +} interface LessonData { id: string; @@ -35,6 +67,7 @@ interface CurrentLessonData { content: string | null; video_url: string | null; content_pdf: string | null; + vocabularies: VocabularyData[]; } interface ProgressData { @@ -47,22 +80,34 @@ interface PlayerProps { course: CourseData; currentLesson: CurrentLessonData; progress: ProgressData; + auth: { + user: any; + }; } // Helper to get YouTube video ID -function getYouTubeId(url: string): string | null { +function getYouTubeId(url: string | null): string | null { + if (!url) return null; const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/; const match = url.match(regExp); return (match && match[2].length === 11) ? match[2] : null; } -export default function Player({ course, currentLesson, progress }: PlayerProps) { +export default function Player({ course, currentLesson, progress, auth }: PlayerProps) { const [sidebarOpen, setSidebarOpen] = useState(true); + const [isPlaying, setIsPlaying] = useState(null); const handleComplete = () => { router.post(route('lessons.complete', { lesson: currentLesson.id })); }; + const playAudio = (url: string, id: string) => { + const audio = new Audio(url); + setIsPlaying(id); + audio.play(); + audio.onended = () => setIsPlaying(null); + }; + const videoSource = currentLesson.video_url ? { type: 'video' as const, sources: [{ @@ -71,113 +116,239 @@ export default function Player({ course, currentLesson, progress }: PlayerProps) }], } : null; + const NavigationContent = () => ( +
+

Daftar Materi

+ +
+ {course.modules.map((module) => ( +
+
+
+

{module.title}

+
+
    + {module.lessons.map((lesson) => ( +
  • + +
    + {lesson.is_completed ? ( +
    + +
    + ) : lesson.id === currentLesson.id ? ( + + ) : ( + + )} +
    + {lesson.title} + +
  • + ))} +
+
+ ))} +
+
+ ); + return ( -
- - +
+
+ + -
-

{course.title}

-

{progress.completed_count}/{progress.total_count} Materi Selesai

+
+

+ {course.title} +

+
+ + {progress.percentage}% +
+
+
+ +
+ {/* Mobile Navigation Trigger */} + + + + + + + {course.title} +

Kurikulum Kursus

+
+
+ +
+
+
+ +
+ + {auth.user.xp_points} XP
-
} > -
+
{/* Main Content Area */} -
-
- {/* Lesson Title */} -
- {currentLesson.type === 'video' &&
- +
+
+ {/* Video Player */} {currentLesson.type === 'video' && videoSource && ( -
+
)} + {/* Title & Stats */} +
+
+
+
+ {currentLesson.type} + {currentLesson.is_completed && Completed} +
+

+ +

+
+
+ {/* Text Content */} {currentLesson.type === 'text' && currentLesson.content && ( -
+
+
+ {/* Ideally we'd have a parser for the whole content HTML too, + but for now we trust the seeder gives us plain text with furigana markers or HTML */} + +
+
)} {/* PDF Content */} {currentLesson.type === 'pdf' && currentLesson.content_pdf && ( -
+