diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/src/app/categories/[slug]/page.tsx b/src/app/categories/[slug]/page.tsx new file mode 100644 index 0000000..4fa29c4 --- /dev/null +++ b/src/app/categories/[slug]/page.tsx @@ -0,0 +1,351 @@ +'use client'; + +import { useEffect, useState, useCallback, useMemo } from 'react'; +import { useParams, useRouter, useSearchParams, usePathname } from 'next/navigation'; +import { SlidersHorizontal } from 'lucide-react'; +import { coursesApi } from '@/lib/api/services'; +import { CourseCard } from '@/components/courses/CourseCard'; +import { CourseCardSkeleton } from '@/components/courses/CourseCardSkeleton'; +import { FilterSidebar } from '@/components/courses/FilterSidebar'; +import { CategoryHero, getCategoryMeta, CATEGORY_META } from '@/components/category/CategoryHero'; +import { cn } from '@/lib/utils'; +import { notFound } from 'next/navigation'; +import type { Course, Category } from '@/types'; + +// ── Constants ────────────────────────────────────────────────────────────── +const PAGE_SIZE = 48; // fetch enough for a static single-page view + +// ── Subcategory helpers ──────────────────────────────────────────────────── + +/** + * Derive up-to-8 subcategory pill labels from frequent meaningful words + * that appear in ≥2 course titles within this category. + */ +function deriveSubcategories(courses: Course[], categoryName: string): string[] { + const stop = new Set([ + 'and', 'the', 'of', 'for', 'in', 'a', 'an', 'to', 'with', + 'on', 'how', 'your', 'you', 'from', 'using', 'part', + categoryName.toLowerCase(), + ...categoryName.toLowerCase().split(' '), + ]); + + const counts = new Map(); + for (const c of courses) { + const words = c.title.toLowerCase().split(/\W+/); + for (const word of words) { + if (word.length > 3 && !stop.has(word)) { + counts.set(word, (counts.get(word) ?? 0) + 1); + } + } + } + + return [...counts.entries()] + .filter(([, n]) => n >= 2) + .sort((a, b) => b[1] - a[1]) + .slice(0, 8) + .map(([w]) => w.charAt(0).toUpperCase() + w.slice(1)); +} + +// ── Page ─────────────────────────────────────────────────────────────────── + +export default function CategoryPage() { + const params = useParams<{ slug: string }>(); + const slug = params.slug; + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const meta = getCategoryMeta(slug); + + // ── URL-driven filter state ──────────────────────────────────────────── + const activeSub = searchParams.get('sub') ?? ''; + const activeSort = searchParams.get('sort') ?? 'popular'; + const activeLevel = searchParams.get('level') ?? ''; + const activePrice = searchParams.get('price') ?? ''; + const activeRating = searchParams.get('rating') ?? ''; + const activeDuration = searchParams.get('duration') ?? ''; + + // ── Data ────────────────────────────────────────────────────────────── + const [allCourses, setAllCourses] = useState([]); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [invalid, setInvalid] = useState(false); + + // Helper: push updated params to the URL without full navigation + const updateParams = useCallback((updates: Record) => { + const p = new URLSearchParams(searchParams.toString()); + Object.entries(updates).forEach(([k, v]) => { + if (v) p.set(k, v); + else p.delete(k); + }); + router.replace(`${pathname}?${p.toString()}`, { scroll: false }); + }, [router, pathname, searchParams]); + + // Fetch all courses in this category + the categories list (for sidebar) + useEffect(() => { + let cancelled = false; + setLoading(true); + + Promise.all([ + coursesApi.list({ category: meta.name, limit: PAGE_SIZE }), + coursesApi.getCategories(), + ]) + .then(([res, cats]) => { + if (cancelled) return; + const courses = res.data ?? []; + // 404 for unknown slug with zero courses + if (courses.length === 0 && !(slug in CATEGORY_META)) { + setInvalid(true); + return; + } + setAllCourses(courses); + setCategories(cats); + }) + .catch(() => { + if (!cancelled) setInvalid(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { cancelled = true; }; + // Re-fetch when the category slug changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [slug]); + + // Subcategory pills — derived from fetched course titles + const subcategories = useMemo( + () => deriveSubcategories(allCourses, meta.name), + [allCourses, meta.name], + ); + + // ── Client-side filter + sort ────────────────────────────────────────── + const displayed = useMemo(() => { + let list = [...allCourses]; + + // Subcategory keyword filter + if (activeSub) { + list = list.filter((c) => + c.title.toLowerCase().includes(activeSub.toLowerCase()), + ); + } + + // Level filter + if (activeLevel) { + list = list.filter((c) => + c.level?.toLowerCase() === activeLevel.toLowerCase(), + ); + } + + // Price filter + if (activePrice) { + list = list.filter((c) => { + const p = Number(c.price); + if (activePrice === 'free') return p === 0; + if (activePrice === 'under20') return p > 0 && p < 20; + if (activePrice === '20to50') return p >= 20 && p <= 50; + if (activePrice === 'over50') return p > 50; + return true; + }); + } + + // Rating filter + if (activeRating) { + const min = parseFloat(activeRating); + if (!isNaN(min)) list = list.filter((c) => (c.rating ?? 0) >= min); + } + + // Duration filter + if (activeDuration) { + list = list.filter((c) => { + const hrs = (c.totalDuration ?? 0) / 3600; + if (activeDuration === 'short') return hrs <= 2; + if (activeDuration === 'medium') return hrs > 2 && hrs <= 6; + if (activeDuration === 'long') return hrs > 6; + return true; + }); + } + + // Sort + if (activeSort === 'newest') { + list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + } else if (activeSort === 'rated') { + list.sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0)); + } else { + // popular (default) + list.sort((a, b) => (b._count?.enrollments ?? 0) - (a._count?.enrollments ?? 0)); + } + + return list; + }, [allCourses, activeSub, activeLevel, activePrice, activeRating, activeDuration, activeSort]); + + const clearAll = () => + updateParams({ sub: '', level: '', price: '', rating: '', duration: '', sort: '' }); + + // ── 404 ─────────────────────────────────────────────────────────────── + if (invalid) notFound(); + + // ── Render ──────────────────────────────────────────────────────────── + return ( +
+ + {/* ── Hero ──────────────────────────────────────────────────────── */} + + +
+ + {/* ── Subcategory pills ──────────────────────────────────────── */} + {(loading || subcategories.length > 0) && ( +
+ {/* "All" pill */} + + + {loading + ? // Skeleton pills while loading + Array.from({ length: 5 }).map((_, i) => ( + + )} + + {/* ── Main layout: sidebar + grid ───────────────────────────── */} +
+ + {/* FilterSidebar (desktop sticky / mobile drawer) */} + setSidebarOpen(false)} + categories={categories} + activeCategory={meta.name} // lock to this category + activeLevel={activeLevel} + activePrice={activePrice} + activeRating={activeRating} + activeDuration={activeDuration} + onCategory={() => {}} // category locked on this page + onLevel={(v) => updateParams({ level: v })} + onPrice={(v) => updateParams({ price: v })} + onRating={(v) => updateParams({ rating: v })} + onDuration={(v) => updateParams({ duration: v })} + onClearAll={clearAll} + /> + + {/* Main content */} +
+ + {/* Toolbar */} +
+
+ {/* Mobile filter button */} + + + {/* Result count */} +

+ {loading ? ( + + ) : ( + <> + + {displayed.length.toLocaleString()} + {' '} + {displayed.length === 1 ? 'course' : 'courses'} + {activeSub && ( + <> matching {activeSub} + )} + + )} +

+
+ + {/* Sort */} + +
+ + {/* Grid */} + {loading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : displayed.length === 0 ? ( +
+ +

No courses found

+

+ Try adjusting your filters. +

+ +
+ ) : ( + /* 2-col mobile → 3-col tablet → 4-col desktop */ +
+ {displayed.map((course, i) => ( + + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/src/app/dashboard/courses/[id]/learn/page.tsx b/src/app/dashboard/courses/[id]/learn/page.tsx index 541ff31..e382b42 100644 --- a/src/app/dashboard/courses/[id]/learn/page.tsx +++ b/src/app/dashboard/courses/[id]/learn/page.tsx @@ -1,69 +1,30 @@ "use client"; -import { useEffect, useState, useRef } from "react"; -import { useParams } from "next/navigation"; -import Link from "next/link"; +import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; +import { useParams } from 'next/navigation'; import { - CheckCircle2, - Circle, - ChevronDown, - ChevronRight, - Download, - Loader2, - ArrowLeft, - MessageSquare, - FileText, - Award, -} from "lucide-react"; -import { coursesApi, enrollmentsApi, lessonsApi } from "@/lib/api/services"; -import { formatDuration, cn } from "@/lib/utils"; -import type { Course, Enrollment, Lesson } from "@/types"; -import NotesPanel from "@/components/learn/NotesPanel"; -import CourseCompletionModal from "@/components/learn/CourseCompletionModal"; -import QuizComponent from "@/components/learn/QuizComponent"; -import { Breadcrumb } from "@/components/ui"; -import type { QuizQuestion } from "@/types"; - -type SidebarTab = "lessons" | "notes" | "qa"; - -const DEFAULT_QUIZ_QUESTIONS: QuizQuestion[] = [ - { - id: "default-1", - type: "multiple_choice", - question: "What does the Stellar network primarily do?", - options: [ - { id: "a", text: "Mine new coins", isCorrect: false }, - { - id: "b", - text: "Facilitate fast, low-cost cross-border payments", - isCorrect: true, - }, - { id: "c", text: "Host smart contracts only", isCorrect: false }, - ], - explanation: - "Stellar is a payment-focused blockchain network built for fast, low-cost transfers.", - }, - { - id: "default-2", - type: "multi_select", - question: "Which of the following are Stellar-native assets?", - options: [ - { id: "a", text: "XLM (Lumens)", isCorrect: true }, - { id: "b", text: "Issued credit assets", isCorrect: true }, - { id: "c", text: "Bitcoin", isCorrect: false }, - { id: "d", text: "Ether", isCorrect: false }, - ], - explanation: - "XLM and issued credit assets are native to Stellar; Bitcoin and Ether exist on other networks.", - }, - { - id: "default-3", - type: "true_false", - question: "Stellar transactions are finalized in seconds, not minutes.", - explanation: - "The Stellar network settles transactions in roughly 3-5 seconds.", - }, -]; + CheckCircle2, Circle, ChevronDown, ChevronRight, + Download, Loader2, ArrowLeft, Award, SkipForward, +} from 'lucide-react'; +import Link from 'next/link'; +import { coursesApi, enrollmentsApi, lessonsApi } from '@/lib/api/services'; +import { formatDuration, cn } from '@/lib/utils'; +import CourseCompletionModal from '@/components/learn/CourseCompletionModal'; +import { VideoPlayer } from '@/components/learn/VideoPlayer'; +import { AutoplayCountdown } from '@/components/learn/AutoplayCountdown'; +import type { Course, Enrollment, Lesson } from '@/types'; + +// ── Constants ────────────────────────────────────────────────────────────── +const AUTOPLAY_STORAGE_KEY = 'hamplard:autoplay'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** Returns the flat ordered list of all lessons across every module */ +function flatLessons(course: Course): Lesson[] { + return course.modules?.flatMap((m) => m.lessons) ?? []; +} + +// ── Component ────────────────────────────────────────────────────────────── export default function LearnPage() { const { id } = useParams<{ id: string }>(); @@ -77,47 +38,91 @@ export default function LearnPage() { const [sidebarTab, setSidebarTab] = useState("lessons"); const [showCompletionModal, setShowCompletionModal] = useState(false); - const videoRef = useRef(null); + // ── Autoplay state ─────────────────────────────────────────────────────── + const [autoplay, setAutoplay] = useState(() => { + if (typeof window === 'undefined') return true; + const stored = window.localStorage.getItem(AUTOPLAY_STORAGE_KEY); + return stored === null ? true : stored === 'true'; + }); + const [showCountdown, setShowCountdown] = useState(false); + const progressRef = useRef(null); const previousCompletedCountRef = useRef(null); const hasInitializedCompletionState = useRef(false); + // ── Derived: next lesson ───────────────────────────────────────────────── + const nextLesson = useMemo(() => { + if (!course || !activeLesson) return null; + const all = flatLessons(course); + const idx = all.findIndex((l) => l.id === activeLesson.id); + return idx >= 0 && idx < all.length - 1 ? all[idx + 1] : null; + }, [course, activeLesson]); + + // ── Persist autoplay preference ────────────────────────────────────────── + const handleAutoplayChange = useCallback((enabled: boolean) => { + setAutoplay(enabled); + if (typeof window !== 'undefined') { + window.localStorage.setItem(AUTOPLAY_STORAGE_KEY, String(enabled)); + } + // If the user turns autoplay off while the countdown is visible, cancel it + if (!enabled) setShowCountdown(false); + }, []); + + // ── Video ended handler ────────────────────────────────────────────────── + const handleVideoEnded = useCallback(() => { + if (autoplay && nextLesson) { + setShowCountdown(true); + } + // If autoplay is off or there's no next lesson the video just stops + }, [autoplay, nextLesson]); + + // ── Navigate to next lecture ───────────────────────────────────────────── + const goToNext = useCallback(() => { + if (!nextLesson) return; + setShowCountdown(false); + setActiveLesson(nextLesson); + }, [nextLesson]); + + const cancelCountdown = useCallback(() => { + setShowCountdown(false); + }, []); + + // ── Initial data load ──────────────────────────────────────────────────── useEffect(() => { - Promise.all([coursesApi.get(id), enrollmentsApi.get(id)]) - .then(([c, e]) => { - setCourse(c); - setEnrollment(e); - // Open first module by default - if (c.modules?.[0]) setExpanded({ [c.modules[0].id]: true }); - // Start from first incomplete lesson - const allLessons = c.modules?.flatMap((m) => m.lessons) ?? []; - const completedIds = new Set( - e.lessonProgress?.filter((p) => p.completed).map((p) => p.lessonId), - ); - const first = - allLessons.find((l) => !completedIds.has(l.id)) ?? allLessons[0]; - if (first) setActiveLesson(first); - }) - .catch(console.error) - .finally(() => setLoading(false)); - - return () => { - if (progressRef.current) clearInterval(progressRef.current); - }; + Promise.all([ + coursesApi.get(id), + enrollmentsApi.get(id), + ]).then(([c, e]) => { + setCourse(c); + setEnrollment(e); + // Open first module by default + if (c.modules?.[0]) setExpanded({ [c.modules[0].id]: true }); + // Start from first incomplete lesson + const allLessons = flatLessons(c); + const completedIds = new Set( + e.lessonProgress?.filter((p: { completed: boolean }) => p.completed).map((p: { lessonId: string }) => p.lessonId), + ); + const first = allLessons.find((l) => !completedIds.has(l.id)) ?? allLessons[0]; + if (first) setActiveLesson(first); + }) + .catch(console.error) + .finally(() => setLoading(false)); + + return () => { if (progressRef.current) clearInterval(progressRef.current); }; }, [id]); + // ── Course completion detection ────────────────────────────────────────── useEffect(() => { if (!course || !enrollment) return; - const totalLessons = course.modules?.flatMap((m) => m.lessons).length ?? 0; - const completedCount = - enrollment.lessonProgress?.filter((p) => p.completed).length ?? 0; + const totalLessons = flatLessons(course).length; + const completedCount = enrollment.lessonProgress?.filter((p: { completed: boolean }) => p.completed).length ?? 0; const isComplete = totalLessons > 0 && completedCount >= totalLessons; - const prevIncomplete = + const previouslyIncomplete = previousCompletedCountRef.current === null || previousCompletedCountRef.current < totalLessons; const newlyCompleted = - hasInitializedCompletionState.current && prevIncomplete && isComplete; + hasInitializedCompletionStateRef.current && previouslyIncomplete && isComplete; previousCompletedCountRef.current = completedCount; hasInitializedCompletionState.current = true; @@ -129,8 +134,8 @@ export default function LearnPage() { const storageKey = `course-completion:${course.id}`; const hasSeenModal = - typeof window !== "undefined" && - window.localStorage.getItem(storageKey) === "true"; + typeof window !== 'undefined' && + window.localStorage.getItem(storageKey) === 'true'; if (newlyCompleted && !hasSeenModal) { setShowCompletionModal(true); @@ -142,9 +147,15 @@ export default function LearnPage() { } }, [course, enrollment]); + // ── Dismiss countdown when lesson changes (e.g. sidebar click) ────────── + useEffect(() => { + setShowCountdown(false); + }, [activeLesson]); + + // ── Helpers ────────────────────────────────────────────────────────────── const isLessonCompleted = (lessonId: string) => enrollment?.lessonProgress?.some( - (p) => p.lessonId === lessonId && p.completed, + (p: { lessonId: string; completed: boolean }) => p.lessonId === lessonId && p.completed, ) ?? false; const handleMarkComplete = async () => { @@ -159,25 +170,16 @@ export default function LearnPage() { } }; - const totalLessons = course?.modules?.flatMap((m) => m.lessons).length ?? 0; - const completedCount = - enrollment?.lessonProgress?.filter((p) => p.completed).length ?? 0; - const progress = - totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0; + const totalLessons = course ? flatLessons(course).length : 0; + const completedCount = enrollment?.lessonProgress?.filter((p: { completed: boolean }) => p.completed).length ?? 0; + const progress = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0; - const allLessons = course?.modules?.flatMap((m) => m.lessons) ?? []; - const currentIndex = allLessons.findIndex((l) => l.id === activeLesson?.id); - const nextLesson = - currentIndex >= 0 && currentIndex < allLessons.length - 1 - ? allLessons[currentIndex + 1] - : null; - - if (loading) - return ( -
- -
- ); + // ── Render ──────────────────────────────────────────────────────────────── + if (loading) return ( +
+ +
+ ); if (!course) return ( @@ -195,52 +197,38 @@ export default function LearnPage() { /> )} - {/* ÔöÇÔöÇ Left sidebar ÔöÇÔöÇ */} -