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:
@@ -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
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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,166 +435,52 @@ 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);
|
<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 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();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={module.id} className="mb-4">
|
<button
|
||||||
{/* Module Header - Collapsible */}
|
key={lesson.id}
|
||||||
<button
|
onClick={() => {
|
||||||
onClick={() => toggleModule(module.id)}
|
if (isReleased) {
|
||||||
className="w-full flex items-center gap-2 text-left mb-2 group"
|
handleSelectLesson(lesson);
|
||||||
>
|
setMobileMenuOpen(false);
|
||||||
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide flex-1">
|
}
|
||||||
{module.title}
|
}}
|
||||||
</h3>
|
disabled={!isReleased}
|
||||||
{isModuleCollapsed ? (
|
className={cn(
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
"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",
|
||||||
<ChevronUp className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
!isReleased && "opacity-50 cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
</button>
|
>
|
||||||
|
{isCompleted ? (
|
||||||
{/* Lessons - Hidden if module is collapsed */}
|
<Check className="w-4 h-4 shrink-0 text-accent" />
|
||||||
{!isModuleCollapsed && (
|
) : (lesson.video_url?.trim() || lesson.youtube_url?.trim() || lesson.embed_code?.trim()) ? (
|
||||||
<div className="space-y-1 ml-2">
|
<Play className="w-4 h-4 shrink-0" />
|
||||||
{module.lessons.map((lesson) => {
|
) : (
|
||||||
const isCompleted = isLessonCompleted(lesson.id);
|
<BookOpen className="w-4 h-4 shrink-0" />
|
||||||
const isSelected = selectedLesson?.id === lesson.id;
|
)}
|
||||||
const isReleased = !lesson.release_at || new Date(lesson.release_at) <= new Date();
|
<span className="truncate flex-1">{lesson.title}</span>
|
||||||
const isLessonCollapsed = collapsedLessons.has(lesson.id);
|
{lesson.duration_seconds && (
|
||||||
const hasChapters = lesson.chapters && lesson.chapters.length > 0;
|
<span className="text-xs text-muted-foreground">{formatDuration(lesson.duration_seconds)}</span>
|
||||||
|
)}
|
||||||
return (
|
</button>
|
||||||
<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>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user