| import React, { useMemo, useState, useCallback, useEffect, useRef, type Key } from 'react'; |
| import { motion, AnimatePresence } from 'motion/react'; |
| import { |
| ArrowLeft, ChevronRight, Database, Monitor, Network, FileText, |
| Layers, BookOpen, ChevronDown, ChevronUp, Lightbulb, CheckCircle2, Circle, Trophy, Zap |
| } from 'lucide-react'; |
| import { Button } from '@/components/ui/button'; |
| import { Badge } from '@/components/ui/badge'; |
| import { csFundamentalsTracks, type CSFundamentalsTrack } from '@/data/csFundamentalsData'; |
| import { cn } from '@/lib/utils'; |
|
|
| const STORAGE_KEY = 'cs_fundamentals_completed'; |
|
|
| function loadCompleted(): Record<string, boolean> { |
| try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch { return {}; } |
| } |
|
|
| function countTopics(track: CSFundamentalsTrack) { |
| return track.modules.reduce((t, m) => t + m.topics.length, 0); |
| } |
|
|
| const getTrackIcon = (id: string, size = 40) => { |
| switch (id) { |
| case 'database': return <Database size={size} />; |
| case 'os': return <Monitor size={size} />; |
| case 'computer-network': return <Network size={size} />; |
| default: return <FileText size={size} />; |
| } |
| }; |
|
|
| |
| function TopicRow({ trackId, moduleId, topic, index, animDelay, completed, onToggle }: { |
| key?: Key; |
| trackId: string; moduleId: string; |
| topic: { title: string; explanation: string }; |
| index: number; animDelay: number; |
| completed: boolean; onToggle: (key: string, title: string) => void; |
| }) { |
| const [open, setOpen] = useState(false); |
| const key = `${trackId}__${moduleId}__${index}`; |
|
|
| return ( |
| <motion.div initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: animDelay }}> |
| <div className={cn( |
| 'group border-b border-zinc-800/50 transition-colors last:border-b-0', |
| index % 2 === 0 ? 'bg-zinc-950/30' : 'bg-zinc-900/20', |
| completed ? 'bg-emerald-500/[0.04]' : open ? 'bg-cyan-500/[0.04]' : 'hover:bg-zinc-800/50' |
| )}> |
| {/* Main row */} |
| <div className="flex items-center gap-3 px-5 py-3.5"> |
| {/* Complete toggle */} |
| <button |
| type="button" |
| onClick={() => onToggle(key, topic.title)} |
| className="shrink-0 transition-transform hover:scale-110" |
| title={completed ? 'Mark incomplete' : 'Mark complete'} |
| > |
| {completed |
| ? <CheckCircle2 size={20} className="text-emerald-400" /> |
| : <Circle size={20} className="text-zinc-600 group-hover:text-zinc-400" /> |
| } |
| </button> |
| |
| {/* Number */} |
| <div className={cn( |
| 'flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-black', |
| completed |
| ? 'bg-emerald-500/20 text-emerald-400' |
| : 'bg-zinc-800 text-zinc-500' |
| )}> |
| {index + 1} |
| </div> |
| |
| {/* Title */} |
| <button |
| type="button" |
| onClick={() => setOpen(!open)} |
| className={cn( |
| 'flex-1 text-left text-sm font-semibold transition-colors', |
| completed ? 'text-emerald-400 line-through decoration-emerald-500/40' : 'text-zinc-200 hover:text-white' |
| )} |
| > |
| {topic.title} |
| </button> |
| |
| {/* Expand */} |
| <button type="button" onClick={() => setOpen(!open)} className="shrink-0 text-zinc-600 hover:text-cyan-400 transition-colors"> |
| {open ? <ChevronUp size={15} /> : <ChevronDown size={15} />} |
| </button> |
| </div> |
| |
| {/* Expandable explanation */} |
| <AnimatePresence> |
| {open && ( |
| <motion.div |
| initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} |
| exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.2 }} |
| className="overflow-hidden" |
| > |
| <div className="flex gap-3 px-14 pb-4 pt-1"> |
| <Lightbulb size={14} className="mt-0.5 shrink-0 text-cyan-400/60" /> |
| <p className="text-sm leading-relaxed text-zinc-400">{topic.explanation}</p> |
| </div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| </div> |
| </motion.div> |
| ); |
| } |
|
|
| interface CSFundamentalsSectionProps { |
| onSolve?: (id: string, title: string) => void; |
| onNavigateToDBMS?: () => void; |
| onNavigateToCN?: () => void; |
| onNavigateToOS?: () => void; |
| } |
|
|
| |
| export default function CSFundamentalsSection({ onSolve, onNavigateToDBMS, onNavigateToCN, onNavigateToOS }: CSFundamentalsSectionProps = {}) { |
| const [selectedTrack, setSelectedTrack] = useState<CSFundamentalsTrack | null>(null); |
| const [completed, setCompleted] = useState<Record<string, boolean>>(loadCompleted); |
|
|
| |
| const [dbmsCount, setDbmsCount] = useState<number | null>(null); |
| const [osCount, setOsCount] = useState<number | null>(null); |
| const [cnCount, setCnCount] = useState<number | null>(null); |
|
|
| useEffect(() => { |
| import('@/lib/dbmsClient').then(m => m.fetchDBMSTopics()).then(res => setDbmsCount(res.length)).catch(() => {}); |
| import('@/lib/osClient').then(m => m.fetchOSSections()).then(res => setOsCount(res.length)).catch(() => {}); |
| import('@/lib/cnClient').then(m => m.fetchCNTopics()).then(res => setCnCount(res.length)).catch(() => {}); |
| }, []); |
|
|
| const topicCounts = useMemo( |
| () => Object.fromEntries(csFundamentalsTracks.map(t => [t.id, countTopics(t)])), |
| [] |
| ); |
|
|
| const toggleTopic = useCallback((key: string, _title: string) => { |
| |
| |
| setCompleted(prev => { |
| const isCompleting = !prev[key]; |
| const next = { ...prev, [key]: isCompleting }; |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); |
| return next; |
| }); |
| }, []); |
|
|
| |
| const prevCompletedRef = useRef<Record<string, boolean>>(completed); |
| useEffect(() => { |
| const prev = prevCompletedRef.current; |
| for (const key of Object.keys(completed)) { |
| if (completed[key] && !prev[key] && onSolve) { |
| |
| let foundTitle = key; |
| for (const track of csFundamentalsTracks) { |
| for (const mod of track.modules) { |
| mod.topics.forEach((topic, ti) => { |
| if (`${track.id}__${mod.id}__${ti}` === key) { |
| foundTitle = topic.title; |
| } |
| }); |
| } |
| } |
| onSolve(`cs-${key}`, foundTitle); |
| } |
| } |
| prevCompletedRef.current = completed; |
| }, [completed, onSolve]); |
|
|
| |
| const trackProgress = useMemo(() => { |
| if (!selectedTrack) return { done: 0, total: 0, pct: 0 }; |
| let done = 0, total = 0; |
| selectedTrack.modules.forEach((m, mi) => { |
| m.topics.forEach((_, ti) => { |
| total++; |
| if (completed[`${selectedTrack.id}__${m.id}__${ti}`]) done++; |
| }); |
| }); |
| return { done, total, pct: total > 0 ? Math.round((done / total) * 100) : 0 }; |
| }, [selectedTrack, completed]); |
|
|
| |
| const getGridProgress = (track: CSFundamentalsTrack) => { |
| if (track.id === 'database') { |
| const dbmsProgress = JSON.parse(localStorage.getItem('ryp_dbms_progress') || '{}'); |
| const done = Object.values(dbmsProgress).filter(Boolean).length; |
| const total = dbmsCount || 7; |
| return { done, total, pct: total > 0 ? Math.round((done / total) * 100) : 0, label: 'chapters' }; |
| } |
| if (track.id === 'os') { |
| const osProgress = JSON.parse(localStorage.getItem('ryp_os_progress') || '{}'); |
| const done = Object.values(osProgress).filter(Boolean).length; |
| const total = osCount || 6; |
| return { done, total, pct: total > 0 ? Math.round((done / total) * 100) : 0, label: 'sections' }; |
| } |
| if (track.id === 'computer-network') { |
| const cnProgress = JSON.parse(localStorage.getItem('ryp_cn_progress') || '{}'); |
| const done = Object.values(cnProgress).filter(Boolean).length; |
| const total = cnCount || 12; |
| return { done, total, pct: total > 0 ? Math.round((done / total) * 100) : 0, label: 'chapters' }; |
| } |
|
|
| let done = 0, total = 0; |
| track.modules.forEach(m => m.topics.forEach((_, ti) => { |
| total++; |
| if (completed[`${track.id}__${m.id}__${ti}`]) done++; |
| })); |
| return { done, total, pct: total > 0 ? Math.round((done / total) * 100) : 0, label: 'topics' }; |
| }; |
|
|
| const getDynamicLabel = (trackId: string) => { |
| if (trackId === 'database') return dbmsCount !== null ? `${dbmsCount} Chapters` : 'Loading...'; |
| if (trackId === 'os') return osCount !== null ? `${osCount} Sections` : 'Loading...'; |
| if (trackId === 'computer-network') return cnCount !== null ? `${cnCount} Chapters` : 'Loading...'; |
| return ''; |
| }; |
|
|
| return ( |
| <div className="mx-auto max-w-5xl space-y-8 pb-12"> |
| {/* Header */} |
| <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> |
| <div> |
| {!selectedTrack ? ( |
| <div className="flex items-center gap-3"> |
| <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-cyan-500/20 bg-cyan-500/10 text-cyan-300"> |
| <Layers size={24} /> |
| </div> |
| <h2 className="text-2xl font-bold text-white">CS Fundamentals</h2> |
| </div> |
| ) : ( |
| <> |
| <div className="flex items-center gap-2 text-xs font-black uppercase tracking-[0.28em] text-cyan-300/80"> |
| <Layers size={16} /> CS Fundamentals / {selectedTrack.title} |
| </div> |
| <h2 className="mt-3 text-4xl font-black tracking-tight text-white">{selectedTrack.title}</h2> |
| <p className="mt-2 max-w-2xl text-sm leading-6 text-slate-400"> |
| Master the core concepts of {selectedTrack.title.toLowerCase()} essential for top-tier engineering interviews and system design. |
| </p> |
| </> |
| )} |
| </div> |
| {selectedTrack && ( |
| <Button variant="outline" onClick={() => setSelectedTrack(null)} className="border-slate-700 bg-slate-900/70 text-slate-200 hover:bg-slate-800 self-start mt-1"> |
| <ArrowLeft size={16} /> Back |
| </Button> |
| )} |
| </div> |
|
|
| <AnimatePresence mode="wait"> |
| {/* ── Track Selection Grid ── */} |
| {!selectedTrack && ( |
| <motion.div key="tracks" initial={{ opacity: 0, y: 18 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -18 }} |
| className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> |
| {csFundamentalsTracks.map((track, idx) => { |
| const { done, total, pct, label } = getGridProgress(track); |
| const dynamicLabel = getDynamicLabel(track.id); |
| return ( |
| <motion.button key={track.id} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: idx * 0.1 }} |
| type="button" onClick={() => { |
| if (track.id === 'database' && onNavigateToDBMS) { |
| onNavigateToDBMS(); |
| } else if (track.id === 'computer-network' && onNavigateToCN) { |
| onNavigateToCN(); |
| } else if (track.id === 'os' && onNavigateToOS) { |
| onNavigateToOS(); |
| } else { |
| setSelectedTrack(track); |
| } |
| }} |
| className="group relative flex min-h-[290px] w-full flex-col overflow-hidden rounded-[32px] border border-zinc-800/80 bg-zinc-950/40 p-8 text-left backdrop-blur-sm transition-all duration-500 hover:-translate-y-1 hover:border-cyan-500/30 hover:bg-zinc-900/60 hover:shadow-2xl cursor-pointer" |
| > |
| <div className="absolute -right-20 -top-20 h-64 w-64 rounded-full blur-[100px] transition-all duration-500 group-hover:scale-110 bg-cyan-500/10 group-hover:bg-cyan-500/20" /> |
| <div className="relative z-10 flex items-start justify-between"> |
| <div className="flex h-16 w-16 items-center justify-center rounded-2xl border border-cyan-500/20 bg-cyan-500/10 text-cyan-400 shadow-inner transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"> |
| {getTrackIcon(track.id, 28)} |
| </div> |
| <div className="flex items-center gap-1.5 rounded-full border border-cyan-500/20 bg-cyan-500/5 px-3 py-1 text-[10px] font-black uppercase tracking-wider text-cyan-300"> |
| {dynamicLabel ? dynamicLabel : `${track.modules.length} Modules`} |
| </div> |
| </div> |
| <div className="relative z-10 mt-6 flex-1"> |
| <h3 className="text-2xl font-bold tracking-tight text-zinc-100 transition-colors group-hover:text-cyan-400">{track.title}</h3> |
| <p className="mt-2 text-sm font-medium text-zinc-400">{dynamicLabel ? track.level : `${total} topics / ${track.level}`}</p> |
| </div> |
| {/* Progress bar */} |
| <div className="relative z-10 mt-6 space-y-2"> |
| <div className="flex items-center justify-between text-xs font-bold"> |
| <span className="text-zinc-500">{done}/{total} {label} done</span> |
| <span className={pct === 100 ? 'text-emerald-400' : 'text-cyan-400'}>{pct}%</span> |
| </div> |
| <div className="h-1.5 w-full overflow-hidden rounded-full bg-zinc-800/80"> |
| <div className={cn('h-full rounded-full transition-all duration-1000', pct === 100 ? 'bg-emerald-500' : 'bg-cyan-500')} |
| style={{ width: `${pct}%` }} /> |
| </div> |
| </div> |
| {pct === 100 && ( |
| <div className="relative z-10 mt-3 flex items-center gap-1.5 text-xs font-black text-emerald-400"> |
| <Trophy size={13} /> Track Completed! |
| </div> |
| )} |
| </motion.button> |
| ); |
| })} |
| </motion.div> |
| )} |
| |
| {/* ── Track Detail ── */} |
| {selectedTrack && ( |
| <motion.div key="modules" initial={{ opacity: 0, x: 18 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -18 }} className="space-y-6"> |
| |
| {/* ── Coding-Ninjas-style Track Progress Banner ── */} |
| <div className="relative overflow-hidden rounded-[2rem] border border-cyan-500/20 bg-[#07111d]/95 p-8 shadow-[0_24px_80px_-60px_rgba(8,145,178,0.5)] backdrop-blur-xl"> |
| <div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(8,145,178,0.12),transparent_55%)]" /> |
| <div className="absolute inset-0 bg-[radial-gradient(circle_at_bottom_left,rgba(99,102,241,0.07),transparent_50%)]" /> |
| |
| <div className="relative z-10 flex flex-col gap-6 md:flex-row md:items-center md:justify-between"> |
| {/* Left: info */} |
| <div className="flex items-center gap-5"> |
| <div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-2xl border border-cyan-500/20 bg-cyan-500/10 text-cyan-400 shadow-inner"> |
| {getTrackIcon(selectedTrack.id, 28)} |
| </div> |
| <div> |
| <div className="inline-flex items-center gap-2 rounded-full border border-cyan-400/20 bg-cyan-400/10 px-3 py-1"> |
| <BookOpen size={12} className="text-cyan-300" /> |
| <span className="text-[10px] font-black uppercase tracking-widest text-cyan-300">Track Progress</span> |
| </div> |
| <div className="mt-2 flex items-end gap-2"> |
| <span className="text-4xl font-black text-white">{trackProgress.done}</span> |
| <span className="mb-1 text-lg font-bold text-zinc-500">/ {trackProgress.total} topics</span> |
| </div> |
| <div className="flex gap-4 mt-1 text-xs text-zinc-500 font-semibold"> |
| <span>{selectedTrack.modules.length} modules</span> |
| <span>|</span> |
| <span>{selectedTrack.level}</span> |
| <span>|</span> |
| <span>{selectedTrack.duration}</span> |
| </div> |
| </div> |
| </div> |
| |
| {/* Right: circular-ish progress display */} |
| <div className="flex w-full flex-col items-start gap-3 md:w-auto md:min-w-[200px] md:items-end mt-6 md:mt-0"> |
| <div className="w-full space-y-2"> |
| <div className="flex justify-between text-xs font-bold text-zinc-400"> |
| <span>Completion</span> |
| <span className={trackProgress.pct === 100 ? 'text-emerald-400' : 'text-cyan-400'}> |
| {trackProgress.pct}% |
| </span> |
| </div> |
| <div className="h-3 w-full overflow-hidden rounded-full bg-zinc-800/80"> |
| <motion.div |
| className={cn('h-full rounded-full shadow-[0_0_10px_rgba(255,255,255,0.15)]', |
| trackProgress.pct === 100 |
| ? 'bg-gradient-to-r from-emerald-500 to-emerald-400' |
| : 'bg-gradient-to-r from-cyan-500 to-blue-500' |
| )} |
| initial={{ width: 0 }} |
| animate={{ width: `${trackProgress.pct}%` }} |
| transition={{ duration: 0.8, ease: 'easeOut' }} |
| /> |
| </div> |
| </div> |
| |
| {/* Milestone badges */} |
| <div className="flex gap-2"> |
| {[25, 50, 75, 100].map(milestone => ( |
| <div key={milestone} |
| className={cn( |
| 'flex h-9 w-9 flex-col items-center justify-center rounded-xl border text-[9px] font-black transition-all', |
| trackProgress.pct >= milestone |
| ? 'border-cyan-500/40 bg-cyan-500/20 text-cyan-300 shadow-[0_0_8px_rgba(6,182,212,0.3)]' |
| : 'border-zinc-800 bg-zinc-900/50 text-zinc-700' |
| )} |
| > |
| <Zap size={10} className={trackProgress.pct >= milestone ? 'text-cyan-400' : 'text-zinc-700'} /> |
| {milestone}% |
| </div> |
| ))} |
| </div> |
| |
| {trackProgress.pct === 100 && ( |
| <div className="flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-4 py-1.5 text-xs font-black text-emerald-400"> |
| <Trophy size={12} /> Track Mastered! |
| </div> |
| )} |
| </div> |
| </div> |
| </div> |
| |
| {/* ── Module Tables ── */} |
| <div className="space-y-5"> |
| {selectedTrack.modules.map((module, mi) => { |
| const modDone = module.topics.filter((_, ti) => completed[`${selectedTrack.id}__${module.id}__${ti}`]).length; |
| const modTotal = module.topics.length; |
| const modPct = modTotal > 0 ? Math.round((modDone / modTotal) * 100) : 0; |
| |
| return ( |
| <motion.div key={module.id} initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: mi * 0.06 }} |
| className="overflow-hidden rounded-[22px] border border-zinc-800/80 bg-zinc-950/60 shadow-xl backdrop-blur-sm"> |
| <div className="h-[3px] bg-gradient-to-r from-cyan-500 via-blue-500 to-indigo-500 opacity-70" /> |
| |
| {/* Module header */} |
| <div className="flex flex-col gap-3 px-6 py-5 md:flex-row md:items-center md:justify-between border-b border-zinc-800/40"> |
| <div className="flex items-center gap-4"> |
| <div className={cn( |
| 'flex h-10 w-10 shrink-0 items-center justify-center rounded-xl ring-1 transition-all', |
| modDone === modTotal && modTotal > 0 |
| ? 'bg-emerald-500/15 text-emerald-400 ring-emerald-500/30' |
| : 'bg-cyan-500/10 text-cyan-400 ring-cyan-500/20' |
| )}> |
| {modDone === modTotal && modTotal > 0 ? <CheckCircle2 size={18} /> : <Layers size={18} />} |
| </div> |
| <div> |
| <h2 className="text-base font-black tracking-tight text-white">{mi + 1}. {module.title}</h2> |
| <p className="text-xs text-zinc-500 mt-0.5 max-w-lg">{module.summary}</p> |
| </div> |
| </div> |
| |
| <div className="flex items-center gap-4 shrink-0"> |
| {/* Mini progress */} |
| <div className="flex items-center gap-2"> |
| <div className="h-1.5 w-24 overflow-hidden rounded-full bg-zinc-800"> |
| <div className={cn('h-full rounded-full transition-all duration-700', |
| modDone === modTotal && modTotal > 0 ? 'bg-emerald-500' : 'bg-cyan-500' |
| )} style={{ width: `${modPct}%` }} /> |
| </div> |
| <span className={cn('text-[10px] font-black', |
| modDone === modTotal && modTotal > 0 ? 'text-emerald-400' : 'text-cyan-400' |
| )}>{modDone}/{modTotal}</span> |
| </div> |
| <Badge className="rounded-full border border-zinc-700 bg-zinc-900 px-3 py-1 text-[10px] font-black uppercase tracking-wider text-zinc-400"> |
| {modTotal} Topics |
| </Badge> |
| </div> |
| </div> |
| |
| {/* Table column header */} |
| <div className="border-b border-zinc-800/40 bg-zinc-900/50 px-5 py-2"> |
| <div className="flex items-center gap-3 text-[9px] font-black uppercase tracking-[0.28em] text-zinc-600"> |
| <span className="w-5">Done</span> |
| <span className="w-6 text-center">#</span> |
| <span>Topic</span> |
| </div> |
| </div> |
| |
| {/* Topic rows */} |
| <div> |
| {module.topics.map((topic, ti) => ( |
| <TopicRow |
| key={topic.title} |
| trackId={selectedTrack.id} |
| moduleId={module.id} |
| topic={topic} |
| index={ti} |
| animDelay={mi * 0.06 + ti * 0.025} |
| completed={!!completed[`${selectedTrack.id}__${module.id}__${ti}`]} |
| onToggle={toggleTopic} |
| /> |
| ))} |
| </div> |
| </motion.div> |
| ); |
| })} |
| </div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| </div> |
| ); |
| } |
|
|