RYP / src /components /CSFundamentalsSection.tsx
Soumya79's picture
Upload 1361 files
f91a684 verified
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} />;
}
};
// ── Topic Row ─────────────────────────────────────────────────────────────────
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;
}
// ── Main ──────────────────────────────────────────────────────────────────────
export default function CSFundamentalsSection({ onSolve, onNavigateToDBMS, onNavigateToCN, onNavigateToOS }: CSFundamentalsSectionProps = {}) {
const [selectedTrack, setSelectedTrack] = useState<CSFundamentalsTrack | null>(null);
const [completed, setCompleted] = useState<Record<string, boolean>>(loadCompleted);
// Dynamic counts for actual pages
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) => {
// onSolve MUST be outside setCompleted — React 18 Strict Mode runs updaters
// twice in dev, so any API call inside fires twice (duplicate transactions).
setCompleted(prev => {
const isCompleting = !prev[key];
const next = { ...prev, [key]: isCompleting };
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
return next;
});
}, []);
// Fire onSolve exactly once when a topic transitions false → true.
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) {
// Find the title for this key by scanning all tracks
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]);
// Track-level progress
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]);
// Grid card track completion
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>
);
}