Fix timeline borders, revert sidebar accordion, and fix video reloading

- Remove border-bottom from TimelineChapters component for better readability
- Revert collapsible timeline changes from bootcamp sidebar
- Fix video reloading issue by adding key prop to force remount on lesson change
- Prevent resume prompt from showing multiple times during video playback
- Resume prompt now only shows once when component mounts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-01-04 11:09:59 +07:00
parent 963160d165
commit 44484afb84
3 changed files with 48 additions and 164 deletions

View File

@@ -76,7 +76,6 @@ export function TimelineChapters({
? `bg-primary/10 border-l-4` ? `bg-primary/10 border-l-4`
: 'border-l-4 border-transparent' : 'border-l-4 border-transparent'
} }
${!isLast ? 'border-b border-border/50' : ''}
`} `}
style={ style={
active active

View File

@@ -53,6 +53,7 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
const [showResumePrompt, setShowResumePrompt] = useState(false); const [showResumePrompt, setShowResumePrompt] = useState(false);
const [resumeTime, setResumeTime] = useState(0); const [resumeTime, setResumeTime] = useState(0);
const saveProgressTimeoutRef = useRef<NodeJS.Timeout | null>(null); const saveProgressTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const hasShownResumePromptRef = useRef(false);
// Determine if using Adilo (M3U8) or YouTube // Determine if using Adilo (M3U8) or YouTube
const isAdilo = videoHost === 'adilo' || m3u8Url; const isAdilo = videoHost === 'adilo' || m3u8Url;
@@ -252,11 +253,12 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
return currentTime; return currentTime;
}; };
// Check for saved progress and show resume prompt // Check for saved progress and show resume prompt (only once on mount)
useEffect(() => { useEffect(() => {
if (!progressLoading && hasProgress && progress && progress.last_position > 5) { if (!hasShownResumePromptRef.current && !progressLoading && hasProgress && progress && progress.last_position > 5) {
setShowResumePrompt(true); setShowResumePrompt(true);
setResumeTime(progress.last_position); setResumeTime(progress.last_position);
hasShownResumePromptRef.current = true;
} }
}, [progressLoading, hasProgress, progress]); }, [progressLoading, hasProgress, progress]);

View File

@@ -8,7 +8,7 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { formatDuration } from '@/lib/format'; import { formatDuration } from '@/lib/format';
import { ChevronLeft, ChevronRight, Check, Play, BookOpen, Clock, Menu, Star, CheckCircle, ChevronDown, ChevronUp } from 'lucide-react'; import { ChevronLeft, ChevronRight, Check, Play, BookOpen, Clock, Menu, Star, CheckCircle } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { ReviewModal } from '@/components/reviews/ReviewModal'; import { ReviewModal } from '@/components/reviews/ReviewModal';
@@ -82,10 +82,6 @@ export default function Bootcamp() {
const [accentColor, setAccentColor] = useState<string>(''); const [accentColor, setAccentColor] = useState<string>('');
const playerRef = useRef<VideoPlayerRef>(null); 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(() => { useEffect(() => {
if (!authLoading && !user) { if (!authLoading && !user) {
navigate('/auth'); navigate('/auth');
@@ -402,6 +398,7 @@ export default function Bootcamp() {
{/* Video Player - Full Width */} {/* Video Player - Full Width */}
<div className="mb-6"> <div className="mb-6">
<VideoPlayerWithChapters <VideoPlayerWithChapters
key={lesson.id}
ref={playerRef} ref={playerRef}
videoUrl={isYouTube ? video.url : undefined} videoUrl={isYouTube ? video.url : undefined}
m3u8Url={isAdilo ? video.m3u8Url : undefined} m3u8Url={isAdilo ? video.m3u8Url : undefined}
@@ -438,76 +435,27 @@ export default function Bootcamp() {
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0); const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
const isBootcampCompleted = totalLessons > 0 && completedCount >= totalLessons; 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 = () => ( const renderSidebarContent = () => (
<div className="p-4"> <div className="p-4">
{modules.map((module) => { {modules.map((module) => (
const isModuleCollapsed = collapsedModules.has(module.id);
return (
<div key={module.id} className="mb-4"> <div key={module.id} className="mb-4">
{/* Module Header - Collapsible */} <h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide mb-2">
<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} {module.title}
</h3> </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"> <div className="space-y-1 ml-2">
{module.lessons.map((lesson) => { {module.lessons.map((lesson) => {
const isCompleted = isLessonCompleted(lesson.id); const isCompleted = isLessonCompleted(lesson.id);
const isSelected = selectedLesson?.id === lesson.id; const isSelected = selectedLesson?.id === lesson.id;
const isReleased = !lesson.release_at || new Date(lesson.release_at) <= new Date(); 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 ( return (
<div key={lesson.id}>
{/* Lesson Button - Collapsible if has chapters */}
<button <button
key={lesson.id}
onClick={() => { onClick={() => {
if (isReleased) { if (isReleased) {
if (hasChapters) {
toggleLesson(lesson.id);
} else {
handleSelectLesson(lesson); handleSelectLesson(lesson);
setMobileMenuOpen(false); setMobileMenuOpen(false);
} }
}
}} }}
disabled={!isReleased} disabled={!isReleased}
className={cn( className={cn(
@@ -527,77 +475,12 @@ export default function Bootcamp() {
{lesson.duration_seconds && ( {lesson.duration_seconds && (
<span className="text-xs text-muted-foreground">{formatDuration(lesson.duration_seconds)}</span> <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> </button>
); );
})} })}
</div> </div>
)}
</div> </div>
); ))}
})}
</div>
)}
</div>
);
})}
</div> </div>
); );