diff --git a/package-lock.json b/package-lock.json index ddc5ad65..581fdefd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18823,7 +18823,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/src/app/roadmaps/[id]/page.tsx b/src/app/roadmaps/[id]/page.tsx new file mode 100644 index 00000000..0eff0611 --- /dev/null +++ b/src/app/roadmaps/[id]/page.tsx @@ -0,0 +1,149 @@ +import { Metadata } from 'next'; +import SkillTreeVisualizer from '@/components/features/SkillTreeVisualizer'; +import ComingSoonRoadmap from '@/components/features/ComingSoonRoadmap'; +import React from 'react'; +import Link from 'next/link'; +import { ArrowLeft } from 'lucide-react'; + +type Props = { + params: Promise<{ id: string }>; +}; + +// Disable dynamic routes since this is a static build +export const dynamicParams = false; + +interface RoadmapConfig { + title: string; + description: string; + techDetails: string; + isAvailable: boolean; + visualizerPath?: 'Frontend' | 'Backend'; +} + +const ROADMAPS_CONFIG: Record = { + frontend: { + title: 'Frontend Developer Roadmap', + description: 'Master modern frontend development with our curated step-by-step Frontend developer roadmap. Learn HTML/CSS, JavaScript, React, and Next.js.', + techDetails: 'HTML/CSS, JavaScript, React, Next.js', + isAvailable: true, + visualizerPath: 'Frontend', + }, + backend: { + title: 'Backend Developer Roadmap', + description: 'Learn backend engineering with our curated backend roadmap. Master databases, Node.js, and API development.', + techDetails: 'Databases, Node.js, API design', + isAvailable: true, + visualizerPath: 'Backend', + }, + devops: { + title: 'DevOps Mastery Roadmap', + description: 'DevOps learning path: Docker, Kubernetes, CI/CD, and AWS infrastructure.', + techDetails: 'Docker & Kubernetes, CI/CD Pipelines, AWS Infrastructure', + isAvailable: false, + }, + 'python-ai': { + title: 'Python for AI Roadmap', + description: 'Learn AI and machine learning: PyTorch, Neural Networks, and LLM Integration.', + techDetails: 'PyTorch Fundamentals, Neural Networks, LLM Integration', + isAvailable: false, + }, + 'full-stack-react': { + title: 'Full Stack React Roadmap', + description: 'Master Next.js App Router, Server Actions, PostgreSQL, and Prisma.', + techDetails: 'Next.js App Router, Server Actions, PostgreSQL & Prisma', + isAvailable: false, + }, + 'web3-development': { + title: 'Web3 Development Roadmap', + description: 'Step into Web3: Solidity Smart Contracts, Ethers.js, and DApp Architecture.', + techDetails: 'Solidity Smart Contracts, Ethers.js, DApp Architecture', + isAvailable: false, + }, +}; + +export async function generateStaticParams() { + return Object.keys(ROADMAPS_CONFIG).map((id) => ({ + id, + })); +} + +export async function generateMetadata({ params }: Props): Promise { + const resolvedParams = await params; + const config = ROADMAPS_CONFIG[resolvedParams.id]; + + if (!config) { + return { + title: 'Roadmap Not Found | DevPath', + description: 'The requested roadmap could not be found.', + }; + } + + const fullTitle = `${config.title} | DevPath`; + + return { + title: fullTitle, + description: config.description, + openGraph: { + title: fullTitle, + description: config.description, + url: `https://devpath.community/roadmaps/${resolvedParams.id}`, + siteName: 'DevPath', + images: [ + { + url: 'https://devpath.community/og-roadmaps.png', + width: 1200, + height: 630, + alt: `${config.title} illustration on DevPath`, + }, + ], + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title: fullTitle, + description: config.description, + images: ['https://devpath.community/og-roadmaps.png'], + }, + }; +} + +export default async function RoadmapPage({ params }: Props) { + const resolvedParams = await params; + const config = ROADMAPS_CONFIG[resolvedParams.id]; + + if (!config) { + return ( +
+

Roadmap Not Found

+

The requested roadmap does not exist.

+ + Return to Learning Paths + +
+ ); + } + + if (!config.isAvailable) { + return ; + } + + return ( +
+
+
+ + + Back to Paths + +

{config.title}

+

{config.description}

+
+ + +
+
+ ); +} diff --git a/src/components/features/ComingSoonRoadmap.tsx b/src/components/features/ComingSoonRoadmap.tsx new file mode 100644 index 00000000..653730ff --- /dev/null +++ b/src/components/features/ComingSoonRoadmap.tsx @@ -0,0 +1,116 @@ +'use client'; + +import React, { useState } from 'react'; +import { Bell, ArrowLeft, Mail, Sparkles } from 'lucide-react'; +import Link from 'next/link'; + +interface ComingSoonRoadmapProps { + title: string; + techDetails: string; +} + +export default function ComingSoonRoadmap({ title, techDetails }: ComingSoonRoadmapProps) { + const [email, setEmail] = useState(''); + const [isSubmitted, setIsSubmitted] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (email.trim()) { + setIsSubmitted(true); + } + }; + + return ( +
+ {/* Background glow effects */} +
+
+ +
+ {/* Back Link */} + + + Back to Learning Paths + + + {/* Card */} +
+
+ + {!isSubmitted ? ( +
+
+
+ + Roadmap Under Construction +
+

+ {title} +

+

+ We are currently crafting a comprehensive curriculum for {title}. This roadmap will outline step-by-step topics, milestones, and guided project recommendations. +

+
+ + {/* Stack Preview */} +
+ Curriculum Highlights +

{techDetails}

+
+ + {/* Form */} +
+
+ +
+ + setEmail(e.target.value)} + className="w-full pl-11 pr-4 py-3 bg-slate-950 border border-slate-800 rounded-xl text-sm focus:border-primary focus:outline-none transition-colors text-white placeholder:text-slate-600" + /> +
+
+ +
+
+ ) : ( +
+
+ ✨ +
+
+

You're on the list!

+

+ Thank you! We have registered your email {email}. We will reach out as soon as the {title} learning path is published. +

+
+ +
+ )} +
+
+
+ ); +} diff --git a/src/components/features/SkillTreeVisualizer.tsx b/src/components/features/SkillTreeVisualizer.tsx index eea6014c..35e20a88 100644 --- a/src/components/features/SkillTreeVisualizer.tsx +++ b/src/components/features/SkillTreeVisualizer.tsx @@ -1,11 +1,12 @@ 'use client'; -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import styles from "./SkillTreeVisualizer.module.css"; import { useLearningProgress } from "@/hooks/useLearningProgress"; import { useAuth } from "@/context/AuthContext"; import { CheckSquare, Square, Flame, Target } from "lucide-react"; import { motion } from "framer-motion"; +import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; type SkillNode = { id: string; @@ -87,18 +88,50 @@ const pathsData: Record = { ], }; -export default function SkillTreeVisualizer() { +export default function SkillTreeVisualizer({ initialPath }: { initialPath?: "Frontend" | "Backend" } = {}) { const { user } = useAuth(); const { completedNodes, loading, toggleNode, isNodeCompleted } = useLearningProgress(); - const [activePath, setActivePath] = useState<"Frontend" | "Backend">("Frontend"); + const [activePath, setActivePath] = useState<"Frontend" | "Backend">(initialPath || "Frontend"); const [selectedNode, setSelectedNode] = useState(null); const nodes = pathsData[activePath]; + // Sync active path with prop updates + useEffect(() => { + if (initialPath) { + setActivePath(initialPath); + } + }, [initialPath]); + // Dynamic progress calculation const completedCount = nodes.filter(node => isNodeCompleted(activePath, node.id)).length; const progressPercent = nodes.length > 0 ? Math.round((completedCount / nodes.length) * 100) : 0; + // Bind local arrow key shortcuts for node selection cycling + useKeyboardShortcuts({ + arrowright: () => { + if (nodes.length === 0) return; + const currentIndex = selectedNode ? nodes.findIndex((n) => n.id === selectedNode.id) : -1; + const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % nodes.length; + setSelectedNode(nodes[nextIndex]); + }, + arrowleft: () => { + if (nodes.length === 0) return; + const currentIndex = selectedNode ? nodes.findIndex((n) => n.id === selectedNode.id) : -1; + const prevIndex = currentIndex === -1 ? nodes.length - 1 : (currentIndex - 1 + nodes.length) % nodes.length; + setSelectedNode(nodes[prevIndex]); + }, + }); + + // Listen for the escape close-all-overlays event to close the side drawer + useEffect(() => { + const handleCloseAll = () => { + setSelectedNode(null); + }; + window.addEventListener('close-all-overlays', handleCloseAll); + return () => window.removeEventListener('close-all-overlays', handleCloseAll); + }, []); + return (
diff --git a/src/components/gamification/Leaderboard.tsx b/src/components/gamification/Leaderboard.tsx index 913e2248..764b1311 100644 --- a/src/components/gamification/Leaderboard.tsx +++ b/src/components/gamification/Leaderboard.tsx @@ -1,8 +1,10 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ 'use client'; import { useEffect, useState } from 'react'; import { collection, query, orderBy, limit, getDocs } from 'firebase/firestore'; import { db } from '@/lib/firebase'; import { getTier } from '@/lib/gamification'; +import Image from 'next/image'; export function Leaderboard() { const [users, setUsers] = useState([]); @@ -48,9 +50,12 @@ export function Leaderboard() { #{i + 1} - {u.displayName {u.displayName ?? 'Anonymous'} diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index 88d716bf..c6b40ee5 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -1,10 +1,10 @@ 'use client'; -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import Image from 'next/image'; -import { motion, AnimatePresence } from 'framer-motion'; +import { motion, AnimatePresence, useScroll, useSpring } from 'framer-motion'; import { Flame, Github, @@ -49,11 +49,27 @@ export default function Navbar() { const pathname = usePathname(); + useEffect(() => { + const handleCloseAll = () => { + setMobileMenuOpen(false); + setBookmarkDrawerOpen(false); + }; + window.addEventListener('close-all-overlays', handleCloseAll); + return () => window.removeEventListener('close-all-overlays', handleCloseAll); + }, []); + const { currentStreak } = useMemo( () => calculateStreak(user?.loginDates), [user?.loginDates] ); + const { scrollYProgress } = useScroll(); + const scaleX = useSpring(scrollYProgress, { + stiffness: 100, + damping: 30, + restDelta: 0.001, + }); + if (pathname === '/ap') return null; const toggleMobileMenu = () => { diff --git a/src/components/layout/RouteAwareChrome.tsx b/src/components/layout/RouteAwareChrome.tsx index b7b34d14..cd155706 100644 --- a/src/components/layout/RouteAwareChrome.tsx +++ b/src/components/layout/RouteAwareChrome.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useState } from 'react'; import { usePathname } from 'next/navigation'; import MaintenanceBlocker from '@/components/layout/MaintenanceBlocker'; @@ -11,6 +12,8 @@ import PageWrapper from '@/components/layout/PageWrapper'; import { FloatingAssistant } from '@/components/assistant/floating-assistant'; import { ToastContainer } from '@/components/ui/ToastContainer'; import SearchModal from '@/components/layout/SearchModal'; +import ShortcutLegend from '@/components/layout/ShortcutLegend'; +import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'; export default function RouteAwareChrome({ children, @@ -19,6 +22,13 @@ export default function RouteAwareChrome({ }) { const pathname = usePathname(); const isAuthRoute = pathname === '/login' || pathname === '/signup'; + const [isLegendOpen, setLegendOpen] = useState(false); + + // Bind global navigation shortcuts + useKeyboardShortcuts({ + '?': () => setLegendOpen((prev) => !prev), + 'escape': () => setLegendOpen(false), + }); return ( <> @@ -34,6 +44,7 @@ export default function RouteAwareChrome({ {!isAuthRoute && } + setLegendOpen(false)} /> ); } diff --git a/src/components/layout/ShortcutLegend.tsx b/src/components/layout/ShortcutLegend.tsx new file mode 100644 index 00000000..a920dce3 --- /dev/null +++ b/src/components/layout/ShortcutLegend.tsx @@ -0,0 +1,170 @@ +'use client'; + +import { motion, AnimatePresence } from 'framer-motion'; +import { X, Keyboard, Search, CornerDownLeft, ArrowLeftRight, HelpCircle } from 'lucide-react'; + +interface ShortcutLegendProps { + isOpen: boolean; + onClose: () => void; +} + +interface ShortcutItem { + keys: string[]; + description: string; + icon?: any; +} + +interface ShortcutSection { + title: string; + items: ShortcutItem[]; +} + +const SHORTCUT_SECTIONS: ShortcutSection[] = [ + { + title: 'Global Shortcuts', + items: [ + { + keys: ['Ctrl', 'K'], + description: 'Toggle Global Search Input', + icon: Search, + }, + { + keys: ['Cmd', 'K'], + description: 'Toggle Global Search Input (macOS)', + icon: Search, + }, + { + keys: ['?'], + description: 'Toggle Keyboard Shortcut Legend', + icon: HelpCircle, + }, + { + keys: ['Esc'], + description: 'Close Open Modals / Side Drawers / Search', + icon: X, + }, + ], + }, + { + title: 'Roadmap Navigation', + items: [ + { + keys: ['→'], + description: 'Cycle focused roadmap selection forward', + icon: ArrowLeftRight, + }, + { + keys: ['←'], + description: 'Cycle focused roadmap selection backward', + icon: ArrowLeftRight, + }, + ], + }, +]; + +export default function ShortcutLegend({ isOpen, onClose }: ShortcutLegendProps) { + return ( + + {isOpen && ( + <> + {/* Backdrop with premium blur */} + + + {/* Modal Container */} +
+ + {/* Top accent glow line */} +
+ + {/* Header */} +
+
+
+ +
+
+

Keyboard Shortcuts

+

Accessibility & navigation hotkeys

+
+
+ +
+ + {/* Content body */} +
+ {SHORTCUT_SECTIONS.map((section) => ( +
+

+ {section.title} +

+
+ {section.items.map((item, idx) => { + const Icon = item.icon; + return ( +
+
+ {Icon && ( + + )} + + {item.description} + +
+
+ {item.keys.map((key, kIdx) => ( + + {kIdx > 0 && +} + + {key} + + + ))} +
+
+ ); + })} +
+
+ ))} +
+ + {/* Footer */} +
+ Press ? to toggle + + + Master Mode Active + +
+
+
+ + )} +
+ ); +} diff --git a/src/components/profile/DevCard.tsx b/src/components/profile/DevCard.tsx index ebc228d0..1f1a35ce 100644 --- a/src/components/profile/DevCard.tsx +++ b/src/components/profile/DevCard.tsx @@ -360,7 +360,7 @@ export default function DevCard({ user }: { user: any }) { const a = document.createElement('a'); a.href = dataUrl; - a.download = `devcard-${user?.name?.replace(/\s+/g, '-').toLowerCase() ?? 'devcard'}.png`; + a.download = `devcard-${realTimeUser?.name?.replace(/\s+/g, '-').toLowerCase() ?? 'devcard'}.png`; a.click(); showSuccess('DevCard downloaded successfully.'); diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 00000000..31bc2179 --- /dev/null +++ b/src/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,129 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { useSearchOpen, useSetSearchOpen } from '@/stores/ui-store'; + +type ShortcutHandler = (e: KeyboardEvent) => void; + +interface ShortcutConfig { + [key: string]: ShortcutHandler; +} + +/** + * Checks if the event target is inside an input, textarea, or contenteditable element. + */ +const isInputElement = (el: HTMLElement | null): boolean => { + if (!el) return false; + const tagName = el.tagName.toUpperCase(); + return tagName === 'INPUT' || tagName === 'TEXTAREA' || el.isContentEditable; +}; + +/** + * Parses and matches a keyboard event against a shortcut key combo string. + * Example combos: 'ctrl+k', 'meta+k', 'cmd+k', 'escape', 'ArrowLeft', '?', 'shift+/' + */ +const matchesCombo = (combo: string, e: KeyboardEvent): boolean => { + const parts = combo.toLowerCase().split('+'); + const targetKey = parts[parts.length - 1]; + + const wantsCtrl = parts.includes('ctrl'); + const wantsMeta = parts.includes('meta') || parts.includes('cmd'); + const wantsAlt = parts.includes('alt'); + const wantsShift = parts.includes('shift'); + + const hasCtrl = e.ctrlKey; + const hasMeta = e.metaKey; + const hasAlt = e.altKey; + const hasShift = e.shiftKey; + + // Normalize key name matches + const eventKey = e.key.toLowerCase(); + let matchesKey = false; + + if (targetKey === 'cmd') { + // If 'cmd' is specified as the key itself rather than a modifier + matchesKey = eventKey === 'meta' || eventKey === 'os'; + } else if (targetKey === 'ctrl') { + matchesKey = eventKey === 'control'; + } else { + matchesKey = eventKey === targetKey; + } + + return ( + matchesKey && + wantsCtrl === hasCtrl && + wantsMeta === hasMeta && + wantsAlt === hasAlt && + wantsShift === hasShift + ); +}; + +export function useKeyboardShortcuts(customShortcuts?: ShortcutConfig) { + const isSearchOpen = useSearchOpen(); + const setSearchOpen = useSetSearchOpen(); + + // Store handlers in a ref so the listener doesn't need to re-bind when customShortcuts change + const shortcutsRef = useRef(customShortcuts); + useEffect(() => { + shortcutsRef.current = customShortcuts; + }, [customShortcuts]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const activeEl = document.activeElement as HTMLElement | null; + const isTyping = isInputElement(activeEl); + + // Input field isolation check + // Escape key is permitted to bubble to allow closing active modal overlay even when typing in search input + if (isTyping && e.key !== 'Escape') { + return; + } + + // 1. Process custom callbacks passed into the hook first (takes precedence) + if (shortcutsRef.current) { + for (const [combo, handler] of Object.entries(shortcutsRef.current)) { + if (matchesCombo(combo, e)) { + e.preventDefault(); + handler(e); + return; + } + } + } + + // 2. Default Global Mappings (runs if no custom handler matched the combo) + + // Ctrl+K / Cmd+K -> Toggle Search + if (matchesCombo('ctrl+k', e) || matchesCombo('meta+k', e) || matchesCombo('cmd+k', e)) { + e.preventDefault(); + setSearchOpen(!isSearchOpen); + return; + } + + // Escape -> Close all open modal overlays/search/drawers + if (matchesCombo('escape', e)) { + // Close search if open + if (isSearchOpen) { + e.preventDefault(); + setSearchOpen(false); + } + + // Dispatch global broadcast event for side drawers / menus + window.dispatchEvent(new CustomEvent('close-all-overlays')); + + // Programmatically close any DOM elements matching close queries as a robust fallback + const closeButtons = document.querySelectorAll( + 'button[aria-label*="Close" i], button[class*="close" i], button[id*="close" i], [class*="modal" i] button[onClick]' + ); + closeButtons.forEach((btn) => { + (btn as HTMLButtonElement).click(); + }); + return; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isSearchOpen, setSearchOpen]); +}