mirror of
https://github.com/nihonbuzz/nihonbuzz-academy.git
synced 2026-01-26 13:32:07 +07:00
feat: implement Notion-like LMS (Course Player, Exam, Certificate) with R2 integration
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user