Improve Bootcamp timeline with collapsible modules and HTML support
- Add collapsible structure: Module → Lesson → Timeline (as sublesson) - Support HTML in timeline titles with DOMPurify sanitization - Add inline code styling for timeline content - Fix vertical alignment (items-start instead of items-center) - Add soft borders between timeline items - Change layout from 2-column grid to single column (video full width, timeline below) - Align Bootcamp page layout with WebinarRecording page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Clock } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
interface VideoChapter {
|
||||
time: number; // Time in seconds
|
||||
@@ -58,9 +59,10 @@ export function TimelineChapters({
|
||||
</div>
|
||||
|
||||
{/* Scrollable chapter list with max-height */}
|
||||
<div className="max-h-[400px] overflow-y-auto space-y-1 pr-2 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 hover:scrollbar-thumb-gray-400">
|
||||
<div className="max-h-[400px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 hover:scrollbar-thumb-gray-400">
|
||||
{chapters.map((chapter, index) => {
|
||||
const active = isChapterActive(index);
|
||||
const isLast = index === chapters.length - 1;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -68,12 +70,13 @@ export function TimelineChapters({
|
||||
onClick={() => clickable && onChapterClick && onChapterClick(chapter.time)}
|
||||
disabled={!clickable}
|
||||
className={`
|
||||
w-full flex items-center gap-3 p-3 rounded-lg transition-all text-left
|
||||
w-full flex items-start gap-3 p-3 rounded-lg transition-all text-left
|
||||
${clickable ? 'hover:bg-muted cursor-pointer' : 'cursor-not-allowed opacity-75'}
|
||||
${active
|
||||
? `bg-primary/10 border-l-4`
|
||||
: 'border-l-4 border-transparent'
|
||||
}
|
||||
${!isLast ? 'border-b border-border/50' : ''}
|
||||
`}
|
||||
style={
|
||||
active
|
||||
@@ -84,24 +87,30 @@ export function TimelineChapters({
|
||||
>
|
||||
{/* Timestamp */}
|
||||
<div className={`
|
||||
font-mono text-sm font-semibold
|
||||
font-mono text-sm font-semibold shrink-0 pt-0.5
|
||||
${active ? 'text-primary' : 'text-muted-foreground'}
|
||||
`} style={active ? { color: accentColor } : undefined}>
|
||||
{formatTime(chapter.time)}
|
||||
</div>
|
||||
|
||||
{/* Chapter Title */}
|
||||
<div className={`
|
||||
flex-1 text-sm
|
||||
${active ? 'font-medium' : 'text-muted-foreground'}
|
||||
`}>
|
||||
{chapter.title}
|
||||
</div>
|
||||
{/* Chapter Title - supports HTML with sanitized output */}
|
||||
<div
|
||||
className={`
|
||||
flex-1 text-sm prose prose-sm max-w-none
|
||||
${active ? 'font-medium' : 'text-muted-foreground'}
|
||||
`}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(chapter.title, {
|
||||
ALLOWED_TAGS: ['code', 'strong', 'em', 'b', 'i', 'u', 'br', 'p', 'span'],
|
||||
ALLOWED_ATTR: ['class', 'style'],
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Active indicator */}
|
||||
{active && (
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
className="w-2 h-2 rounded-full shrink-0 mt-1.5"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -382,4 +382,10 @@ All colors MUST be HSL.
|
||||
.prose img {
|
||||
@apply rounded-lg my-4;
|
||||
}
|
||||
|
||||
/* Timeline chapter inline code styling */
|
||||
.prose-sm code:not(pre code) {
|
||||
@apply bg-slate-100 text-slate-800 px-1 py-0.5 rounded text-xs font-mono;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { formatDuration } from '@/lib/format';
|
||||
import { ChevronLeft, ChevronRight, Check, Play, BookOpen, Clock, Menu, Star, CheckCircle } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, Check, Play, BookOpen, Clock, Menu, Star, CheckCircle, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { ReviewModal } from '@/components/reviews/ReviewModal';
|
||||
@@ -82,6 +82,10 @@ export default function Bootcamp() {
|
||||
const [accentColor, setAccentColor] = useState<string>('');
|
||||
const playerRef = useRef<VideoPlayerRef>(null);
|
||||
|
||||
// Collapsible state for modules and lessons
|
||||
const [collapsedModules, setCollapsedModules] = useState<Set<string>>(new Set());
|
||||
const [collapsedLessons, setCollapsedLessons] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
navigate('/auth');
|
||||
@@ -210,6 +214,17 @@ export default function Bootcamp() {
|
||||
return progress.some(p => p.lesson_id === lessonId);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleSelectLesson = (lesson: Lesson) => {
|
||||
setSelectedLesson(lesson);
|
||||
// Update URL without full page reload
|
||||
@@ -383,8 +398,9 @@ export default function Bootcamp() {
|
||||
const isAdilo = video.type === 'adilo';
|
||||
|
||||
return (
|
||||
<div className={hasChapters ? "grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6" : "mb-6"}>
|
||||
<div className={hasChapters ? "lg:col-span-2" : ""}>
|
||||
<>
|
||||
{/* Video Player - Full Width */}
|
||||
<div className="mb-6">
|
||||
<VideoPlayerWithChapters
|
||||
ref={playerRef}
|
||||
videoUrl={isYouTube ? video.url : undefined}
|
||||
@@ -399,8 +415,9 @@ export default function Bootcamp() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Timeline Chapters - Below video like WebinarRecording */}
|
||||
{hasChapters && (
|
||||
<div className="lg:col-span-1">
|
||||
<div className="mb-6">
|
||||
<TimelineChapters
|
||||
chapters={lesson.chapters}
|
||||
onChapterClick={(time) => {
|
||||
@@ -413,7 +430,7 @@ export default function Bootcamp() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -421,52 +438,166 @@ export default function Bootcamp() {
|
||||
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
|
||||
const isBootcampCompleted = totalLessons > 0 && completedCount >= totalLessons;
|
||||
|
||||
// Toggle functions for collapsible modules and lessons
|
||||
const toggleModule = (moduleId: string) => {
|
||||
setCollapsedModules(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(moduleId)) {
|
||||
newSet.delete(moduleId);
|
||||
} else {
|
||||
newSet.add(moduleId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleLesson = (lessonId: string) => {
|
||||
setCollapsedLessons(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(lessonId)) {
|
||||
newSet.delete(lessonId);
|
||||
} else {
|
||||
newSet.add(lessonId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
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();
|
||||
{modules.map((module) => {
|
||||
const isModuleCollapsed = collapsedModules.has(module.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={lesson.id}
|
||||
onClick={() => {
|
||||
if (isReleased) {
|
||||
handleSelectLesson(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?.trim() || lesson.youtube_url?.trim() || lesson.embed_code?.trim()) ? (
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<div key={module.id} className="mb-4">
|
||||
{/* Module Header - Collapsible */}
|
||||
<button
|
||||
onClick={() => toggleModule(module.id)}
|
||||
className="w-full flex items-center gap-2 text-left mb-2 group"
|
||||
>
|
||||
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide flex-1">
|
||||
{module.title}
|
||||
</h3>
|
||||
{isModuleCollapsed ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
) : (
|
||||
<ChevronUp className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Lessons - Hidden if module is collapsed */}
|
||||
{!isModuleCollapsed && (
|
||||
<div className="space-y-1 ml-2">
|
||||
{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();
|
||||
const isLessonCollapsed = collapsedLessons.has(lesson.id);
|
||||
const hasChapters = lesson.chapters && lesson.chapters.length > 0;
|
||||
|
||||
return (
|
||||
<div key={lesson.id}>
|
||||
{/* Lesson Button - Collapsible if has chapters */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isReleased) {
|
||||
if (hasChapters) {
|
||||
toggleLesson(lesson.id);
|
||||
} else {
|
||||
handleSelectLesson(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?.trim() || lesson.youtube_url?.trim() || lesson.embed_code?.trim()) ? (
|
||||
<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>
|
||||
)}
|
||||
{hasChapters && (
|
||||
<>
|
||||
{isLessonCollapsed ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0 ml-1" />
|
||||
) : (
|
||||
<ChevronUp className="w-4 h-4 text-muted-foreground shrink-0 ml-1" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Timeline Chapters - Shown if lesson is expanded */}
|
||||
{hasChapters && !isLessonCollapsed && (
|
||||
<div className="ml-4 mt-1 space-y-1">
|
||||
{lesson.chapters?.map((chapter, index) => {
|
||||
const isChapterActive = currentTime >= chapter.time &&
|
||||
(!lesson.chapters?.[index + 1] || currentTime < lesson.chapters[index + 1].time);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
if (playerRef.current) {
|
||||
// First select the lesson if not selected
|
||||
if (selectedLesson?.id !== lesson.id) {
|
||||
handleSelectLesson(lesson);
|
||||
}
|
||||
// Then jump to time
|
||||
setTimeout(() => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.jumpToTime(chapter.time);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-1.5 rounded text-xs flex items-center gap-2 transition-colors border-l-2",
|
||||
isChapterActive
|
||||
? "bg-primary/10 border-primary"
|
||||
: "border-transparent hover:bg-muted/50"
|
||||
)}
|
||||
title={`Jump to ${formatTime(chapter.time)}`}
|
||||
>
|
||||
<span className="font-mono text-xs text-muted-foreground shrink-0">
|
||||
{formatTime(chapter.time)}
|
||||
</span>
|
||||
<span
|
||||
className="flex-1 truncate prose-sm prose"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(chapter.title, {
|
||||
ALLOWED_TAGS: ['code', 'strong', 'em'],
|
||||
ALLOWED_ATTR: [],
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{isChapterActive && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user