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/opportunities/page.tsx b/src/app/opportunities/page.tsx new file mode 100644 index 00000000..4a95a97c --- /dev/null +++ b/src/app/opportunities/page.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import OpportunityDashboard from '@/components/features/OpportunityDashboard'; + +export const metadata = { + title: 'Opportunities Hub | DevPath', + description: 'Explore career opportunities, developer internships, and hackathons with deadline countdowns and dynamic bookmarking.', +}; + +export default function OpportunitiesPage() { + return ( +
+
+ {/* Page Heading banner */} +
+

+ Career & Developer Opportunities +

+

+ Apply to top-tier internship openings, hackathons, and fellowship cohorts. + Pin key dates and visualize live deadline countdowns below. +

+
+ + {/* Dashboard Component */} + +
+
+ ); +} 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/OpportunityDashboard.tsx b/src/components/features/OpportunityDashboard.tsx new file mode 100644 index 00000000..7cf09249 --- /dev/null +++ b/src/components/features/OpportunityDashboard.tsx @@ -0,0 +1,427 @@ +'use client'; + +import React, { useState, useEffect, useMemo } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Bookmark, + Calendar, + Grid, + List, + Search, + SlidersHorizontal, + Clock, + Building, + Check, + AlertTriangle, + ArrowUpDown, +} from 'lucide-react'; +import { useBookmarks, BookmarkItem } from '@/hooks/useBookmarks'; + +const MOCK_OPPORTUNITIES: BookmarkItem[] = [ + { + id: 'opp-1', + title: 'Open Source Contributor', + description: 'GSoC is a global program focused on bringing student developers into open source software development.', + type: 'opportunity', + company: 'Google', + deadline: '2026-06-25T23:59:59Z', + tags: ['Open Source', 'Remote', 'Stipend'], + color: 'linear-gradient(135deg, #ea4335, #c5221f)', + }, + { + id: 'opp-2', + title: 'Software Engineering Intern', + description: "Join Meta's product teams to build technologies that help people connect, find communities, and grow businesses.", + type: 'opportunity', + company: 'Meta', + deadline: '2026-06-20T23:59:59Z', + tags: ['Internship', 'Frontend', 'Backend'], + color: 'linear-gradient(135deg, #0080ff, #0055b3)', + }, + { + id: 'opp-3', + title: 'Hackathon Participant', + description: 'Our annual 48-hour hackathon to build open-source projects for community welfare. Compete for cash prizes and mentorship.', + type: 'opportunity', + company: 'DevPath', + deadline: '2026-06-18T18:00:00Z', + tags: ['Hackathon', 'Community', 'Prizes'], + color: 'linear-gradient(135deg, #10b981, #047857)', + }, + { + id: 'opp-4', + title: 'GitHub Octernship Fellow', + description: 'The GitHub Octernships program connects students with industry partners for paid internship opportunities.', + type: 'opportunity', + company: 'GitHub', + deadline: '2026-07-15T23:59:59Z', + tags: ['Fellowship', 'Remote', 'Paid'], + color: 'linear-gradient(135deg, #24292e, #1a1e22)', + }, + { + id: 'opp-5', + title: 'Developer Participant', + description: 'Build next-generation payment integrations and financial tools using Stripe API.', + type: 'opportunity', + company: 'Stripe', + deadline: '2026-06-01T23:59:59Z', + tags: ['Hackathon', 'API', 'Payments'], + color: 'linear-gradient(135deg, #635bff, #4339ca)', + }, +]; + +interface DeadlineStatus { + text: string; + status: 'expired' | 'closing-today' | 'closing-tomorrow' | 'upcoming'; + daysLeft: number; +} + +const calculateDeadlineStatus = (deadlineStr: string): DeadlineStatus => { + const deadline = new Date(deadlineStr).getTime(); + const now = Date.now(); + const diff = deadline - now; + + if (diff <= 0) { + return { text: 'Expired', status: 'expired', daysLeft: -1 }; + } + + const oneDay = 24 * 60 * 60 * 1000; + const daysLeft = diff / oneDay; + + if (daysLeft <= 1) { + return { text: 'Closing today', status: 'closing-today', daysLeft }; + } else if (daysLeft <= 2) { + return { text: 'Closing tomorrow', status: 'closing-tomorrow', daysLeft }; + } else { + return { text: `${Math.ceil(daysLeft)} days left`, status: 'upcoming', daysLeft }; + } +}; + +export default function OpportunityDashboard() { + const { bookmarks, toggleBookmark, isBookmarked } = useBookmarks(); + const [activeTab, setActiveTab] = useState<'explore' | 'bookmarked'>('explore'); + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); + const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState<'deadline' | 'recent' | 'alpha'>('deadline'); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + // Filter bookmarked opportunities to match our schema + const bookmarkedOpportunities = useMemo(() => { + return bookmarks.filter((b) => b.type === 'opportunity'); + }, [bookmarks]); + + // Combine mock data with bookmark updates to display correct saved states + const opportunitiesSource = useMemo(() => { + if (activeTab === 'bookmarked') { + return bookmarkedOpportunities; + } + return MOCK_OPPORTUNITIES; + }, [activeTab, bookmarkedOpportunities]); + + // Handle Search & Filter logic + const processedOpportunities = useMemo(() => { + let result = [...opportunitiesSource]; + + // Search query match + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + result = result.filter( + (opp) => + opp.title.toLowerCase().includes(query) || + opp.company?.toLowerCase().includes(query) || + opp.tags?.some((t) => t.toLowerCase().includes(query)) + ); + } + + // Sort matching configuration + result.sort((a, b) => { + if (sortBy === 'deadline') { + const timeA = a.deadline ? new Date(a.deadline).getTime() : Infinity; + const timeB = b.deadline ? new Date(b.deadline).getTime() : Infinity; + return timeA - timeB; + } + if (sortBy === 'recent') { + const bookmarkedA = a.bookmarkedAt ?? 0; + const bookmarkedB = b.bookmarkedAt ?? 0; + // Most recent first + return bookmarkedB - bookmarkedA; + } + if (sortBy === 'alpha') { + return a.title.localeCompare(b.title); + } + return 0; + }); + + return result; + }, [opportunitiesSource, searchQuery, sortBy]); + + const handleBookmarkToggle = (opp: BookmarkItem) => { + const isSaved = isBookmarked(opp.id); + if (isSaved) { + toggleBookmark(opp); + } else { + toggleBookmark({ + ...opp, + bookmarkedAt: Date.now(), + }); + } + }; + + return ( +
+ {/* Header and Controls */} +
+
+
+

+ + Opportunity Hub +

+

+ Track applications, countdown deadlines, and save high-value career opportunities. +

+
+ + {/* View and Sorting Actions */} +
+ {/* Search Bar */} +
+ + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2 bg-slate-950/80 border border-slate-800 focus:border-primary/50 focus:outline-none rounded-xl text-xs text-white placeholder:text-slate-600 transition-colors" + /> +
+ + {/* Sort Dropdown */} +
+ + +
+ + {/* Grid/List toggler */} +
+ + +
+
+
+ + {/* Tabs */} +
+ + +
+ + {/* Dashboard Grid/List */} + + {processedOpportunities.length === 0 ? ( + +
+ +
+

No Opportunities Found

+

+ {activeTab === 'bookmarked' + ? 'Save opportunities from the Explore tab to track deadlines here.' + : 'No opportunities match your current filter query.'} +

+
+ ) : ( + + {processedOpportunities.map((opp) => { + const saved = isBookmarked(opp.id); + const status = opp.deadline ? calculateDeadlineStatus(opp.deadline) : null; + + // Deadline badge styling configuration + let badgeColor = 'bg-slate-900 text-slate-400 border-slate-800'; + if (status) { + if (status.status === 'expired') { + badgeColor = 'bg-red-500/10 text-red-400 border-red-500/20'; + } else if (status.status === 'closing-today') { + badgeColor = 'bg-orange-500/10 text-orange-400 border-orange-500/20 animate-pulse'; + } else if (status.status === 'closing-tomorrow') { + badgeColor = 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20'; + } else { + badgeColor = 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20'; + } + } + + return ( + + {/* Accent Side Line */} +
+ + {/* Body Content */} +
+ {/* Header Row: Company and Title */} +
+ + + {opp.company} + +

+ {opp.title} +

+
+ +

+ {opp.description} +

+ + {/* Tags */} +
+ {opp.tags?.map((tag) => ( + + {tag} + + ))} +
+
+ + {/* Action Footer (List layout alignment) */} +
+ {/* Deadline & Countdown */} + {opp.deadline && ( +
+
+ + + {new Date(opp.deadline).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + +
+ {mounted ? ( + + + {status?.text} + + ) : ( +
+ )} +
+ )} + + {/* Bookmark Toggle Button */} + +
+
+ ); + })} +
+ )} +
+
+ ); +} 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..822701d2 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, @@ -34,6 +34,7 @@ const navLinks = [ { href: '/community', label: 'Community' }, { href: '/resources', label: 'Resources' }, { href: '/events', label: 'Events' }, + { href: '/opportunities', label: 'Opportunities' }, { href: '/opensource', label: 'Open Source' }, { href: '/team', label: 'Team' }, ]; @@ -49,11 +50,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/useBookmarks.ts b/src/hooks/useBookmarks.ts index 0ba63671..5fa57b22 100644 --- a/src/hooks/useBookmarks.ts +++ b/src/hooks/useBookmarks.ts @@ -4,9 +4,13 @@ export interface BookmarkItem { id: string; title: string; description: string; - type: 'roadmap' | 'project'; + type: 'roadmap' | 'project' | 'opportunity'; color?: string; path?: string; + company?: string; + deadline?: string; + tags?: string[]; + bookmarkedAt?: number; } const listeners = new Set<() => void>(); 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]); +}