feat: implement Phase 7 (Course Player v2, Furigana, XP System, Integrated Vocab)

This commit is contained in:
2026-01-23 18:15:51 +07:00
parent 82fe5f8a79
commit 7aa4eb89df
8 changed files with 386 additions and 73 deletions

View File

@@ -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>
);
}

View 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>;
};