Spaces:
Sleeping
Sleeping
| import { useEffect, useState } from 'react'; | |
| import { supabase } from '../supabaseClient'; | |
| import { ExternalLink, Search, CheckCircle, XCircle, ArrowLeft, RefreshCw, Star, Info } from 'lucide-react'; | |
| const JOURNEY_SEQUENCE = [ | |
| 217, 242, 1, 49, 347, 271, 238, 36, 128, 125, 167, 15, 11, 42, 121, 3, 424, 567, 76, 239, 20, | |
| 155, 150, 739, 853, 84, 704, 74, 875, 153, 33, 981, 4, 206, 21, 141, 143, 19, 138, 2, 287, | |
| 146, 23, 25, 226, 104, 543, 110, 100, 572, 235, 102, 199, 1448, 98, 230, 105, 124, 297, 703, | |
| 1046, 973, 215, 621, 355, 295, 78, 39, 40, 46, 90, 22, 79, 131, 17, 51, 208, 211, 212, 200, | |
| 695, 133, 286, 994, 417, 130, 207, 210, 261, 323, 684, 127, 743, 332, 1584, 778, 269, 787, | |
| 70, 746, 198, 213, 5, 647, 91, 322, 152, 139, 300, 416, 62, 1143, 309, 518, 494, 97, 329, | |
| 115, 72, 312, 10, 53, 55, 45, 134, 846, 1899, 763, 678, 57, 56, 435, 252, 253, 1851, 48, | |
| 54, 73, 202, 66, 50, 43, 2013, 136, 191, 338, 190, 268, 371, 7 | |
| ]; | |
| interface JourneyPathProps { | |
| onSelectProblemForReview: (problemId: string) => void; | |
| } | |
| export default function JourneyPath({ onSelectProblemForReview }: JourneyPathProps) { | |
| const [completions, setCompletions] = useState<Set<number>>(new Set()); | |
| const [titles, setTitles] = useState<Record<number, string>>({}); | |
| const [loading, setLoading] = useState(true); | |
| const [selectedNum, setSelectedNum] = useState<number | null>(null); | |
| const [details, setDetails] = useState<any[]>([]); | |
| const [modalLoading, setModalLoading] = useState(false); | |
| const [toggleLoading, setToggleLoading] = useState(false); | |
| const fetchData = async () => { | |
| try { | |
| const { data: { session } } = await supabase.auth.getSession(); | |
| if (!session) return; | |
| const headers = { 'Authorization': `Bearer ${session.access_token}` }; | |
| const [progRes, titlesRes] = await Promise.all([ | |
| fetch(`${import.meta.env.VITE_API_BASE_URL}/api/journey-progress`, { headers }), | |
| fetch(`${import.meta.env.VITE_API_BASE_URL}/api/journey-progress/titles`, { headers }) | |
| ]); | |
| if (progRes.ok && titlesRes.ok) { | |
| const progData = await progRes.json(); | |
| const titlesData = await titlesRes.json(); | |
| setCompletions(new Set(progData)); | |
| setTitles(titlesData); | |
| } | |
| } catch (err) { | |
| console.error('Error fetching journey data:', err); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| useEffect(() => { | |
| fetchData(); | |
| }, []); | |
| const handleNodeClick = async (num: number) => { | |
| setSelectedNum(num); | |
| setDetails([]); | |
| setModalLoading(true); | |
| try { | |
| const { data: { session } } = await supabase.auth.getSession(); | |
| if (!session) return; | |
| const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/reviews/problems/${num}`, { | |
| headers: { 'Authorization': `Bearer ${session.access_token}` } | |
| }); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| setDetails(data); | |
| } | |
| } catch (err) { | |
| console.error('Error fetching problem details:', err); | |
| } finally { | |
| setModalLoading(false); | |
| } | |
| }; | |
| const handleToggleCompletion = async (num: number) => { | |
| setToggleLoading(true); | |
| try { | |
| const { data: { session } } = await supabase.auth.getSession(); | |
| if (!session) return; | |
| const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/journey-progress/toggle`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${session.access_token}` | |
| }, | |
| body: JSON.stringify({ problem_number: num }) | |
| }); | |
| if (res.ok) { | |
| const updated = new Set(completions); | |
| if (updated.has(num)) { | |
| updated.delete(num); | |
| } else { | |
| updated.add(num); | |
| } | |
| setCompletions(updated); | |
| } | |
| } catch (err) { | |
| console.error('Error toggling progress:', err); | |
| } finally { | |
| setToggleLoading(false); | |
| } | |
| }; | |
| // Compute stats | |
| const total = JOURNEY_SEQUENCE.length; | |
| const completedCount = JOURNEY_SEQUENCE.filter(num => completions.has(num)).length; | |
| const percentage = total > 0 ? Math.round((completedCount / total) * 100) : 0; | |
| if (loading) { | |
| return ( | |
| <div className="w-full h-full flex items-center justify-center bg-[#fdf8e1] text-ink-black"> | |
| <div className="flex flex-col items-center gap-3"> | |
| <RefreshCw className="w-8 h-8 text-primary animate-spin" /> | |
| <span className="font-arcade text-[10px] tracking-wide">COMPILING MAP...</span> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // Determine active node: first node in sequence that is not completed | |
| const activeNode = JOURNEY_SEQUENCE.find(num => !completions.has(num)) || JOURNEY_SEQUENCE[0]; | |
| return ( | |
| <div className="w-full h-full bg-[#fdf8e1] p-6 overflow-y-auto custom-scrollbar flex flex-col relative"> | |
| <div className="flex flex-col gap-4"> | |
| {/* Header progress info */} | |
| <div className="flex flex-col md:flex-row items-center justify-between p-4 bg-white border-[3px] border-ink-black shadow-[4px_4px_0px_0px_rgba(30,41,59,1)] gap-4 select-none"> | |
| <div> | |
| <h3 className="font-headline-md font-bold text-ink-black text-sm m-0">MAP PROGRESS STATUS</h3> | |
| <span className="text-[10px] font-arcade text-trash-gray font-bold uppercase mt-1 block">Leitner path completion metrics</span> | |
| </div> | |
| <div className="flex items-center gap-4"> | |
| <span className="font-arcade text-[10px] font-bold text-ink-black">{percentage}% DONE</span> | |
| <div className="w-48 h-6 bg-slate-100 border-[3px] border-ink-black relative overflow-hidden"> | |
| <div | |
| className="absolute inset-y-0 left-0 bg-[#0ea5e9] border-r-[3px] border-ink-black transition-all duration-500" | |
| style={{ width: `${percentage}%` }} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Milestone path visualization */} | |
| <div className="bg-white border-[3px] border-ink-black shadow-[4px_4px_0px_0px_rgba(30,41,59,1)] relative p-8 mt-4 overflow-hidden min-h-[700px] flex flex-col items-center"> | |
| {/* Timeline Center Sine Path via SVG */} | |
| <svg className="absolute inset-y-0 w-full h-full pointer-events-none" preserveAspectRatio="none" viewBox="0 0 100 1000"> | |
| <path | |
| d="M 50,0 Q 70,50 30,100 T 50,200 T 70,300 T 30,400 T 50,500 T 70,600 T 30,700 T 50,800 T 70,900 T 50,1000" | |
| fill="none" | |
| opacity="0.3" | |
| stroke="#1E293B" | |
| strokeDasharray="5,15" | |
| strokeLinecap="round" | |
| strokeWidth="6" | |
| /> | |
| </svg> | |
| {/* Nodes Container */} | |
| <div className="relative z-10 flex flex-col items-center gap-10 w-full max-w-sm"> | |
| {JOURNEY_SEQUENCE.map((num, index) => { | |
| const isCompleted = completions.has(num); | |
| const isSynced = num in titles; | |
| const title = titles[num] || 'Unsynced Note'; | |
| const isActive = num === activeNode; | |
| // Sine wave displacement | |
| const xOffset = Math.sin(index * 0.8) * 75; | |
| return ( | |
| <div | |
| key={num} | |
| className="relative group flex items-center justify-center" | |
| style={{ transform: `translateX(${xOffset}px)` }} | |
| > | |
| {/* Tooltip */} | |
| <div className="absolute bottom-[115%] left-1/2 -translate-x-1/2 bg-paper-white text-ink-black border-[3px] border-ink-black text-[9px] font-arcade px-3 py-2 rounded-xl whitespace-nowrap shadow-[4px_4px_0px_0px_#1E293B] opacity-0 group-hover:opacity-100 transition-all duration-200 scale-90 group-hover:scale-100 pointer-events-none z-30"> | |
| <span className="font-bold text-primary mr-1">#{num}</span> | |
| <span>{title.toUpperCase()}</span> | |
| {!isSynced && <span className="ml-2 text-red-500 font-bold">(LOCKED)</span>} | |
| </div> | |
| {/* Circular / Rounded Square Node */} | |
| <button | |
| onClick={() => handleNodeClick(num)} | |
| className={`w-14 h-14 border-[3px] border-ink-black flex items-center justify-center font-arcade font-bold text-xs cursor-pointer transition-all duration-200 hover:scale-110 active:scale-95 ${ | |
| isCompleted | |
| ? 'bg-grass-green text-ink-black shadow-[3px_3px_0px_0px_#1E293B] rounded-lg' | |
| : isActive | |
| ? 'bg-[#ffe24c] text-ink-black shadow-[4px_4px_0px_0px_#F472B6] rounded-xl animate-bounce border-dashed' | |
| : isSynced | |
| ? 'bg-[#c0e8ff] text-ink-black shadow-[3px_3px_0px_0px_#1E293B] rounded-lg' | |
| : 'bg-slate-200 text-trash-gray opacity-60 shadow-[1px_1px_0px_0px_#1E293B] rounded-lg' | |
| }`} | |
| > | |
| {isCompleted ? '✓' : isActive ? '▶' : num} | |
| </button> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Modal Dialog (Styled as a Retro OS System Dialog) */} | |
| {selectedNum !== null && ( | |
| <div className="fixed inset-0 bg-black/50 backdrop-blur-xs flex items-center justify-center z-[100] p-4 animate-fade-in"> | |
| <div className="w-full max-w-md bg-paper-white border-[4px] border-ink-black p-0 shadow-[8px_8px_0px_0px_rgba(30,41,59,1)] relative select-none"> | |
| {/* Title Bar */} | |
| <div className="bg-[#0ea5e9] border-b-[4px] border-ink-black px-4 py-2 flex justify-between items-center text-white font-bold text-sm"> | |
| <div className="flex items-center gap-2"> | |
| <span className="material-symbols-outlined text-sm font-bold">info</span> | |
| <span>Problem_Detail.sys</span> | |
| </div> | |
| <button | |
| onClick={() => setSelectedNum(null)} | |
| className="w-6 h-6 bg-highlight-pink text-white border-2 border-ink-black flex items-center justify-center hover:bg-rose-600 active:translate-y-0.5 shadow-[1px_1px_0px_0px_rgba(30,41,59,1)] active:shadow-none transition-all cursor-pointer font-bold" | |
| > | |
| × | |
| </button> | |
| </div> | |
| {/* Dialog Content */} | |
| <div className="p-6 bg-[#fdf8e1] space-y-4"> | |
| <div className="pb-3 border-b-2 border-dashed border-ink-black"> | |
| <span className="text-[8px] font-arcade font-bold text-trash-gray uppercase block">Milestone Index</span> | |
| <h3 className="text-xl font-bold font-headline-md text-ink-black mt-1">LeetCode Problem #{selectedNum}</h3> | |
| </div> | |
| {modalLoading ? ( | |
| <div className="py-8 flex flex-col items-center justify-center gap-3"> | |
| <RefreshCw className="w-6 h-6 text-primary animate-spin" /> | |
| <span className="text-[8px] font-arcade font-bold text-trash-gray uppercase">FETCHING DATA...</span> | |
| </div> | |
| ) : details.length > 0 ? ( | |
| <div className="space-y-4"> | |
| {details.map((prob) => ( | |
| <div key={prob.id} className="p-4 bg-white border-[3px] border-ink-black shadow-[3px_3px_0px_0px_rgba(30,41,59,1)] space-y-3"> | |
| <div className="flex justify-between items-start gap-2"> | |
| <div> | |
| <h4 className="font-bold text-ink-black text-sm font-headline-md leading-tight m-0">{prob.name}</h4> | |
| <span className="text-[8px] font-arcade text-trash-gray font-bold uppercase mt-1 block">Pattern: {prob.pattern}</span> | |
| </div> | |
| <span className={`text-[8px] font-arcade font-bold px-2 py-0.5 border-2 border-ink-black ${ | |
| prob.difficulty === 'Easy' ? 'bg-grass-green text-ink-black' : | |
| prob.difficulty === 'Medium' ? 'bg-secondary-container text-ink-black' : | |
| 'bg-highlight-pink text-white' | |
| }`}> | |
| {prob.difficulty.toUpperCase()} | |
| </span> | |
| </div> | |
| <div className="flex gap-4 text-xs font-bold text-ink-black"> | |
| <div>Time: <code className="bg-[#1E293B] text-[#4ADE80] font-code-mono px-1.5 py-0.5 border border-ink-black">{prob.optimal_time}</code></div> | |
| <div>Space: <code className="bg-[#1E293B] text-[#4ADE80] font-code-mono px-1.5 py-0.5 border border-ink-black">{prob.optimal_space}</code></div> | |
| </div> | |
| <div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-slate-200"> | |
| <a | |
| href={prob.google_doc_url} | |
| target="_blank" | |
| rel="noreferrer" | |
| className="flex-1 flex items-center justify-center gap-1.5 py-2 border-[3px] border-ink-black bg-white hover:bg-slate-100 text-xs font-bold text-ink-black shadow-[2px_2px_0px_0px_#1E293B] active:translate-y-0.5 active:shadow-none transition-all cursor-pointer text-center" | |
| > | |
| <ExternalLink className="w-3.5 h-3.5" /> | |
| Google Doc | |
| </a> | |
| <button | |
| onClick={() => { | |
| onSelectProblemForReview(prob.id); | |
| setSelectedNum(null); | |
| }} | |
| className="flex-1 flex items-center justify-center gap-1.5 py-2 border-[3px] border-ink-black bg-[#ffe24c] hover:bg-yellow-300 text-xs font-bold text-ink-black shadow-[2px_2px_0px_0px_#1E293B] active:translate-y-0.5 active:shadow-none transition-all cursor-pointer text-center" | |
| > | |
| <Star className="w-3.5 h-3.5" /> | |
| Review Problem | |
| </button> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| ) : ( | |
| <div className="p-4 bg-white border-[3px] border-ink-black shadow-[3px_3px_0px_0px_rgba(30,41,59,1)] space-y-4"> | |
| <div className="flex items-start gap-3 text-xs font-bold text-ink-black leading-relaxed"> | |
| <Info className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" /> | |
| <div> | |
| <h4 className="font-bold text-ink-black m-0">No Local Note Synced</h4> | |
| <p className="text-[10px] text-trash-gray mt-1 leading-normal font-semibold"> | |
| You haven't synced notes for LeetCode #{selectedNum} yet. Click "Sync Google Drive" to import your progress notes. | |
| </p> | |
| </div> | |
| </div> | |
| <div className="space-y-2 pt-2 border-t border-slate-200"> | |
| <span className="text-[8px] font-arcade text-trash-gray uppercase font-bold block">External Search Shortcuts:</span> | |
| <a | |
| href={`https://leetcode.com/problemset/?search=${selectedNum}`} | |
| target="_blank" | |
| rel="noreferrer" | |
| className="w-full flex items-center justify-between p-2.5 bg-white border-[3px] border-ink-black text-xs font-bold text-ink-black hover:bg-slate-100 shadow-[2px_2px_0px_0px_#1E293B] active:translate-y-0.5 active:shadow-none transition-all cursor-pointer" | |
| > | |
| <span className="flex items-center gap-2"> | |
| <ExternalLink className="w-4 h-4 text-emerald-500" /> | |
| Search LeetCode | |
| </span> | |
| <ArrowLeft className="w-3.5 h-3.5 rotate-180 text-trash-gray" /> | |
| </a> | |
| <a | |
| href={`https://www.google.com/search?q=leetcode+${selectedNum}`} | |
| target="_blank" | |
| rel="noreferrer" | |
| className="w-full flex items-center justify-between p-2.5 bg-white border-[3px] border-ink-black text-xs font-bold text-ink-black hover:bg-slate-100 shadow-[2px_2px_0px_0px_#1E293B] active:translate-y-0.5 active:shadow-none transition-all cursor-pointer" | |
| > | |
| <span className="flex items-center gap-2"> | |
| <Search className="w-4 h-4 text-primary" /> | |
| Search Google | |
| </span> | |
| <ArrowLeft className="w-3.5 h-3.5 rotate-180 text-trash-gray" /> | |
| </a> | |
| </div> | |
| </div> | |
| )} | |
| {/* Completion Toggle */} | |
| <div className="mt-4 pt-3 border-t-2 border-dashed border-ink-black flex flex-col gap-2"> | |
| <span className="text-[8px] font-arcade text-trash-gray font-bold uppercase block">Quick Action</span> | |
| <button | |
| onClick={() => handleToggleCompletion(selectedNum)} | |
| disabled={toggleLoading} | |
| className={`w-full py-3 border-[3px] border-ink-black font-arcade text-[10px] font-bold tracking-wide uppercase flex items-center justify-center gap-2 cursor-pointer transition-all shadow-[3px_3px_0px_0px_#1E293B] active:translate-y-0.5 active:shadow-none disabled:opacity-50 ${ | |
| completions.has(selectedNum) | |
| ? 'bg-red-100 text-red-600 hover:bg-red-200' | |
| : 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' | |
| }`} | |
| > | |
| {completions.has(selectedNum) ? ( | |
| <> | |
| <XCircle className="w-4.5 h-4.5" /> | |
| UNMARK AS COMPLETED | |
| </> | |
| ) : ( | |
| <> | |
| <CheckCircle className="w-4.5 h-4.5" /> | |
| MARK AS COMPLETED | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |