This commit is contained in:
gpt-engineer-app[bot]
2025-12-19 01:54:13 +00:00
parent 278f709201
commit ff877266b0
13 changed files with 2540 additions and 231 deletions

View File

@@ -3,11 +3,13 @@ import { useParams, useNavigate } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/hooks/use-toast';
import { ChevronLeft, ChevronRight, Check, Play, BookOpen } from 'lucide-react';
import { formatDuration } from '@/lib/format';
import { ChevronLeft, ChevronRight, Check, Play, BookOpen, Clock, Menu, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
interface Product {
id: string;
@@ -27,6 +29,7 @@ interface Lesson {
title: string;
content: string | null;
video_url: string | null;
duration_seconds: number | null;
position: number;
release_at: string | null;
}
@@ -40,14 +43,14 @@ export default function Bootcamp() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const { user, loading: authLoading } = useAuth();
const [product, setProduct] = useState<Product | null>(null);
const [modules, setModules] = useState<Module[]>([]);
const [progress, setProgress] = useState<Progress[]>([]);
const [selectedLesson, setSelectedLesson] = useState<Lesson | null>(null);
const [loading, setLoading] = useState(true);
const [hasAccess, setHasAccess] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
useEffect(() => {
if (!authLoading && !user) {
@@ -58,7 +61,6 @@ export default function Bootcamp() {
}, [user, authLoading, slug]);
const checkAccessAndFetch = async () => {
// First get the product
const { data: productData, error: productError } = await supabase
.from('products')
.select('id, title, slug')
@@ -67,14 +69,13 @@ export default function Bootcamp() {
.maybeSingle();
if (productError || !productData) {
toast({ title: 'Error', description: 'Bootcamp not found', variant: 'destructive' });
toast({ title: 'Error', description: 'Bootcamp tidak ditemukan', variant: 'destructive' });
navigate('/dashboard');
return;
}
setProduct(productData);
// Check access
const { data: accessData } = await supabase
.from('user_access')
.select('id')
@@ -83,14 +84,11 @@ export default function Bootcamp() {
.maybeSingle();
if (!accessData) {
toast({ title: 'Access denied', description: 'You don\'t have access to this bootcamp', variant: 'destructive' });
toast({ title: 'Akses ditolak', description: 'Anda tidak memiliki akses ke bootcamp ini', variant: 'destructive' });
navigate('/dashboard');
return;
}
setHasAccess(true);
// Fetch modules with lessons
const { data: modulesData } = await supabase
.from('bootcamp_modules')
.select(`
@@ -102,6 +100,7 @@ export default function Bootcamp() {
title,
content,
video_url,
duration_seconds,
position,
release_at
)
@@ -116,13 +115,11 @@ export default function Bootcamp() {
}));
setModules(sortedModules);
// Select first lesson
if (sortedModules.length > 0 && sortedModules[0].lessons.length > 0) {
setSelectedLesson(sortedModules[0].lessons[0]);
}
}
// Fetch progress
const { data: progressData } = await supabase
.from('lesson_progress')
.select('lesson_id, completed_at')
@@ -148,23 +145,20 @@ export default function Bootcamp() {
if (error) {
if (error.code === '23505') {
toast({ title: 'Already completed', description: 'This lesson is already marked as completed' });
toast({ title: 'Info', description: 'Pelajaran sudah ditandai selesai' });
} else {
toast({ title: 'Error', description: 'Failed to mark as completed', variant: 'destructive' });
toast({ title: 'Error', description: 'Gagal menandai selesai', variant: 'destructive' });
}
return;
}
setProgress([...progress, { lesson_id: selectedLesson.id, completed_at: new Date().toISOString() }]);
toast({ title: 'Completed!', description: 'Lesson marked as completed' });
// Auto-advance to next lesson
toast({ title: 'Selesai!', description: 'Pelajaran ditandai selesai' });
goToNextLesson();
};
const goToNextLesson = () => {
if (!selectedLesson) return;
const allLessons = modules.flatMap(m => m.lessons);
const currentIndex = allLessons.findIndex(l => l.id === selectedLesson.id);
if (currentIndex < allLessons.length - 1) {
@@ -174,7 +168,6 @@ export default function Bootcamp() {
const goToPrevLesson = () => {
if (!selectedLesson) return;
const allLessons = modules.flatMap(m => m.lessons);
const currentIndex = allLessons.findIndex(l => l.id === selectedLesson.id);
if (currentIndex > 0) {
@@ -183,27 +176,70 @@ export default function Bootcamp() {
};
const getVideoEmbed = (url: string) => {
// Handle YouTube URLs
const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
if (youtubeMatch) {
return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
}
// Handle Vimeo URLs
if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/);
if (vimeoMatch) {
return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
}
if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
return url;
};
const completedCount = progress.length;
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
const renderSidebarContent = () => (
<div className="p-4">
{modules.map((module) => (
<div key={module.id} className="mb-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide mb-2">
{module.title}
</h3>
<div className="space-y-1">
{module.lessons.map((lesson) => {
const isCompleted = isLessonCompleted(lesson.id);
const isSelected = selectedLesson?.id === lesson.id;
const isReleased = !lesson.release_at || new Date(lesson.release_at) <= new Date();
return (
<button
key={lesson.id}
onClick={() => {
if (isReleased) {
setSelectedLesson(lesson);
setMobileMenuOpen(false);
}
}}
disabled={!isReleased}
className={cn(
"w-full text-left px-3 py-2 rounded-none text-sm flex items-center gap-2 transition-colors",
isSelected ? "bg-primary text-primary-foreground" : "hover:bg-muted",
!isReleased && "opacity-50 cursor-not-allowed"
)}
>
{isCompleted ? (
<Check className="w-4 h-4 shrink-0 text-accent" />
) : lesson.video_url ? (
<Play className="w-4 h-4 shrink-0" />
) : (
<BookOpen className="w-4 h-4 shrink-0" />
)}
<span className="truncate flex-1">{lesson.title}</span>
{lesson.duration_seconds && (
<span className="text-xs text-muted-foreground">{formatDuration(lesson.duration_seconds)}</span>
)}
</button>
);
})}
</div>
</div>
))}
</div>
);
if (authLoading || loading) {
return (
<div className="min-h-screen bg-background">
<div className="flex">
<div className="w-80 border-r border-border p-4">
<div className="w-80 border-r border-border p-4 hidden md:block">
<Skeleton className="h-8 w-full mb-4" />
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-10 w-full mb-2" />
@@ -221,94 +257,77 @@ export default function Bootcamp() {
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b border-border bg-card">
<header className="border-b-2 border-border bg-card sticky top-0 z-50">
<div className="flex items-center justify-between px-4 h-16">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate('/dashboard')}>
<ChevronLeft className="w-4 h-4 mr-1" />
Back to Dashboard
<span className="hidden sm:inline">Kembali ke Dashboard</span>
</Button>
<h1 className="text-xl font-bold">{product?.title}</h1>
<h1 className="text-lg md:text-xl font-bold truncate">{product?.title}</h1>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
{completedCount} / {totalLessons} completed
<span className="text-sm text-muted-foreground hidden sm:inline">
{completedCount} / {totalLessons} selesai
</span>
<div className="w-32 h-2 bg-muted rounded-full overflow-hidden">
<div
<div className="w-24 md:w-32 h-2 bg-muted rounded-none overflow-hidden border border-border">
<div
className="h-full bg-primary transition-all"
style={{ width: `${totalLessons > 0 ? (completedCount / totalLessons) * 100 : 0}%` }}
/>
</div>
{/* Mobile menu trigger */}
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="sm" className="md:hidden">
<Menu className="w-5 h-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-80 p-0 border-r-2 border-border">
<div className="p-4 border-b-2 border-border font-bold">Kurikulum</div>
<div className="overflow-y-auto h-[calc(100vh-60px)]">
{renderSidebarContent()}
</div>
</SheetContent>
</Sheet>
</div>
</div>
</header>
<div className="flex">
{/* Sidebar */}
{/* Desktop Sidebar */}
<aside className={cn(
"border-r border-border bg-card transition-all overflow-y-auto h-[calc(100vh-64px)]",
"hidden md:block border-r-2 border-border bg-card transition-all overflow-y-auto h-[calc(100vh-64px)] sticky top-16",
sidebarOpen ? "w-80" : "w-0"
)}>
{sidebarOpen && (
<div className="p-4">
{modules.map((module) => (
<div key={module.id} className="mb-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide mb-2">
{module.title}
</h3>
<div className="space-y-1">
{module.lessons.map((lesson) => {
const isCompleted = isLessonCompleted(lesson.id);
const isSelected = selectedLesson?.id === lesson.id;
const isReleased = !lesson.release_at || new Date(lesson.release_at) <= new Date();
return (
<button
key={lesson.id}
onClick={() => isReleased && setSelectedLesson(lesson)}
disabled={!isReleased}
className={cn(
"w-full text-left px-3 py-2 rounded-md text-sm flex items-center gap-2 transition-colors",
isSelected ? "bg-primary text-primary-foreground" : "hover:bg-muted",
!isReleased && "opacity-50 cursor-not-allowed"
)}
>
{isCompleted ? (
<Check className="w-4 h-4 shrink-0 text-accent" />
) : lesson.video_url ? (
<Play className="w-4 h-4 shrink-0" />
) : (
<BookOpen className="w-4 h-4 shrink-0" />
)}
<span className="truncate">{lesson.title}</span>
</button>
);
})}
</div>
</div>
))}
</div>
)}
{sidebarOpen && renderSidebarContent()}
</aside>
{/* Toggle sidebar button */}
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="absolute left-0 top-1/2 -translate-y-1/2 bg-card border border-border rounded-r-md p-1 z-10"
className="hidden md:flex absolute top-1/2 -translate-y-1/2 bg-card border-2 border-border rounded-r-none p-1 z-10"
style={{ left: sidebarOpen ? '320px' : '0' }}
>
{sidebarOpen ? <ChevronLeft className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
{/* Main content */}
<main className="flex-1 p-8 h-[calc(100vh-64px)] overflow-y-auto">
<main className="flex-1 p-4 md:p-8 min-h-[calc(100vh-64px)] overflow-y-auto">
{selectedLesson ? (
<div className="max-w-4xl mx-auto">
<h2 className="text-3xl font-bold mb-6">{selectedLesson.title}</h2>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-2xl md:text-3xl font-bold">{selectedLesson.title}</h2>
{selectedLesson.duration_seconds && (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
{formatDuration(selectedLesson.duration_seconds)}
</span>
)}
</div>
{selectedLesson.video_url && (
<div className="aspect-video bg-muted rounded-lg overflow-hidden mb-6">
<div className="aspect-video bg-muted rounded-none overflow-hidden mb-6 border-2 border-border">
<iframe
src={getVideoEmbed(selectedLesson.video_url)}
className="w-full h-full"
@@ -321,7 +340,7 @@ export default function Bootcamp() {
{selectedLesson.content && (
<Card className="border-2 border-border mb-6">
<CardContent className="pt-6">
<div
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: selectedLesson.content }}
/>
@@ -329,7 +348,7 @@ export default function Bootcamp() {
</Card>
)}
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-4 flex-wrap">
<Button
variant="outline"
onClick={goToPrevLesson}
@@ -337,7 +356,7 @@ export default function Bootcamp() {
className="border-2"
>
<ChevronLeft className="w-4 h-4 mr-2" />
Previous
Sebelumnya
</Button>
<Button
@@ -348,10 +367,10 @@ export default function Bootcamp() {
{isLessonCompleted(selectedLesson.id) ? (
<>
<Check className="w-4 h-4 mr-2" />
Completed
Sudah Selesai
</>
) : (
'Mark as Completed'
'Tandai Selesai'
)}
</Button>
@@ -361,7 +380,7 @@ export default function Bootcamp() {
disabled={modules.flatMap(m => m.lessons).findIndex(l => l.id === selectedLesson.id) === modules.flatMap(m => m.lessons).length - 1}
className="border-2"
>
Next
Selanjutnya
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
</div>
@@ -372,7 +391,7 @@ export default function Bootcamp() {
<CardContent className="py-12 text-center">
<BookOpen className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">
{modules.length === 0 ? 'No lessons available yet' : 'Select a lesson to begin'}
{modules.length === 0 ? 'Belum ada pelajaran tersedia' : 'Pilih pelajaran untuk memulai'}
</p>
</CardContent>
</Card>