mirror of
https://github.com/nihonbuzz/nihonbuzz-academy.git
synced 2026-01-27 02:41:58 +07:00
feat: implement Phase 7 (Course Player v2, Furigana, XP System, Integrated Vocab)
This commit is contained in:
@@ -41,6 +41,12 @@ class LessonForm
|
||||
->default(0),
|
||||
Textarea::make('metadata')
|
||||
->columnSpanFull(),
|
||||
Select::make('vocabularies')
|
||||
->relationship('vocabularies', 'word')
|
||||
->multiple()
|
||||
->searchable()
|
||||
->preload()
|
||||
->label('Focus Vocabularies'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,4 +23,9 @@ class Lesson extends Model
|
||||
{
|
||||
return $this->belongsTo(Module::class);
|
||||
}
|
||||
|
||||
public function vocabularies()
|
||||
{
|
||||
return $this->belongsToMany(Vocabulary::class, 'lesson_vocabulary');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('lesson_vocabulary', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string | null>(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 = () => (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-xs font-black text-gray-400 uppercase tracking-widest pl-1">Daftar Materi</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{course.modules.map((module) => (
|
||||
<div key={module.id} className="space-y-2">
|
||||
<div className="flex items-center gap-2 group cursor-default">
|
||||
<div className="w-1 h-4 bg-nihonbuzz-red/30 rounded-full group-hover:bg-nihonbuzz-red transition-colors" />
|
||||
<h4 className="font-black text-gray-800 text-xs uppercase tracking-tight">{module.title}</h4>
|
||||
</div>
|
||||
<ul className="space-y-1 ml-1">
|
||||
{module.lessons.map((lesson) => (
|
||||
<li key={lesson.id}>
|
||||
<Link
|
||||
href={route('courses.learn', { course: course.slug, lesson: lesson.slug })}
|
||||
className={`flex items-center gap-3 p-3 rounded-xl transition-all text-sm group ${
|
||||
lesson.id === currentLesson.id
|
||||
? 'bg-nihonbuzz-red text-white shadow-lg shadow-nihonbuzz-red/20 font-bold'
|
||||
: 'hover:bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="relative flex items-center justify-center shrink-0">
|
||||
{lesson.is_completed ? (
|
||||
<div className="bg-green-100 p-0.5 rounded-full">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600" />
|
||||
</div>
|
||||
) : lesson.id === currentLesson.id ? (
|
||||
<Play className="w-4 h-4 text-white fill-current" />
|
||||
) : (
|
||||
<Circle className="w-4 h-4 text-gray-300 group-hover:text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<span className="line-clamp-1">{lesson.title}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={route('dashboard')} className="p-2 rounded-xl bg-gray-100 hover:bg-gray-200 transition-colors">
|
||||
<ChevronLeft className="w-5 h-5 text-gray-600" />
|
||||
<div className="flex items-center justify-between gap-4 px-2 lg:px-0">
|
||||
<div className="flex items-center gap-2 lg:gap-4 overflow-hidden">
|
||||
<Link href={route('courses.index')} className="p-2.5 rounded-xl bg-gray-50 hover:bg-white hover:shadow-md border border-gray-100 transition-all text-gray-500 hover:text-nihonbuzz-red shrink-0">
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900 line-clamp-1">{course.title}</h2>
|
||||
<p className="text-xs text-gray-500">{progress.completed_count}/{progress.total_count} Materi Selesai</p>
|
||||
<div className="overflow-hidden">
|
||||
<h2 className="text-sm lg:text-base font-black text-gray-900 line-clamp-1 flex items-center gap-2">
|
||||
<span className="text-nihonbuzz-red truncate">{course.title}</span>
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<Progress value={progress.percentage} className="w-20 lg:w-32 h-1.5" />
|
||||
<span className="text-[10px] font-bold text-gray-400 whitespace-nowrap">{progress.percentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{/* Mobile Navigation Trigger */}
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="lg:hidden rounded-xl border-gray-200">
|
||||
<Menu className="w-5 h-5 text-gray-600" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="p-0 border-l border-gray-100">
|
||||
<SheetHeader className="p-6 border-b border-gray-50 text-left">
|
||||
<SheetTitle className="text-xl font-black">{course.title}</SheetTitle>
|
||||
<p className="text-xs text-nihonbuzz-red font-bold uppercase tracking-widest">Kurikulum Kursus</p>
|
||||
</SheetHeader>
|
||||
<div className="p-6 overflow-y-auto h-[calc(100vh-120px)]">
|
||||
<NavigationContent />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div className="hidden lg:flex items-center gap-2 px-4 py-2 bg-yellow-50 rounded-full border border-yellow-100">
|
||||
<Trophy className="w-4 h-4 text-yellow-600" />
|
||||
<span className="text-xs font-black text-yellow-700">{auth.user.xp_points} XP</span>
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={progress.percentage} className="w-32 h-2" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Head title={`${currentLesson.title} - ${course.title}`} />
|
||||
|
||||
<div className="flex h-[calc(100vh-80px)]">
|
||||
<div className="flex min-h-[calc(100vh-80px)] bg-gray-50/30">
|
||||
{/* Main Content Area */}
|
||||
<div className={`flex-1 overflow-y-auto p-6 lg:p-10 transition-all ${sidebarOpen ? 'lg:mr-80' : ''}`}>
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Lesson Title */}
|
||||
<div className="flex items-center gap-3">
|
||||
{currentLesson.type === 'video' && <Video className="w-6 h-6 text-nihonbuzz-red" />}
|
||||
{currentLesson.type === 'text' && <FileText className="w-6 h-6 text-blue-500" />}
|
||||
{currentLesson.type === 'pdf' && <FileText className="w-6 h-6 text-orange-500" />}
|
||||
<h1 className="text-2xl font-black text-gray-900">{currentLesson.title}</h1>
|
||||
</div>
|
||||
|
||||
<div className={`flex-1 overflow-y-auto p-4 lg:p-10 transition-all ${sidebarOpen ? 'lg:mr-80' : ''}`}>
|
||||
<div className="max-w-4xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
|
||||
{/* Video Player */}
|
||||
{currentLesson.type === 'video' && videoSource && (
|
||||
<div className="aspect-video rounded-2xl overflow-hidden shadow-2xl bg-black">
|
||||
<div className="relative group shadow-2xl rounded-3xl overflow-hidden ring-1 ring-black/5 bg-black aspect-video transition-transform hover:scale-[1.01] duration-500">
|
||||
<Plyr source={videoSource} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title & Stats */}
|
||||
<div className="bg-white p-6 lg:p-8 rounded-[32px] shadow-sm border border-gray-100 relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gray-50 -mr-16 -mt-16 rounded-full opacity-50 group-hover:scale-110 transition-transform duration-700" />
|
||||
<div className="relative space-y-3">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black tracking-widest text-nihonbuzz-red uppercase">
|
||||
<span className="px-2 py-0.5 bg-nihonbuzz-red/10 rounded-full">{currentLesson.type}</span>
|
||||
{currentLesson.is_completed && <span className="px-2 py-0.5 bg-green-100 text-green-600 rounded-full">Completed</span>}
|
||||
</div>
|
||||
<h1 className="text-2xl lg:text-3xl font-black text-gray-900 leading-tight">
|
||||
<FuriganaText text={currentLesson.title} />
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
{currentLesson.type === 'text' && currentLesson.content && (
|
||||
<div
|
||||
className="prose prose-lg max-w-none bg-white p-8 rounded-2xl shadow-sm border border-gray-100"
|
||||
dangerouslySetInnerHTML={{ __html: currentLesson.content }}
|
||||
/>
|
||||
<div className="bg-white p-8 lg:p-12 rounded-[40px] shadow-sm border border-gray-100">
|
||||
<article className="prose prose-nihonbuzz prose-lg lg:prose-xl max-w-none">
|
||||
{/* 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 */}
|
||||
<FuriganaText text={currentLesson.content} />
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PDF Content */}
|
||||
{currentLesson.type === 'pdf' && currentLesson.content_pdf && (
|
||||
<div className="aspect-[3/4] w-full">
|
||||
<div className="aspect-[3/4] w-full rounded-[40px] overflow-hidden shadow-2xl ring-1 ring-black/5">
|
||||
<iframe
|
||||
src={currentLesson.content_pdf}
|
||||
className="w-full h-full rounded-2xl shadow-lg border border-gray-200"
|
||||
className="w-full h-full"
|
||||
title={currentLesson.title}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mark as Complete Button */}
|
||||
<div className="flex justify-center pt-6">
|
||||
{/* Vocabulary Section */}
|
||||
{currentLesson.vocabularies?.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-nihonbuzz-red/10 p-2 rounded-2xl">
|
||||
<BookOpen className="w-5 h-5 text-nihonbuzz-red" />
|
||||
</div>
|
||||
<h2 className="text-xl font-black text-gray-900 tracking-tight">Kosa Kata Fokus</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{currentLesson.vocabularies.map((vocab) => (
|
||||
<div key={vocab.id} className="bg-white p-5 rounded-3xl border border-gray-100 shadow-sm flex items-center justify-between group hover:border-nihonbuzz-red/30 transition-all hover:shadow-md">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
onClick={() => vocab.audio_url && playAudio(vocab.audio_url, vocab.id)}
|
||||
className={`w-12 h-12 rounded-2xl flex items-center justify-center transition-all cursor-pointer ${
|
||||
isPlaying === vocab.id ? 'bg-nihonbuzz-red text-white scale-95 shadow-inner' : 'bg-gray-50 text-gray-400 group-hover:bg-nihonbuzz-red/5 group-hover:text-nihonbuzz-red'
|
||||
}`}
|
||||
>
|
||||
{isPlaying === vocab.id ? <Volume2 className="w-5 h-5 animate-pulse" /> : <Volume2 className="w-5 h-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<FuriganaText text={vocab.word} className="text-lg font-black text-gray-900" />
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs font-bold text-nihonbuzz-red">{vocab.reading}</span>
|
||||
<span className="text-[10px] text-gray-400 uppercase font-black">{vocab.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right pr-2">
|
||||
<p className="font-bold text-gray-700">{vocab.meaning_id}</p>
|
||||
<p className="text-[11px] text-gray-400">{vocab.romaji}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Footer */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 pt-10 border-t border-gray-100">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex -space-x-3 overflow-hidden">
|
||||
<div className="w-10 h-10 rounded-full border-4 border-white bg-gray-100 flex items-center justify-center font-bold text-nihonbuzz-red text-xs">NB</div>
|
||||
<div className="w-10 h-10 rounded-full border-4 border-white bg-red-50 flex items-center justify-center font-bold text-nihonbuzz-red text-xs">🇯🇵</div>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-black text-gray-900 tracking-tight">NihonBuzz Academy</p>
|
||||
<p className="text-xs text-nihonbuzz-red font-bold uppercase tracking-widest">Master Japanese Effortlessly</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
size="lg"
|
||||
className="bg-nihonbuzz-red hover:bg-nihonbuzz-red/90 text-white font-bold rounded-full px-10 py-6 text-base shadow-xl shadow-nihonbuzz-red/30"
|
||||
className={`rounded-full px-12 py-7 text-base font-black shadow-2xl transition-all hover:scale-105 active:scale-95 group ${
|
||||
currentLesson.is_completed
|
||||
? 'bg-green-500 hover:bg-green-600 shadow-green-200 text-white'
|
||||
: 'bg-nihonbuzz-red hover:bg-nihonbuzz-red/90 shadow-nihonbuzz-red/20 text-white'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className="w-5 h-5 mr-2" />
|
||||
Tandai Selesai & Lanjutkan
|
||||
{currentLesson.is_completed ? (
|
||||
<>Selesai! Tonton Lagi</>
|
||||
) : (
|
||||
<>Tandai Selesai <ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" /></>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - Module & Lesson Navigation */}
|
||||
<aside className={`fixed right-0 top-16 bottom-0 w-80 bg-white border-l border-gray-100 overflow-y-auto transition-transform ${sidebarOpen ? 'translate-x-0' : 'translate-x-full'} hidden lg:block`}>
|
||||
<div className="p-6 space-y-6">
|
||||
<h3 className="text-sm font-black text-gray-400 uppercase tracking-widest">Daftar Materi</h3>
|
||||
{/* Sidebar - Desktop Navigation */}
|
||||
<aside className={`fixed right-0 top-16 bottom-0 w-80 bg-white border-l border-gray-50 overflow-y-auto transition-all shadow-[-10px_0_30px_rgba(0,0,0,0.02)] ${sidebarOpen ? 'translate-x-0' : 'translate-x-full'} hidden lg:block z-10`}>
|
||||
<div className="p-8 h-full flex flex-col">
|
||||
<NavigationContent />
|
||||
|
||||
{course.modules.map((module) => (
|
||||
<div key={module.id} className="space-y-2">
|
||||
<h4 className="font-bold text-gray-700 text-sm">{module.title}</h4>
|
||||
<ul className="space-y-1">
|
||||
{module.lessons.map((lesson) => (
|
||||
<li key={lesson.id}>
|
||||
<Link
|
||||
href={route('courses.learn', { course: course.slug, lesson: lesson.slug })}
|
||||
className={`flex items-center gap-3 p-3 rounded-xl transition-all text-sm ${
|
||||
lesson.id === currentLesson.id
|
||||
? 'bg-nihonbuzz-red/10 text-nihonbuzz-red font-bold'
|
||||
: 'hover:bg-gray-50 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{lesson.is_completed ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
|
||||
) : lesson.id === currentLesson.id ? (
|
||||
<Play className="w-5 h-5 text-nihonbuzz-red shrink-0 fill-current" />
|
||||
) : (
|
||||
<Circle className="w-5 h-5 text-gray-300 shrink-0" />
|
||||
)}
|
||||
<span className="line-clamp-1">{lesson.title}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-auto pt-10">
|
||||
<div className="bg-nihonbuzz-red/5 p-6 rounded-[32px] border border-nihonbuzz-red/10 text-center space-y-3 relative overflow-hidden group">
|
||||
<div className="absolute inset-0 bg-nihonbuzz-red/5 translate-y-full group-hover:translate-y-0 transition-transform duration-500" />
|
||||
<div className="relative">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-nihonbuzz-red text-white rounded-2xl shadow-lg shadow-nihonbuzz-red/20 mb-2">
|
||||
<Trophy className="w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-sm font-black text-gray-900">Selesaikan untuk XP!</p>
|
||||
<p className="text-[10px] font-bold text-nihonbuzz-red uppercase tracking-widest">+50 XP Per Materi</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
48
resources/js/lib/furigana.tsx
Normal file
48
resources/js/lib/furigana.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Parses a string with Japanese Furigana format {kanji|reading}
|
||||
* into HTML <ruby> tags.
|
||||
*/
|
||||
export function parseFurigana(text: string): React.ReactNode[] {
|
||||
if (!text) return [];
|
||||
|
||||
const regex = /\{([^|]+)\|([^}]+)\}/g;
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// Add text before match
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.substring(lastIndex, match.index));
|
||||
}
|
||||
|
||||
// Add ruby tag
|
||||
const [fullMatch, kanji, reading] = match;
|
||||
parts.push(
|
||||
<ruby key={match.index} className="ruby-text">
|
||||
{kanji}
|
||||
<rt className="text-[0.6em] opacity-80">{reading}</rt>
|
||||
</ruby>
|
||||
);
|
||||
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.substring(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
interface FuriganaTextProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FuriganaText: React.FC<FuriganaTextProps> = ({ text, className }) => {
|
||||
return <span className={className}>{parseFurigana(text)}</span>;
|
||||
};
|
||||
Reference in New Issue
Block a user